diff --git a/README.md b/README.md index 7e9560d..d110646 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ It does not directly support the following commands (but you can add them in an - **Dont miss the ShredderChess Annual Barbeque**: This command was in the original specification ... But was a joke. - **register**: As a promoter of open source free sofware, I will not encourage you to develop software that requires registration. - **ponderhit** is not yet implemented. -- Only depth, score, hashfull and pv are implemented in info lines preceeding go reply. +- Only depth, score and pv are implemented in info lines preceding go reply. It also does not recognize commands starting with unknown token (to be honest, it's not very hard to implement but seemed a very bad, error prone, idea to me). @@ -92,4 +92,5 @@ If you do not use the *com.fathzer.jchess.uci.extended* and *com.fathzer.jchess. ## TODO * Verify the engine is protected against strange client behavior (like changing the position during a go request). +* Implement support for multi-PV search. * Implement support for pondering. diff --git a/pom.xml b/pom.xml index d3ea2e2..b94d3b8 100644 --- a/pom.xml +++ b/pom.xml @@ -22,7 +22,7 @@ com.fathzer games-core - 0.0.11-SNAPSHOT + 0.0.12-SNAPSHOT org.junit.jupiter @@ -36,6 +36,11 @@ 4.2.2 test + + org.mockito + mockito-junit-jupiter + 5.14.2 + diff --git a/src/main/java/com/fathzer/jchess/uci/BackgroundTaskManager.java b/src/main/java/com/fathzer/jchess/uci/BackgroundTaskManager.java index c0d524c..e20e762 100644 --- a/src/main/java/com/fathzer/jchess/uci/BackgroundTaskManager.java +++ b/src/main/java/com/fathzer/jchess/uci/BackgroundTaskManager.java @@ -5,21 +5,23 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import com.fathzer.games.util.exec.CustomThreadFactory; +import com.fathzer.jchess.uci.UCI.ThrowingRunnable; + class BackgroundTaskManager implements AutoCloseable { static class Task { private final Consumer logger; - private final Runnable run; + private final ThrowingRunnable run; private final Runnable stopTask; - Task(Runnable task, Runnable stopTask, Consumer logger) { + Task(ThrowingRunnable task, Runnable stopTask, Consumer logger) { this.run = task; this.stopTask = stopTask; this.logger = logger; } } - - - private final ExecutorService exec = Executors.newFixedThreadPool(1); + + private final ExecutorService exec = Executors.newFixedThreadPool(1, new CustomThreadFactory(()->"Stoppable Tasks", true)); private final AtomicReference current = new AtomicReference<>(); boolean doBackground(Task task) { diff --git a/src/main/java/com/fathzer/jchess/uci/Engine.java b/src/main/java/com/fathzer/jchess/uci/Engine.java index 58da089..dfcb316 100644 --- a/src/main/java/com/fathzer/jchess/uci/Engine.java +++ b/src/main/java/com/fathzer/jchess/uci/Engine.java @@ -105,7 +105,7 @@ default List> getOptions() { *
  • The supplier should be cooperative with the stopper; It should end as quickly as possible when stopper is invoked and always return a move.
  • * * @param params The go parameters. - * @return A long running task able to compute the engine's move. + * @return A task able to compute the engine's move. */ - LongRunningTask go(GoParameters params); + StoppableTask go(GoParameters params); } diff --git a/src/main/java/com/fathzer/jchess/uci/StoppableTask.java b/src/main/java/com/fathzer/jchess/uci/StoppableTask.java index 62e900c..4c1b9c4 100644 --- a/src/main/java/com/fathzer/jchess/uci/StoppableTask.java +++ b/src/main/java/com/fathzer/jchess/uci/StoppableTask.java @@ -1,24 +1,15 @@ package com.fathzer.jchess.uci; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.Callable; -/** A task that will be executed in the background of UCI interface. +/** A task that can be stopped. + *
    Please note that stoppable is different from {@link java.util.concurrent.Cancellable} + * When a task is cancelled, it produces no result (for example, a {@link java.util.concurrent.CancellationException} if the task was cancelled). + * The typical use case of a StoppableTask is a best move search engine that performs iterative deepening. You may want to stop its deepening and get the current result. * @param The result of the task */ -public abstract class LongRunningTask { - private final AtomicBoolean stopped; - - protected LongRunningTask() { - stopped = new AtomicBoolean(); - } - - public abstract T get(); - - public boolean isStopped() { - return stopped.get(); - } - - public void stop() { - stopped.set(true); - } +public interface StoppableTask extends Callable { + /** Stops the task. + */ + void stop(); } diff --git a/src/main/java/com/fathzer/jchess/uci/UCI.java b/src/main/java/com/fathzer/jchess/uci/UCI.java index 30ccde5..e2b8a7c 100644 --- a/src/main/java/com/fathzer/jchess/uci/UCI.java +++ b/src/main/java/com/fathzer/jchess/uci/UCI.java @@ -37,7 +37,14 @@ */ public class UCI implements Runnable, AutoCloseable { public static final String INIT_COMMANDS_PROPERTY_FILE = "uciInitCommands"; - + + @FunctionalInterface + /** A runnable that can throw an exception. + */ + public interface ThrowingRunnable { + void run() throws Exception; + } + private static final BufferedReader IN = new BufferedReader(new InputStreamReader(System.in)); private static final String MOVES = "moves"; private static final String ENGINE_CMD = "engine"; @@ -196,7 +203,7 @@ private String getFEN(Collection tokens) { * @param logger Where to send the exceptions * @return true if the task is launched, false if another task is already running. */ - protected boolean doBackground(Runnable task, Runnable stopper, Consumer logger) { + protected boolean doBackground(ThrowingRunnable task, Runnable stopper, Consumer logger) { return backTasks.doBackground(new Task(task, stopper, logger)); } @@ -206,9 +213,9 @@ protected void doGo(Deque tokens) { } else { final Optional goOptions = parse(GoParameters::new, GoParameters.PARSER, tokens); if (goOptions.isPresent()) { - final LongRunningTask task = engine.go(goOptions.get()); + final StoppableTask task = engine.go(goOptions.get()); final boolean started = doBackground(() -> { - final GoReply goReply = task.get(); + final GoReply goReply = task.call(); final Optional mainInfo = goReply.getMainInfoString(); if (mainInfo.isPresent()) { this.out(mainInfo.get()); diff --git a/src/main/java/com/fathzer/jchess/uci/extended/ExtendedUCI.java b/src/main/java/com/fathzer/jchess/uci/extended/ExtendedUCI.java index 7df7187..3fc2a41 100644 --- a/src/main/java/com/fathzer/jchess/uci/extended/ExtendedUCI.java +++ b/src/main/java/com/fathzer/jchess/uci/extended/ExtendedUCI.java @@ -15,7 +15,7 @@ import com.fathzer.games.perft.PerfTTestData; import com.fathzer.games.perft.TestableMoveGeneratorBuilder; import com.fathzer.jchess.uci.Engine; -import com.fathzer.jchess.uci.LongRunningTask; +import com.fathzer.jchess.uci.StoppableTask; import com.fathzer.jchess.uci.UCI; import com.fathzer.jchess.uci.parameters.PerfStatsParameters; import com.fathzer.jchess.uci.parameters.PerfTParameters; @@ -67,22 +67,16 @@ protected void doPerft(Deque tokens) { final Optional params = parse(PerfTParameters::new, PerfTParameters.PARSER, tokens); if (params.isPresent()) { @SuppressWarnings("unchecked") - final LongRunningTask> task = new PerftTask<>((MoveGeneratorSupplier)engine, params.get()); + final StoppableTask> task = new PerftTask<>((MoveGeneratorSupplier)engine, params.get()); if (!doBackground(() -> doPerft(task, params.get()), task::stop, e -> err(PERFT_COMMAND,e))) { debug("Engine is already working"); } } } - -// private void background(Runnable task, Runnable stopper) { -// if (!doBackground(task, stopper, e -> out(e,0))) { -// debug("Engine is already working"); -// } -// } - private void doPerft(LongRunningTask> task, PerfTParameters params) { + private void doPerft(StoppableTask> task, PerfTParameters params) throws Exception { final long start = System.currentTimeMillis(); - final PerfTResult result = task.get(); + final PerfTResult result = task.call(); final long duration = System.currentTimeMillis() - start; if (result.isInterrupted()) { diff --git a/src/main/java/com/fathzer/jchess/uci/extended/PerftTask.java b/src/main/java/com/fathzer/jchess/uci/extended/PerftTask.java index 80294f4..fc47d23 100644 --- a/src/main/java/com/fathzer/jchess/uci/extended/PerftTask.java +++ b/src/main/java/com/fathzer/jchess/uci/extended/PerftTask.java @@ -6,10 +6,10 @@ import com.fathzer.games.perft.PerfT; import com.fathzer.games.perft.PerfTResult; import com.fathzer.games.util.exec.ContextualizedExecutor; -import com.fathzer.jchess.uci.LongRunningTask; +import com.fathzer.jchess.uci.StoppableTask; import com.fathzer.jchess.uci.parameters.PerfTParameters; -class PerftTask extends LongRunningTask> { +class PerftTask implements StoppableTask> { private PerfT perft; private final Supplier> engine; private final PerfTParameters params; @@ -21,7 +21,7 @@ public PerftTask(Supplier> engine, PerfTParameters params) { } @Override - public PerfTResult get() { + public PerfTResult call() { try (ContextualizedExecutor> exec = new ContextualizedExecutor<>(params.getParallelism())) { this.perft = new PerfT<>(exec); if (params.isLegal()) { @@ -36,7 +36,6 @@ public PerfTResult get() { @Override public void stop() { - super.stop(); perft.interrupt(); } diff --git a/src/main/java/com/fathzer/jchess/uci/helper/AbstractEngine.java b/src/main/java/com/fathzer/jchess/uci/helper/AbstractEngine.java index fccf93f..755565d 100644 --- a/src/main/java/com/fathzer/jchess/uci/helper/AbstractEngine.java +++ b/src/main/java/com/fathzer/jchess/uci/helper/AbstractEngine.java @@ -23,9 +23,9 @@ import com.fathzer.jchess.uci.GoReply.Info; import com.fathzer.jchess.uci.GoReply.MateScore; import com.fathzer.jchess.uci.GoReply.Score; +import com.fathzer.jchess.uci.StoppableTask; import com.fathzer.jchess.uci.ClassicalOptions; import com.fathzer.jchess.uci.Engine; -import com.fathzer.jchess.uci.LongRunningTask; import com.fathzer.jchess.uci.UCIMove; import com.fathzer.jchess.uci.extended.MoveGeneratorSupplier; import com.fathzer.jchess.uci.extended.MoveToUCIConverter; @@ -64,7 +64,8 @@ public abstract class AbstractEngine> implements E */ protected AbstractEngine(IterativeDeepeningEngine engine, TimeManager timeManager) { this.engine = engine; - this.ttSizeInMB = engine.getTranspositionTable().getMemorySizeMB(); + final TranspositionTable transpositionTable = engine.getTranspositionTable(); + this.ttSizeInMB = transpositionTable==null ? -1 : transpositionTable.getMemorySizeMB(); this.defaultThreads = engine.getParallelism(); this.defaultDepth = engine.getDeepeningPolicy().getDepth(); this.defaultMaxTime = engine.getDeepeningPolicy().getMaxTime(); @@ -85,8 +86,10 @@ public int getDefaultHashTableSize() { @Override public void setHashTableSize(int sizeInMB) { - if (engine.getTranspositionTable().getMemorySizeMB()!=sizeInMB) { - engine.setTranspositionTable(buildTranspositionTable(sizeInMB)); + final TranspositionTable transpositionTable = engine.getTranspositionTable(); + final int currentSize = transpositionTable==null ? -1 : transpositionTable.getMemorySizeMB(); + if (currentSize!=sizeInMB) { + engine.setTranspositionTable(sizeInMB<0 ? null : buildTranspositionTable(sizeInMB)); } } @@ -130,41 +133,43 @@ public void move(UCIMove move) { } @Override - public LongRunningTask go(GoParameters options) { - return new LongRunningTask<>() { + public StoppableTask go(GoParameters options) { + return new StoppableTask<>() { @Override - public GoReply get() { + public GoReply call() { final UCIEngineSearchConfiguration c = new UCIEngineSearchConfiguration<>(timeManager); final UCIEngineSearchConfiguration.EngineConfiguration previous = c.configure(engine, options, board); - final List candidates = options.getMoveToSearch().stream().map(AbstractEngine.this::toMove).toList(); - final SearchHistory search = engine.getBestMoves(board, candidates.isEmpty() ? null : candidates); - c.set(engine, previous); - if (search.isEmpty()) { - return new GoReply(null); + try { + final List candidates = options.getMoveToSearch().stream().map(AbstractEngine.this::toMove).toList(); + final SearchHistory search = engine.getBestMoves(board, candidates.isEmpty() ? null : candidates); + if (search.isEmpty()) { + return new GoReply(null); + } + final EvaluatedMove move = getSelected(board, search); + final GoReply goReply = new GoReply(toUCI(move.getContent())); + final Info info = new Info(search.getDepth()); + final TranspositionTable tt = engine.getTranspositionTable(); + final int entryCount = tt.getEntryCount(); + if (entryCount>0) { + info.setHashFull((int)(1000L*entryCount/tt.getSize())); + } + final List> bestMoves = search.getBestMoves(); + final Map> scores = bestMoves.stream().collect(Collectors.toMap(em -> toUCI(em.getContent()).toString(), em -> toScore(em.getEvaluation()))); + info.setScoreBuilder(m -> scores.get(m.toString())); + info.setPvBuilder(m -> { + final List list = tt.collectPV(board, toMove(m), info.getDepth()).stream().map(x -> toUCI(x)).toList(); + return list.isEmpty() ? Optional.empty() : Optional.of(list); + }); + info.setExtraMoves(bestMoves.stream().filter(em -> !move.getContent().equals(em.getContent())).limit(engine.getDeepeningPolicy().getSize()-1).map(em->toUCI(em.getContent())).toList()); + goReply.setInfo(info); + return goReply; + } finally { + c.set(engine, previous); } - final EvaluatedMove move = getSelected(board, search); - final GoReply goReply = new GoReply(toUCI(move.getContent())); - final Info info = new Info(search.getDepth()); - final TranspositionTable tt = engine.getTranspositionTable(); - final int entryCount = tt.getEntryCount(); - if (entryCount>0) { - info.setHashFull((int)(1000L*entryCount/tt.getSize())); - } - final List> bestMoves = search.getBestMoves(); - final Map> scores = bestMoves.stream().collect(Collectors.toMap(em -> toUCI(em.getContent()).toString(), em -> toScore(em.getEvaluation()))); - info.setScoreBuilder(m -> scores.get(m.toString())); - info.setPvBuilder(m -> { - final List list = tt.collectPV(board, toMove(m), info.getDepth()).stream().map(x -> toUCI(x)).toList(); - return list.isEmpty() ? Optional.empty() : Optional.of(list); - }); - info.setExtraMoves(bestMoves.stream().filter(em -> !move.getContent().equals(em.getContent())).limit(engine.getDeepeningPolicy().getSize()-1).map(em->toUCI(em.getContent())).toList()); - goReply.setInfo(info); - return goReply; } @Override public void stop() { - super.stop(); engine.interrupt(); } }; diff --git a/src/test/java/com/fathzer/jchess/uci/UCITest.java b/src/test/java/com/fathzer/jchess/uci/UCITest.java index a90cd13..657f922 100644 --- a/src/test/java/com/fathzer/jchess/uci/UCITest.java +++ b/src/test/java/com/fathzer/jchess/uci/UCITest.java @@ -139,11 +139,15 @@ void bug20241123() { engine.setPositionConsumer(s -> {}); assertTrue(uci.post("position fen toto", 10)); - engine.setGoFunction(s -> new LongRunningTask<>() { + engine.setGoFunction(s -> new StoppableTask<>() { @Override - public GoReply get() { + public GoReply call() { throw new UnsupportedOperationException("I'm a buggy engine"); } + + @Override + public void stop() { + } }); uci.post("go", 10); await().atMost(200, TimeUnit.MILLISECONDS).until(() -> uci.getExceptions().getOrDefault("go", new IllegalArgumentException()).getClass()==UnsupportedOperationException.class); diff --git a/src/test/java/com/fathzer/jchess/uci/helper/AbstractEngineTest.java b/src/test/java/com/fathzer/jchess/uci/helper/AbstractEngineTest.java new file mode 100644 index 0000000..1a75082 --- /dev/null +++ b/src/test/java/com/fathzer/jchess/uci/helper/AbstractEngineTest.java @@ -0,0 +1,84 @@ +package com.fathzer.jchess.uci.helper; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import org.junit.jupiter.api.Test; + +import com.fathzer.games.MoveGenerator; +import com.fathzer.games.ai.iterativedeepening.DeepeningPolicy; +import com.fathzer.games.ai.iterativedeepening.IterativeDeepeningEngine; +import com.fathzer.games.ai.iterativedeepening.IterativeDeepeningSearch; +import com.fathzer.games.ai.time.TimeManager; +import com.fathzer.games.ai.transposition.TranspositionTable; +import com.fathzer.games.clock.CountDownState; +import com.fathzer.jchess.uci.GoReply; +import com.fathzer.jchess.uci.StoppableTask; +import com.fathzer.jchess.uci.UCIMove; +import com.fathzer.jchess.uci.parameters.GoParameters; + +class AbstractEngineTest { + + @Test + void bug20241125() { + // after a go wtime xxx that ends with an exception, a go command (without time options) continues to use the previous max time + long maxTime = 36000; + DeepeningPolicy policy = new DeepeningPolicy(10); + policy.setMaxTime(maxTime); + final AtomicLong lastMaxTime = new AtomicLong(); + IterativeDeepeningEngine> engine = new IterativeDeepeningEngine<>(policy, null, null) { + @Override + protected IterativeDeepeningSearch doSearch(MoveGenerator board, List searchedMoves) { + lastMaxTime.set(getDeepeningPolicy().getMaxTime()); + throw new RuntimeException(); + } + }; + engine.setParallelism(1); + + final TimeManager> tm = new TimeManager>() { + @Override + public long getMaxTime(MoveGenerator data, CountDownState countDown) { + return countDown.getRemainingMs(); + } + }; + + AbstractEngine> ae = new AbstractEngine<>(engine, tm) { + @Override + public String getId() { + return "fake"; + } + + @Override + public void setStartPosition(String fen) { + } + + @Override + public UCIMove toUCI(String move) { + return UCIMove.from(move); + } + + @Override + protected TranspositionTable buildTranspositionTable(int sizeInMB) { + return null; + } + + @Override + protected String toMove(UCIMove move) { + return move.toString(); + } + }; + + GoParameters params = new GoParameters(); + // Check settings changes engine config + params = new GoParameters(); + GoParameters.PARSER.parse(params, new LinkedList<>(Arrays.asList(("movetime 1000").split(" ")))); + StoppableTask task = ae.go(params); + assertThrows(RuntimeException.class, () -> task.call()); + assertEquals(1000, lastMaxTime.get()); + assertEquals(maxTime, engine.getDeepeningPolicy().getMaxTime()); + } +} diff --git a/src/test/java/com/fathzer/jchess/uci/helper/UCIEngineSearchConfigurationTest.java b/src/test/java/com/fathzer/jchess/uci/helper/UCIEngineSearchConfigurationTest.java new file mode 100644 index 0000000..2e2ed5d --- /dev/null +++ b/src/test/java/com/fathzer/jchess/uci/helper/UCIEngineSearchConfigurationTest.java @@ -0,0 +1,57 @@ +package com.fathzer.jchess.uci.helper; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Arrays; +import java.util.LinkedList; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; + +import com.fathzer.games.MoveGenerator; +import com.fathzer.games.ai.iterativedeepening.DeepeningPolicy; +import com.fathzer.games.ai.iterativedeepening.IterativeDeepeningEngine; +import com.fathzer.games.ai.time.TimeManager; +import com.fathzer.games.clock.CountDownState; +import com.fathzer.jchess.uci.parameters.GoParameters; + +import org.mockito.junit.jupiter.MockitoExtension; + + +@ExtendWith(MockitoExtension.class) +class UCIEngineSearchConfigurationTest { + + @Test + void test(@Mock MoveGenerator mv) { + // after a go wtime xxx, a go command (without time options) continues to use the previous max time + DeepeningPolicy policy = new DeepeningPolicy(10); + IterativeDeepeningEngine> engine = new IterativeDeepeningEngine<>(policy, null, null); + + final TimeManager> tm = new TimeManager>() { + @Override + public long getMaxTime(MoveGenerator data, CountDownState countDown) { + return countDown.getRemainingMs(); + } + }; + + final UCIEngineSearchConfiguration> tested = new UCIEngineSearchConfiguration<>(tm); + final long maxTime = engine.getDeepeningPolicy().getMaxTime(); + final int maxDepth = engine.getDeepeningPolicy().getDepth(); + + GoParameters params = new GoParameters(); + // No params, no change in engine configuration + GoParameters.PARSER.parse(params, new LinkedList<>()); + tested.configure(engine, params, mv); + assertEquals(maxTime, engine.getDeepeningPolicy().getMaxTime()); + assertEquals(maxDepth, engine.getDeepeningPolicy().getDepth()); + + // Check settings changes engine config + params = new GoParameters(); + final int depth = maxDepth+6; + GoParameters.PARSER.parse(params, new LinkedList<>(Arrays.asList(("depth "+depth+" movetime 1000").split(" ")))); + tested.configure(engine, params, mv); + assertEquals(1000, engine.getDeepeningPolicy().getMaxTime()); + assertEquals(depth, engine.getDeepeningPolicy().getDepth()); + } +} diff --git a/src/test/java/com/fathzer/jchess/uci/util/InstrumentedEngine.java b/src/test/java/com/fathzer/jchess/uci/util/InstrumentedEngine.java index d7b4485..06a9cfc 100644 --- a/src/test/java/com/fathzer/jchess/uci/util/InstrumentedEngine.java +++ b/src/test/java/com/fathzer/jchess/uci/util/InstrumentedEngine.java @@ -5,14 +5,14 @@ import com.fathzer.jchess.uci.Engine; import com.fathzer.jchess.uci.GoReply; -import com.fathzer.jchess.uci.LongRunningTask; +import com.fathzer.jchess.uci.StoppableTask; import com.fathzer.jchess.uci.UCIMove; import com.fathzer.jchess.uci.parameters.GoParameters; public class InstrumentedEngine implements Engine { private Consumer positionConsumer; private Consumer moveConsumer; - private Function> goFunction; + private Function> goFunction; @Override public String getId() { @@ -30,7 +30,7 @@ public void move(UCIMove move) { } @Override - public LongRunningTask go(GoParameters params) { + public StoppableTask go(GoParameters params) { return goFunction.apply(params); } @@ -42,7 +42,7 @@ public void setMoveConsumer(Consumer moveConsumer) { this.moveConsumer = moveConsumer; } - public void setGoFunction(Function> goFunction) { + public void setGoFunction(Function> goFunction) { this.goFunction = goFunction; }