// Game of Fanorona // David Eppstein, UC Irvine, 11 Jun 1997 // // Search engine control import java.util.*; // thread with controlled access to termination paths // each path should call terminationPath() and only proceed if it returns true abstract class TerminationPathThread extends Thread { boolean terminating = false; synchronized boolean terminationPath() { if (terminating) return false; return terminating = true; } } class Search extends TerminationPathThread { // signal from watcher that it's time to stop searching // although this variable is viewed by multiple threads, // it should be safe to leave it unsynchronized. volatile boolean stop = false; boolean getStop() { return stop; } void setStop(boolean s) { stop = s; } static final int searchBound = 7200; Game game; Board board; SearchLog log; Board firstMove = null; SearchWatcher watcher = null; int ply = 0; Search(Game g, Board b, SearchLog sl, int p) { ply = p; if (ply == 0) watcher = new SearchWatcher(this); board = b; game = g; log = sl; } // Current candidate for best move // Again, assuming assignments are atomic, it should be safe to be unsynchronized. volatile Board move = null; Board getMove() { return move; } void setMove(Board m) { move = m; } // different paths to thread termination // abort - when game has been updated out from under us (called from main applet thread) // done - normal search termination (called from searcher thread) // panic - timeout expired, use partial search results (called from watcher thread) // // note that to avoid timing complications we dont worry about explicitly // terminating the watcher -- that will be done for us when the board is updated void suspendSafely() { try { suspend(); } catch (Throwable e) { } } void makeMove() { Board move = getMove(); if (move == null) move = new Board(board, new MoveGenerator(board).nextElement()); Thread.currentThread().setPriority(Thread.MAX_PRIORITY); // make UI run fast game.move(board, move); } void abort() { if (!terminationPath()) return; log.addItem("Position changed while searching, search aborted"); Board.sequenceNumber++; // terminate currently running search } void done() { if (!terminationPath()) return; makeMove(); board.child = null; // now that search is over, allow gc to free nodes board.principalVariation = null; board.moveGenerator = null; } void panic() { if (!terminationPath()) return; Board.sequenceNumber++; // terminate currently running search log.addItem("Search timed out, making move without finishing search"); makeMove(); } static StringBuffer searchLogBuffer = new StringBuffer(); void pvString(Board b) { boolean alg = game.getParameter(Options.NOTATION_ALGEBRAIC); boolean firstMove = true; for (;;) { if (!b.hasPrincipalVariation) break; else { b = b.principalVariation; MoveLog.stringForMove(searchLogBuffer, b, alg, false, firstMove); } firstMove = false; } } // main search loop public void run() { // make redisplay happen before we start using all the CPU setPriority(MIN_PRIORITY); yield(); // after redisplay, make priority above system maint tasks setPriority(NORM_PRIORITY); // If we have to pass, do so without search if (game.mustPass(board)) { log.addItem("Forced pass"); setMove(new Board(board, 0)); done(); return; } // Test if we are in the first four ply. // // If so, we move arbitrarily rather than passing // to add a little variety to our play // (and also because the search doesn't know about mustPass and // may give a bogus principal variation). if (board.previousPosition == null || board.previousPosition.previousPosition.previousPosition == null) { log.addItem("Choosing first move arbitrarily"); setMove(MoveGenerator.arbitraryMove(board)); done(); return; } // avoid search if we only have one move (or even more if we have zero) MoveGenerator mg = new MoveGenerator(board); if (!mg.hasMoreElements()) return; setMove(new Board(board, mg.nextElement())); if (!mg.hasMoreElements()) { log.addItem("Forced move"); done(); return; } // try endgame database before searching if (Board.endgameDatabase != null && Board.endgameDatabase.ready && Board.endgameDatabase.search(board)) { setMove(board.principalVariation); searchLogBuffer.setLength(0); searchLogBuffer.append("Endgame database eval: "); centsToDollars(board.evaluation); log.addItem(searchLogBuffer.toString()); done(); return; } // set the alarm clock *AFTER* giving UI a chance to catch up on redisplay // and after checking that we don't need to bother searching if (watcher != null) watcher.start(); search(); } static final void centsToDollars(int n) { if (n < 0) { searchLogBuffer.append("-"); n = -n; } int cents = n % 100; searchLogBuffer.append(n/100); if (cents < 10) searchLogBuffer.append(".0"); else searchLogBuffer.append("."); searchLogBuffer.append(cents); } // iterated deepening until we get told to stop static final int ASPIRATION_WINDOW = 50; static int currentEval = 0; void search() { int depth = 1; boolean logDisabled = false; int previousEval = 0; board.bestMove = -1L; int sequenceNumber = ++Board.sequenceNumber; // abort any previously running search while ((ply == 0 || depth <= ply) && !getStop()) { Board.nodeCount = Board.leafCount = 0; if (Board.COLLECT_EXTRA_STATISTICS) Board.boardConsCount = Board.moveGenConsCount = Board.endgameEvalCount = Board.pvChangeCount = 0; if (Hash.COLLECT_STATISTICS) Hash.hits = Hash.misses = Hash.shallow = Hash.badBound = 0; long startTime = System.currentTimeMillis(); // Aspiration search -- start with an artificially narrow [alpha,beta] window // centered on our best guess of the true search value, then repeat if // we fail high or low. This assumes a fail high or low is going to produce // a true evaluation of a (not necessarily) optimal line, rather than just // returning alpha or beta itself, so we don't spend too much time running // through numbers between our initial guess and the true search value. int alpha = currentEval - ASPIRATION_WINDOW/2; int beta = currentEval + ASPIRATION_WINDOW/2; int aspirations = 0; for (;;) { if (sequenceNumber != Board.sequenceNumber) { abort(); return; } board.alphaBeta(depth * Board.PLY, alpha, beta, sequenceNumber); currentEval = board.evaluation; aspirations++; if (currentEval >= beta) { // fail high // alpha = currentEval - 1; // // May lead to infinite loops in some pathological cases? // It shouldn't hurt much to leave alpha alone anyway, the hash table // will take care of speeding up searches at nodes w/values less than currentEval. beta = Integer.MAX_VALUE; } else if (currentEval <= alpha) { // fail low alpha = -Integer.MAX_VALUE; // beta = currentEval + 1; } else break; yield(); } long endTime = System.currentTimeMillis(); // because some important lines in the hash table might have disappeared, // it is possible to see a win and then stop seeing it again. // in this case, we don't want to make a move into a non-winning variation // just because it was returned by a deeper search... if (winning(previousEval) && !between(0,previousEval,currentEval)) currentEval = previousEval; // don't just use principalVariation as move, it will get trashed by the next // search -- instead make another board with the same move else if (board.principalVariation != null) setMove(new Board(board,board.bestMove)); // log search (if in winning line improvement search, only log if interesting) if (!logDisabled || currentEval != previousEval) { previousEval = currentEval; searchLogBuffer.setLength(0); searchLogBuffer.append(depth).append(" ply: "); centsToDollars(currentEval); searchLogBuffer.append(", ").append(Board.nodeCount).append(" nodes, "); searchLogBuffer.append(Board.leafCount).append(" leaves, ").append( Board.nodeCount / (endTime - startTime + 1)).append("k nps"); if (Hash.COLLECT_STATISTICS) { long hashTotal = Hash.hits + Hash.shallow + Hash.badBound + Hash.misses; if (hashTotal > 0) { searchLogBuffer.append(", hash rate ").append((100L*Hash.hits)/hashTotal); searchLogBuffer.append("%-"); searchLogBuffer.append((100L*(Hash.hits+Hash.shallow+Hash.badBound))/hashTotal); searchLogBuffer.append("%"); } } log.addItem(searchLogBuffer.toString()); if (Board.COLLECT_EXTRA_STATISTICS){ searchLogBuffer.setLength(0); searchLogBuffer.append(" ").append(Board.boardConsCount).append(" new boards, "); searchLogBuffer.append(Board.moveGenConsCount).append(" move generators, "); searchLogBuffer.append(Board.pvChangeCount).append(" pv changes, "); searchLogBuffer.append(Board.endgameEvalCount).append(" endgame evals"); log.addItem(searchLogBuffer.toString()); } searchLogBuffer.setLength(0); searchLogBuffer.append(" PV: "); pvString(board); log.addItem(searchLogBuffer.toString()); } // The following line looks safe: if we see a win in k plys, no need to search any deeper. // // if (board.evaluation < -searchBound || board.evaluation > searchBound) done(); // // Or is it? Because of the hash table and search extensions, the first win we see might not // win in the fewest plys, and our opponent might be able to force a repeated sequence of // moves where we see a win in our PV after each search, but it never gets any closer. // In fact, I actually saw this happen. // // So, even if we think it's obvious how to win, we do a deeper search to look for a better // win (fewer plys, or more plys but more of our pieces left at the end). Also, this search // slows us down, so the user can see the moves as they're being made. To avoid ridiculous // streams of long searches in the log, we only log "interesting" searches beyond this point. if (winning(currentEval) && !logDisabled) { log.addItem("Winning line found, searching for better win"); logDisabled = true; } depth++; yield(); // make sure other threads get a chance to run } done(); } // test whether an evaluation is good enough to win static final boolean winning(int eval) { return eval < -searchBound || eval > searchBound; } // return true iff b is in the [closed] interval [a,c]. assumes a != c but not a= b) != (c >= b); } } // Alarm clock for search class SearchWatcher extends TerminationPathThread implements Observer { int searchTime = 1500; // time until we tell search to wake up (msec) public void setSearchTime(int st) { searchTime = st; } public int getSearchTime() { return searchTime; } Search search; SearchWatcher(Search s) { search = s; setPriority(MAX_PRIORITY); } boolean observing = false; synchronized void observe() { observing = true; search.game.addObserver(this); } synchronized void done() { if (observing) search.game.deleteObserver(this); observing = false; stop(); } public void run() { observe(); try { sleep(searchTime); } catch (InterruptedException e) { } search.setStop(true); try { sleep(5*searchTime); } catch (InterruptedException e) { } if (!terminationPath()) return; search.panic(); done(); } public void update(Observable g, Object o) { if (g != search.game) return; // ignore update that's not about the actual game if (o == search.board) return; // Ignore update that started this search running if (!terminationPath()) return; search.abort(); // Mother will start a new one if necessary done(); } }