From 7a27ae6e63a8b8b44ff0b19ed555b5125385db2b Mon Sep 17 00:00:00 2001 From: hopshackle Date: Thu, 30 Apr 2026 20:08:15 +0100 Subject: [PATCH 01/41] Adviser framework --- src/main/java/core/Game.java | 16 +++++- .../java/players/observers/GameAdviser.java | 49 +++++++++++++++++++ .../java/players/observers/IAdviceFilter.java | 10 ++++ .../players/observers/PlayerNameAdviser.java | 18 +++++++ .../players/observers/UnderdogAdviser.java | 14 ++++++ 5 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 src/main/java/players/observers/GameAdviser.java create mode 100644 src/main/java/players/observers/IAdviceFilter.java create mode 100644 src/main/java/players/observers/PlayerNameAdviser.java create mode 100644 src/main/java/players/observers/UnderdogAdviser.java diff --git a/src/main/java/core/Game.java b/src/main/java/core/Game.java index 828f94b78..0c1ca0cf0 100644 --- a/src/main/java/core/Game.java +++ b/src/main/java/core/Game.java @@ -75,6 +75,7 @@ public class Game { String codecName = null; int snapsPerSecond = 10; private int turnPause; + protected AbstractAction overrideAction; /** * Game constructor. Receives a list of players, a forward model and a game state. Sets unique and final @@ -481,7 +482,12 @@ public final AbstractAction oneAction() { } else { // Resolve action and game rules, time it s = System.nanoTime(); - // we copy the action before using it..so that the action returned by oneAction() does not have a state link + if (overrideAction != null) { + action = overrideAction; + System.out.println("Overriding action with " + overrideAction); + overrideAction = null; + } + // we copy the action before using it...so that the action returned by oneAction() does not have a state link forwardModel.next(gameState, action.copy()); nextTime = (System.nanoTime() - s); } @@ -581,6 +587,14 @@ public double getActionComputeTime() { return actionComputeTime; } + /** + * May be called by a third party observer (i.e. a listener) if it interjects an action to override a player + * @param overrideAction + */ + public void setOverrideAction(AbstractAction overrideAction) { + this.overrideAction = overrideAction; + } + /** * Retrieves the number of game loop repetitions performed in this game. * diff --git a/src/main/java/players/observers/GameAdviser.java b/src/main/java/players/observers/GameAdviser.java new file mode 100644 index 000000000..6074aac8f --- /dev/null +++ b/src/main/java/players/observers/GameAdviser.java @@ -0,0 +1,49 @@ +package players.observers; + +import core.AbstractPlayer; +import core.Game; +import core.actions.AbstractAction; +import evaluation.listeners.IGameListener; +import evaluation.metrics.Event; + +public class GameAdviser implements IGameListener { + + Game game; + AbstractPlayer player; + IAdviceFilter filter; + + /** + * A Game Adviser is responsible for providing advice to one or more players during a game. + * This may be one or all players in the game. + * + * @param player + */ + public GameAdviser(AbstractPlayer player, IAdviceFilter filter) { + this.player = player; + this.filter = filter; + } + + @Override + public void onEvent(Event event) { + int actingPlayer = event.state.getCurrentPlayer(); + if (event.type == Event.GameEvent.ACTION_CHOSEN && filter.advise(event.state, game.getPlayers().get(actingPlayer))) { + AbstractAction action = player.getAction(event.state, event.actions); + game.setOverrideAction(action); + } + } + + @Override + public void report() { + // nothing to do in this case + } + + @Override + public void setGame(Game game) { + this.game = game; + } + + @Override + public Game getGame() { + return game; + } +} diff --git a/src/main/java/players/observers/IAdviceFilter.java b/src/main/java/players/observers/IAdviceFilter.java new file mode 100644 index 000000000..288f3e2fb --- /dev/null +++ b/src/main/java/players/observers/IAdviceFilter.java @@ -0,0 +1,10 @@ +package players.observers; + +import core.AbstractGameState; +import core.AbstractPlayer; + +@FunctionalInterface +public interface IAdviceFilter { + + boolean advise(AbstractGameState state, AbstractPlayer player); +} diff --git a/src/main/java/players/observers/PlayerNameAdviser.java b/src/main/java/players/observers/PlayerNameAdviser.java new file mode 100644 index 000000000..567c62cd7 --- /dev/null +++ b/src/main/java/players/observers/PlayerNameAdviser.java @@ -0,0 +1,18 @@ +package players.observers; + +import core.AbstractGameState; +import core.AbstractPlayer; + +public class PlayerNameAdviser implements IAdviceFilter { + + public final String playerName; + + public PlayerNameAdviser(String playerName) { + this.playerName = playerName; + } + + @Override + public boolean advise(AbstractGameState state, AbstractPlayer player) { + return player.toString().equals(playerName); + } +} diff --git a/src/main/java/players/observers/UnderdogAdviser.java b/src/main/java/players/observers/UnderdogAdviser.java new file mode 100644 index 000000000..6579fe98c --- /dev/null +++ b/src/main/java/players/observers/UnderdogAdviser.java @@ -0,0 +1,14 @@ +package players.observers; + +import core.AbstractGameState; +import core.AbstractPlayer; + +public class UnderdogAdviser implements IAdviceFilter { + @Override + public boolean advise(AbstractGameState state, AbstractPlayer player) { + // TODO: We need to determine if they are behind. + // We need a heuristic to estimate this....which could be a full oracle searh, but I'm happier for the moment if we just + // make this a prelearned heuristic. + return false; + } +} From b76f27e8d9e95bd998385b5cb3d024774530c77e Mon Sep 17 00:00:00 2001 From: hopshackle Date: Sun, 3 May 2026 18:47:04 +0100 Subject: [PATCH 02/41] Initial framework for Advisers --- src/main/java/players/mcts/ForestNode.java | 65 +++++++++---------- src/main/java/players/mcts/MCTSPlayer.java | 8 +++ src/main/java/players/mcts/MultiTreeNode.java | 6 ++ .../java/players/mcts/SingleTreeNode.java | 36 +++++----- .../java/players/observers/GameAdviser.java | 39 ++++++++--- .../java/players/observers/IAdviceFilter.java | 39 ++++++++++- .../players/observers/PlayerNameAdviser.java | 29 ++++++++- .../players/observers/UnderdogAdviser.java | 14 ++-- 8 files changed, 167 insertions(+), 69 deletions(-) diff --git a/src/main/java/players/mcts/ForestNode.java b/src/main/java/players/mcts/ForestNode.java index 73f08ed28..d4a1a70b5 100644 --- a/src/main/java/players/mcts/ForestNode.java +++ b/src/main/java/players/mcts/ForestNode.java @@ -3,6 +3,7 @@ import core.AbstractGameState; import core.actions.AbstractAction; import utilities.Pair; + import java.util.*; /** @@ -10,11 +11,11 @@ * Uses multiple determinised SingleTreeNode roots to create several * determinisations, running MCTS on each determinised state, and then aggregating the suggested * root actions across trees using a chosen aggregation policy. - * + *

* This class does the following: - * - Build N determinized trees from the decision point. - * - Runs MCTS on each determinized root. - * - Aggregates action statistics to inform the final choice. + * - Build N determinized trees from the decision point. + * - Runs MCTS on each determinized root. + * - Aggregates action statistics to inform the final choice. */ public class ForestNode extends SingleTreeNode { //One root MCTS tree per determinisation @@ -48,7 +49,7 @@ public ForestNode(MCTSPlayer player, AbstractGameState state, Random rnd) { roots = new SingleTreeNode[params.numDeterminizations]; } - //Run PI-MCTS search where for each determinisation, state is copied, a SingleTreeNode root is created, and MCTS search is performed. + //Run PI-MCTS search where for each determinisation, state is copied, a SingleTreeNode root is created, and MCTS search is performed. @Override public void mctsSearch(long initialisationTime) { @@ -65,36 +66,28 @@ public void mctsSearch(long initialisationTime) { } - //The choice is made by aggregating recommendations across determinisations using the chosen PerfectInformationPolicy. + //The choice is made by aggregating recommendations across determinisations using the chosen PerfectInformationPolicy. @Override - public AbstractAction bestAction() - { - AbstractAction calculatedAction = null; - - switch (params.perfectInformationPolicy) - { - case SingleVote : - { - return bestAction_SingleVote(); - } - case AverageValue: - { - return bestAction_AccumulatedResults(MCTSEnums.PerfectInformationPolicy.AverageValue); - } - case TotalVisits: - { - return bestAction_AccumulatedResults(MCTSEnums.PerfectInformationPolicy.TotalVisits); - } - } - return calculatedAction; + public AbstractAction bestAction() { + return switch (params.perfectInformationPolicy) { + case SingleVote -> bestAction_SingleVote(); + case AverageValue -> bestAction_AccumulatedResults(MCTSEnums.PerfectInformationPolicy.AverageValue); + case TotalVisits -> bestAction_AccumulatedResults(MCTSEnums.PerfectInformationPolicy.TotalVisits); + }; + } + + @Override + public double getValue(AbstractAction action) { + // we average over the value for all roots + return Arrays.stream(roots).mapToDouble(r -> r.getValue(action)).average().orElse(0.0); } /** * Single-vote aggregation: - * - Let each determinised root pick its own best action. - * - Aggregate votes for actions across all determinisations. - * - Pick the action with the highest count with tiny noise to break ties. + * - Let each determinised root pick its own best action. + * - Aggregate votes for actions across all determinisations. + * - Pick the action with the highest count with tiny noise to break ties. */ AbstractAction bestAction_SingleVote() { //Voting @@ -107,9 +100,9 @@ AbstractAction bestAction_SingleVote() { AbstractAction bestAction_vote = null; double maxCount = -1; for (Map.Entry entry : actionCounts.entrySet()) { - double totValue = entry.getValue() +( rnd.nextGaussian() * params.noiseEpsilon); + double totValue = entry.getValue() + (rnd.nextGaussian() * params.noiseEpsilon); if (totValue > maxCount) { - maxCount = totValue ; + maxCount = totValue; bestAction_vote = entry.getKey(); } } @@ -118,9 +111,9 @@ AbstractAction bestAction_SingleVote() { /** * Aggregation by accumulated statistics from all determinised roots based on selected aggregation policy - * TotalValue: Total Value for decisionPlayer per action node - * TotalVisits: total visits to the action node - * AverageValue: TotalValue / TotalVisits + * TotalValue: Total Value for decisionPlayer per action node + * TotalVisits: total visits to the action node + * AverageValue: TotalValue / TotalVisits */ AbstractAction bestAction_AccumulatedResults(MCTSEnums.PerfectInformationPolicy policy) { accumulatedActionStats = new HashMap<>(); @@ -152,10 +145,10 @@ AbstractAction bestAction_AccumulatedResults(MCTSEnums.PerfectInformationPolicy double totValue = 0.0; switch (policy) { case AverageValue: - totValue = (stats.totValue[decisionPlayer] + (rnd.nextGaussian() * params.noiseEpsilon)) / (stats.nVisits +( rnd.nextGaussian() * params.noiseEpsilon)) ; + totValue = (stats.totValue[decisionPlayer] + (rnd.nextGaussian() * params.noiseEpsilon)) / (stats.nVisits + (rnd.nextGaussian() * params.noiseEpsilon)); break; case TotalVisits: - totValue = stats.nVisits + (rnd.nextGaussian() * params.noiseEpsilon) ; + totValue = stats.nVisits + (rnd.nextGaussian() * params.noiseEpsilon); } if (totValue > bestValue) { bestValue = totValue; diff --git a/src/main/java/players/mcts/MCTSPlayer.java b/src/main/java/players/mcts/MCTSPlayer.java index 4320bce56..927bf07e9 100644 --- a/src/main/java/players/mcts/MCTSPlayer.java +++ b/src/main/java/players/mcts/MCTSPlayer.java @@ -306,6 +306,14 @@ public AbstractAction _getAction(AbstractGameState gameState, List "\t" + a.toString() + "\n").collect(joining())); if ((params.useActionHeuristicForMoveOrdering && nVisits < actionsFromOpenLoopState.size()) - || params.pUCTTemperature <= 10000.0 || params.progressiveWideningConstant > 1.0 + || params.pUCTTemperature <= 10000.0 || params.progressiveWideningConstant > 1.0 || params.progressiveBias > 0 || params.initialiseVisits > 0) { // We only need to calculate actionValueEstimates if we are going to be using the data in one of these variants // If not, then we can save processing time by not calculating them @@ -711,6 +711,7 @@ public ActionStats getActionStats(AbstractAction action) { public List> getActionsInRollout() { return actionsInRollout; } + public List> getActionsInTree() { return actionsInTree; } @@ -1196,20 +1197,14 @@ public AbstractAction bestAction() { if (!actionValues.containsKey(action)) { throw new AssertionError("Hashcode / equals contract issue for " + action); } - if (actionValues.get(action) != null) { - ActionStats stats = actionValues.get(action); - double childValue = stats.nVisits; // if ROBUST - if (policy == SIMPLE) - childValue = stats.totValue[decisionPlayer] / (stats.nVisits + params.noiseEpsilon); - - // Apply small noise to break ties randomly - childValue = noise(childValue, params.noiseEpsilon, rnd.nextDouble()); - - // Save best value - if (childValue > bestValue) { - bestValue = childValue; - bestAction = action; - } + double childValue = getValue(action); + // Apply small noise to break ties randomly + childValue = noise(childValue, params.noiseEpsilon, rnd.nextDouble()); + + // Save best value + if (childValue > bestValue) { + bestValue = childValue; + bestAction = action; } } } @@ -1225,6 +1220,17 @@ public AbstractAction bestAction() { return bestAction; } + public double getValue(AbstractAction action) { + if (actionValues.get(action) != null) { + ActionStats stats = actionValues.get(action); + if (params.selectionPolicy == SIMPLE) + return stats.totValue[decisionPlayer] / (stats.nVisits + params.noiseEpsilon); + else + return stats.nVisits; // ROBUST + } + return 0.0; + } + protected void updateRegretMatchingAverage(List actionsToConsider) { double[] av = actionValues(actionsToConsider); double[] pdf = pdf(av); diff --git a/src/main/java/players/observers/GameAdviser.java b/src/main/java/players/observers/GameAdviser.java index 6074aac8f..4053574ad 100644 --- a/src/main/java/players/observers/GameAdviser.java +++ b/src/main/java/players/observers/GameAdviser.java @@ -1,37 +1,60 @@ package players.observers; +import core.AbstractGameState; import core.AbstractPlayer; import core.Game; import core.actions.AbstractAction; import evaluation.listeners.IGameListener; import evaluation.metrics.Event; +import org.jetbrains.annotations.NotNull; public class GameAdviser implements IGameListener { - Game game; - AbstractPlayer player; - IAdviceFilter filter; + protected Game game; + protected AbstractPlayer player; + private final IAdviceFilter filter; /** * A Game Adviser is responsible for providing advice to one or more players during a game. * This may be one or all players in the game. * - * @param player + * The GameAdviser is composed of two elements: + * The AbstractPlayer that decides on what the Adviser thinks is best + * The IAdviceFilter that decides on which players in a game are advised, and other conditions + * about *when* the GameAdviser actually intervenes + * + * @param player the agent to be used by the adviser + * @param filter the filter to (may be null) */ - public GameAdviser(AbstractPlayer player, IAdviceFilter filter) { + public GameAdviser(@NotNull AbstractPlayer player, @NotNull IAdviceFilter filter) { this.player = player; this.filter = filter; } @Override public void onEvent(Event event) { - int actingPlayer = event.state.getCurrentPlayer(); - if (event.type == Event.GameEvent.ACTION_CHOSEN && filter.advise(event.state, game.getPlayers().get(actingPlayer))) { + int actingPlayerID = event.state.getCurrentPlayer(); + AbstractPlayer actingPlayer = game.getPlayers().get(actingPlayerID); + if (event.type == Event.GameEvent.ACTION_CHOSEN && filter.advise(event.state, event.action, actingPlayer)) { AbstractAction action = player.getAction(event.state, event.actions); - game.setOverrideAction(action); + if (provideAdvice(event.state, event.action, action, actingPlayer)) { + game.setOverrideAction(action); + } } } + /** + * This is designed to be overridden in sub-classes (which have access to the player) + * @param state the current game state + * @param action the action chosen by the advisee + * @param advice the action the adviser would recommend + * @param advisee the advisee + * @return true if we should recommend the action + */ + public boolean provideAdvice(AbstractGameState state, AbstractAction action, AbstractAction advice, AbstractPlayer advisee) { + return true; + } + @Override public void report() { // nothing to do in this case diff --git a/src/main/java/players/observers/IAdviceFilter.java b/src/main/java/players/observers/IAdviceFilter.java index 288f3e2fb..d0bf08e2a 100644 --- a/src/main/java/players/observers/IAdviceFilter.java +++ b/src/main/java/players/observers/IAdviceFilter.java @@ -2,9 +2,44 @@ import core.AbstractGameState; import core.AbstractPlayer; +import core.actions.AbstractAction; -@FunctionalInterface public interface IAdviceFilter { - boolean advise(AbstractGameState state, AbstractPlayer player); + /** + * Returns true/false whether the Adviser will look at the move further + * This is the first (low budget) stage in the advice chain. If the answer to this is 'true' + * then the adviser will go on to invest computation in determining whether advice should be given + * and if so, precisely what that advise should be + * + * @param state the current game state + * @param proposedAction the action the advisee will take in the absence of our advice + * @param advisee the agent that we may consider advising + * @return true if we will consider what we would do (low budget) + */ + boolean payAttention(AbstractGameState state, + AbstractAction proposedAction, + AbstractPlayer advisee); + + /** + * Returns true/false whether the Adviser will advise if the player takes the action from the state + * This is the second (high budget) stage in the advise chain. If the answer to this is true (after computation), + * then the adviser will intervene. + * This intervention is controlled by the main GameAdviser (currently) + * + * @param state the current game state + * @param proposedAction the action the advisee will take in the absence of our advice + * @param advisee the agent that we may consider advising + * @param advice the action we are considering as best + * @param adviser the GameAdviser + * @return true if we will intervene and provide advice + * + */ + boolean provideAdvice(AbstractGameState state, + AbstractAction proposedAction, + AbstractPlayer advisee, + AbstractAction advice, + GameAdviser adviser); + + } diff --git a/src/main/java/players/observers/PlayerNameAdviser.java b/src/main/java/players/observers/PlayerNameAdviser.java index 567c62cd7..5f27582ef 100644 --- a/src/main/java/players/observers/PlayerNameAdviser.java +++ b/src/main/java/players/observers/PlayerNameAdviser.java @@ -2,17 +2,40 @@ import core.AbstractGameState; import core.AbstractPlayer; +import core.actions.AbstractAction; +import players.mcts.MCTSPlayer; public class PlayerNameAdviser implements IAdviceFilter { public final String playerName; + protected double adviceThreshold; - public PlayerNameAdviser(String playerName) { + /** + * Will advise a player that matches on playerName, if we think our recommendation + * is more than adviceThreshold better than their proposed move + * + * @param playerName name of advisee + * @param adviceThreshold threshold for advice to be given (value of proposed action over their action) + */ + public PlayerNameAdviser(String playerName, double adviceThreshold) { this.playerName = playerName; + this.adviceThreshold = adviceThreshold; } @Override - public boolean advise(AbstractGameState state, AbstractPlayer player) { - return player.toString().equals(playerName); + public boolean payAttention(AbstractGameState state, AbstractAction proposedAction, AbstractPlayer advisee) { + return advisee.toString().equals(playerName); + } + + @Override + public boolean provideAdvice(AbstractGameState state, AbstractAction proposedAction, AbstractPlayer advisee, + AbstractAction advice, GameAdviser adviser) { + if (adviser.player instanceof MCTSPlayer mctsPlayer) { + double valueOfProposedAction = mctsPlayer.getValue(proposedAction); + double valueOfOurAction = mctsPlayer.getValue(advice); + return valueOfOurAction - valueOfProposedAction > adviceThreshold; + } else { + throw new AssertionError("PlayerNameAdviser only supports MCTS players"); + } } } diff --git a/src/main/java/players/observers/UnderdogAdviser.java b/src/main/java/players/observers/UnderdogAdviser.java index 6579fe98c..bd172605f 100644 --- a/src/main/java/players/observers/UnderdogAdviser.java +++ b/src/main/java/players/observers/UnderdogAdviser.java @@ -2,13 +2,17 @@ import core.AbstractGameState; import core.AbstractPlayer; +import core.actions.AbstractAction; public class UnderdogAdviser implements IAdviceFilter { + + @Override + public boolean payAttention(AbstractGameState state, AbstractAction proposedAction, AbstractPlayer advisee) { + return false; // TODO + } + @Override - public boolean advise(AbstractGameState state, AbstractPlayer player) { - // TODO: We need to determine if they are behind. - // We need a heuristic to estimate this....which could be a full oracle searh, but I'm happier for the moment if we just - // make this a prelearned heuristic. - return false; + public boolean provideAdvice(AbstractGameState state, AbstractAction proposedAction, AbstractPlayer advisee, AbstractAction advice, GameAdviser adviser) { + return false; // TODO } } From 71ec089756616a31968c763f603f732c68b31091 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Sun, 3 May 2026 19:17:40 +0100 Subject: [PATCH 03/41] Added logging to GameAdviser --- .../java/players/observers/GameAdviser.java | 78 +++++++-- .../players/observers/GameAdviserTest.java | 154 ++++++++++++++++++ 2 files changed, 217 insertions(+), 15 deletions(-) create mode 100644 src/test/java/players/observers/GameAdviserTest.java diff --git a/src/main/java/players/observers/GameAdviser.java b/src/main/java/players/observers/GameAdviser.java index 4053574ad..57f87dc95 100644 --- a/src/main/java/players/observers/GameAdviser.java +++ b/src/main/java/players/observers/GameAdviser.java @@ -1,18 +1,25 @@ package players.observers; -import core.AbstractGameState; import core.AbstractPlayer; import core.Game; import core.actions.AbstractAction; import evaluation.listeners.IGameListener; import evaluation.metrics.Event; import org.jetbrains.annotations.NotNull; +import players.mcts.MCTSPlayer; +import utilities.Utils; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; public class GameAdviser implements IGameListener { protected Game game; protected AbstractPlayer player; private final IAdviceFilter filter; + protected FileWriter writer; + protected String fileName; /** * A Game Adviser is responsible for providing advice to one or more players during a game. @@ -27,37 +34,63 @@ public class GameAdviser implements IGameListener { * @param filter the filter to (may be null) */ public GameAdviser(@NotNull AbstractPlayer player, @NotNull IAdviceFilter filter) { + this(player, filter, null); + } + + public GameAdviser(@NotNull AbstractPlayer player, @NotNull IAdviceFilter filter, String fileName) { this.player = player; this.filter = filter; + this.fileName = fileName == null ? this.getClass().getSimpleName() + ".txt" : fileName; + setupWriter(); + } + + private void setupWriter() { + try { + writer = new FileWriter(this.fileName, true); + File file = new File(this.fileName); + if (file.length() == 0) { + writer.write("PlayerID\tAgentName\tAgentAction\tAgentValue\tAdviserAction\tAdviserValue\tGameID\tTurn\tRound\tTick\n"); + } + } catch (IOException e) { + throw new RuntimeException(e); + } } @Override public void onEvent(Event event) { int actingPlayerID = event.state.getCurrentPlayer(); AbstractPlayer actingPlayer = game.getPlayers().get(actingPlayerID); - if (event.type == Event.GameEvent.ACTION_CHOSEN && filter.advise(event.state, event.action, actingPlayer)) { + if (event.type == Event.GameEvent.ACTION_CHOSEN && filter.payAttention(event.state, event.action, actingPlayer)) { AbstractAction action = player.getAction(event.state, event.actions); - if (provideAdvice(event.state, event.action, action, actingPlayer)) { + if (filter.provideAdvice(event.state, event.action, actingPlayer, action, this)) { game.setOverrideAction(action); + logIntervention(event, action, actingPlayer); } } } - /** - * This is designed to be overridden in sub-classes (which have access to the player) - * @param state the current game state - * @param action the action chosen by the advisee - * @param advice the action the adviser would recommend - * @param advisee the advisee - * @return true if we should recommend the action - */ - public boolean provideAdvice(AbstractGameState state, AbstractAction action, AbstractAction advice, AbstractPlayer advisee) { - return true; + @Override + public void report() { + if (writer != null) { + try { + writer.flush(); + writer.close(); + } catch (IOException e) { + throw new RuntimeException("Error closing writer", e); + } + } } @Override - public void report() { - // nothing to do in this case + public boolean setOutputDirectory(String... nestedDirectories) { + if (writer != null) { + report(); + writer = null; + } + String folder = Utils.createDirectory(nestedDirectories); + this.fileName = folder + File.separator + this.fileName; + setupWriter(); + return true; } @Override @@ -69,4 +102,19 @@ public void setGame(Game game) { public Game getGame() { return game; } + + protected void logIntervention(Event event, AbstractAction action, AbstractPlayer actingPlayer) { + if (writer == null) return; + double agentValue = player instanceof MCTSPlayer mcts ? mcts.getValue(event.action) : 0.0; + double adviserValue = player instanceof MCTSPlayer mcts ? mcts.getValue(action) : 0.0; + + try { + writer.write(String.format("%s\t%s\t%s\t%.3g\t%s\t%.3g\t%d\t%d\t%d\t%d\n", + event.playerID, actingPlayer.toString(), + event.action.toString(), agentValue, action.toString(), adviserValue, + event.state.getGameID(), event.state.getTurnCounter(), event.state.getRoundCounter(), event.state.getGameTick())); + } catch (IOException e) { + throw new RuntimeException(e); + } + } } diff --git a/src/test/java/players/observers/GameAdviserTest.java b/src/test/java/players/observers/GameAdviserTest.java new file mode 100644 index 000000000..7d6c4f624 --- /dev/null +++ b/src/test/java/players/observers/GameAdviserTest.java @@ -0,0 +1,154 @@ +package players.observers; + +import core.AbstractGameState; +import core.AbstractPlayer; +import core.Game; +import core.actions.AbstractAction; +import evaluation.metrics.Event; +import games.GameType; +import org.junit.Test; +import players.mcts.MCTSPlayer; +import players.simple.RandomPlayer; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.*; + +public class GameAdviserTest { + + @Test + public void testLogging() throws IOException { + String fileName = "test_adviser_log.txt"; + File file = new File(fileName); + if (file.exists()) file.delete(); + + AbstractPlayer adviserPlayer = new RandomPlayer(); + IAdviceFilter filter = new IAdviceFilter() { + @Override + public boolean payAttention(core.AbstractGameState state, AbstractAction proposedAction, AbstractPlayer advisee) { + return true; + } + + @Override + public boolean provideAdvice(core.AbstractGameState state, AbstractAction proposedAction, AbstractPlayer advisee, AbstractAction advice, GameAdviser adviser) { + return true; + } + }; + + GameAdviser adviser = new GameAdviser(adviserPlayer, filter, fileName); + + // Setup a game + List players = new ArrayList<>(); + players.add(new RandomPlayer()); + players.add(new RandomPlayer()); + Game game = GameType.TicTacToe.createGameInstance(2); + game.reset(players); + adviser.setGame(game); + + // Manually trigger an event + AbstractGameState state = game.getGameState(); + List actions = game.getForwardModel().computeAvailableActions(state); + AbstractAction action = actions.get(0); + Event event = Event.createEvent(Event.GameEvent.ACTION_CHOSEN, state, action, actions, state.getCurrentPlayer()); + + adviser.onEvent(event); + adviser.report(); // Should close the file + + assertTrue("File should exist", file.exists()); + + try (BufferedReader reader = new BufferedReader(new FileReader(file))) { + String header = reader.readLine(); + assertNotNull("Header should not be null", header); + assertEquals("PlayerID\tAgentName\tAgentAction\tAgentValue\tAdviserAction\tAdviserValue\tGameID\tTurn\tRound\tTick", header); + + String logLine = reader.readLine(); + assertNotNull("Log line should not be null", logLine); + String[] parts = logLine.split("\t"); + assertEquals("Should have 10 parts", 10, parts.length); + } finally { + file.delete(); + } + } + + @Test + public void testSetOutputDirectory() { + AbstractPlayer adviserPlayer = new RandomPlayer(); + IAdviceFilter filter = new UnderdogAdviser(); + GameAdviser adviser = new GameAdviser(adviserPlayer, filter, "test.txt"); + + adviser.setOutputDirectory("testDir1", "testDir2"); + + String expectedPath = "testDir1" + File.separator + "testDir2" + File.separator + "test.txt"; + assertEquals(new File(expectedPath).getAbsolutePath(), new File(adviser.fileName).getAbsolutePath()); + + File file = new File(adviser.fileName); + assertTrue("File should exist in the new directory", file.exists()); + + adviser.report(); + file.delete(); + new File("testDir1" + File.separator + "testDir2").delete(); + new File("testDir1").delete(); + new File("test.txt").delete(); // The one created in constructor + } + + @Test + public void testMCTSLogging() throws IOException { + String fileName = "mcts_adviser_log.txt"; + File file = new File(fileName); + if (file.exists()) file.delete(); + + MCTSPlayer adviserPlayer = new MCTSPlayer(); + + // Setup a game + List players = new ArrayList<>(); + players.add(new RandomPlayer()); + players.add(new RandomPlayer()); + Game game = GameType.TicTacToe.createGameInstance(2); + game.reset(players); + adviserPlayer.setForwardModel(game.getForwardModel()); + + IAdviceFilter filter = new IAdviceFilter() { + @Override + public boolean payAttention(AbstractGameState state, AbstractAction proposedAction, AbstractPlayer advisee) { + return true; + } + + @Override + public boolean provideAdvice(AbstractGameState state, AbstractAction proposedAction, AbstractPlayer advisee, AbstractAction advice, GameAdviser adviser) { + return true; + } + }; + + GameAdviser adviser = new GameAdviser(adviserPlayer, filter, fileName); + adviser.setGame(game); + + // Run one action to populate MCTS tree + AbstractGameState state = game.getGameState(); + List actions = game.getForwardModel().computeAvailableActions(state); + AbstractAction action = adviserPlayer.getAction(state, actions); + + // Manually trigger an event + Event event = Event.createEvent(Event.GameEvent.ACTION_CHOSEN, state, action, actions, state.getCurrentPlayer()); + + adviser.onEvent(event); + adviser.report(); + + assertTrue("File should exist", file.exists()); + try (BufferedReader reader = new BufferedReader(new FileReader(file))) { + reader.readLine(); // skip header + String logLine = reader.readLine(); + assertNotNull("Log line should not be null", logLine); + String[] parts = logLine.split("\t"); + assertEquals("Should have 10 parts", 10, parts.length); + // Check that values are not "0.0" if possible, but TicTacToe might have 0.0 values. + // At least it didn't crash. + } finally { + file.delete(); + } + } +} From c24a7496237a5ee634bf4e3350bfd0e8a5cf9309 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Mon, 4 May 2026 19:24:52 +0100 Subject: [PATCH 04/41] UnderdogAdviser, and refactor to support override with reusing MCTS trees --- src/main/java/core/AbstractPlayer.java | 16 +++++++++- src/main/java/core/Game.java | 3 +- src/main/java/players/mcts/MCTSPlayer.java | 16 ++++++---- .../observers/AbstractMCTSAdviser.java | 27 +++++++++++++++++ .../java/players/observers/GameAdviser.java | 20 ++++++++++--- .../players/observers/PlayerNameAdviser.java | 17 ++--------- .../players/observers/UnderdogAdviser.java | 29 +++++++++++++++---- .../players/observers/GameAdviserTest.java | 2 +- 8 files changed, 96 insertions(+), 34 deletions(-) create mode 100644 src/main/java/players/observers/AbstractMCTSAdviser.java diff --git a/src/main/java/core/AbstractPlayer.java b/src/main/java/core/AbstractPlayer.java index ca3df192a..63906a599 100644 --- a/src/main/java/core/AbstractPlayer.java +++ b/src/main/java/core/AbstractPlayer.java @@ -90,7 +90,13 @@ public final AbstractAction getAction(AbstractGameState gameState, List possibleActions); + /** + * overrideAction is called in the main Game loop if the action chosen by the agent (via _getAction) + * is overridden for any reason (usually because an adviser intervenes) + * This enables the agent to update its internal state (if any) + */ + public void overrideAction(AbstractAction originalAction, AbstractAction override) { + // do nothing...purely for overriding in sub-classes + } /* Methods that can be implemented in subclass */ /** diff --git a/src/main/java/core/Game.java b/src/main/java/core/Game.java index 0c1ca0cf0..6efe7c94c 100644 --- a/src/main/java/core/Game.java +++ b/src/main/java/core/Game.java @@ -483,8 +483,9 @@ public final AbstractAction oneAction() { // Resolve action and game rules, time it s = System.nanoTime(); if (overrideAction != null) { + currentPlayer.overrideAction(action, overrideAction); action = overrideAction; - System.out.println("Overriding action with " + overrideAction); + // System.out.println("Overriding action with " + overrideAction); overrideAction = null; } // we copy the action before using it...so that the action returned by oneAction() does not have a state link diff --git a/src/main/java/players/mcts/MCTSPlayer.java b/src/main/java/players/mcts/MCTSPlayer.java index 927bf07e9..1d3f05736 100644 --- a/src/main/java/players/mcts/MCTSPlayer.java +++ b/src/main/java/players/mcts/MCTSPlayer.java @@ -302,16 +302,20 @@ public AbstractAction _getAction(AbstractGameState gameState, List 3 * actions.size() && !(root instanceof MCGSNode) && !getParameters().reuseTree && !getParameters().actionSpace.equals(gameState.getCoreGameParameters().actionSpace)) throw new AssertionError(String.format("Unexpectedly large number of children: %d with action size of %d", root.children.size(), actions.size())); - lastAction = new Pair<>(gameState.getCurrentPlayer(), root.bestAction()); + lastAction = Pair.of(gameState.getCurrentPlayer(), root.bestAction()); return lastAction.b.copy(); } - public double getValue(AbstractAction action) { - if (root.actionValues.containsKey(action)) { - return root.actionValues.get(action).valueOf(getPlayerID()); - } else { - throw new IllegalArgumentException("Unknown action: " + action); + @Override + public void overrideAction(AbstractAction originalAction, AbstractAction override) { + if (!lastAction.b.equals(originalAction)) { + throw new IllegalArgumentException("Original action does not match the last action taken" + lastAction.b + " vs " + originalAction); } + lastAction = Pair.of(lastAction.a, override.copy()); + } + + public double getValue(AbstractAction action) { + return root.getValue(action); } @Override diff --git a/src/main/java/players/observers/AbstractMCTSAdviser.java b/src/main/java/players/observers/AbstractMCTSAdviser.java new file mode 100644 index 000000000..20e557b43 --- /dev/null +++ b/src/main/java/players/observers/AbstractMCTSAdviser.java @@ -0,0 +1,27 @@ +package players.observers; + +import core.AbstractGameState; +import core.AbstractPlayer; +import core.actions.AbstractAction; +import players.mcts.MCTSPlayer; + +public abstract class AbstractMCTSAdviser implements IAdviceFilter { + + protected double adviceThreshold; + + public AbstractMCTSAdviser(double adviceThreshold) { + this.adviceThreshold = adviceThreshold; + } + + @Override + public boolean provideAdvice(AbstractGameState state, AbstractAction proposedAction, AbstractPlayer advisee, + AbstractAction advice, GameAdviser adviser) { + if (adviser.player instanceof MCTSPlayer mctsPlayer) { + double valueOfProposedAction = mctsPlayer.getValue(proposedAction); + double valueOfOurAction = mctsPlayer.getValue(advice); + return valueOfOurAction - valueOfProposedAction > adviceThreshold; + } else { + throw new AssertionError("AbstractMCTSAdviser only supports MCTS players"); + } + } +} diff --git a/src/main/java/players/observers/GameAdviser.java b/src/main/java/players/observers/GameAdviser.java index 57f87dc95..90a4e89cf 100644 --- a/src/main/java/players/observers/GameAdviser.java +++ b/src/main/java/players/observers/GameAdviser.java @@ -6,12 +6,14 @@ import evaluation.listeners.IGameListener; import evaluation.metrics.Event; import org.jetbrains.annotations.NotNull; +import players.mcts.MCTSParams; import players.mcts.MCTSPlayer; import utilities.Utils; import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.util.List; public class GameAdviser implements IGameListener { @@ -36,6 +38,9 @@ public class GameAdviser implements IGameListener { public GameAdviser(@NotNull AbstractPlayer player, @NotNull IAdviceFilter filter) { this(player, filter, null); } + public GameAdviser(@NotNull MCTSParams params, @NotNull IAdviceFilter filter, String fileName) { + this(new MCTSPlayer(params), filter, fileName); + } public GameAdviser(@NotNull AbstractPlayer player, @NotNull IAdviceFilter filter, String fileName) { this.player = player; @@ -61,10 +66,17 @@ public void onEvent(Event event) { int actingPlayerID = event.state.getCurrentPlayer(); AbstractPlayer actingPlayer = game.getPlayers().get(actingPlayerID); if (event.type == Event.GameEvent.ACTION_CHOSEN && filter.payAttention(event.state, event.action, actingPlayer)) { - AbstractAction action = player.getAction(event.state, event.actions); - if (filter.provideAdvice(event.state, event.action, actingPlayer, action, this)) { - game.setOverrideAction(action); - logIntervention(event, action, actingPlayer); + player.setPlayerID(actingPlayer.getPlayerID()); + player.setForwardModel(actingPlayer.getForwardModel()); + List adviserActions = player.getForwardModel().computeAvailableActions(event.state); + if (adviserActions.size() > 1 && adviserActions.contains(event.action)) { + // only advise if we have more than one option + // and if the action they plan on taking is one we recognise + AbstractAction action = player.getAction(event.state, event.actions); + if (filter.provideAdvice(event.state, event.action, actingPlayer, action, this)) { + game.setOverrideAction(action); + logIntervention(event, action, actingPlayer); + } } } } diff --git a/src/main/java/players/observers/PlayerNameAdviser.java b/src/main/java/players/observers/PlayerNameAdviser.java index 5f27582ef..5679b1654 100644 --- a/src/main/java/players/observers/PlayerNameAdviser.java +++ b/src/main/java/players/observers/PlayerNameAdviser.java @@ -3,12 +3,10 @@ import core.AbstractGameState; import core.AbstractPlayer; import core.actions.AbstractAction; -import players.mcts.MCTSPlayer; -public class PlayerNameAdviser implements IAdviceFilter { +public class PlayerNameAdviser extends AbstractMCTSAdviser { public final String playerName; - protected double adviceThreshold; /** * Will advise a player that matches on playerName, if we think our recommendation @@ -18,8 +16,8 @@ public class PlayerNameAdviser implements IAdviceFilter { * @param adviceThreshold threshold for advice to be given (value of proposed action over their action) */ public PlayerNameAdviser(String playerName, double adviceThreshold) { + super(adviceThreshold); this.playerName = playerName; - this.adviceThreshold = adviceThreshold; } @Override @@ -27,15 +25,4 @@ public boolean payAttention(AbstractGameState state, AbstractAction proposedActi return advisee.toString().equals(playerName); } - @Override - public boolean provideAdvice(AbstractGameState state, AbstractAction proposedAction, AbstractPlayer advisee, - AbstractAction advice, GameAdviser adviser) { - if (adviser.player instanceof MCTSPlayer mctsPlayer) { - double valueOfProposedAction = mctsPlayer.getValue(proposedAction); - double valueOfOurAction = mctsPlayer.getValue(advice); - return valueOfOurAction - valueOfProposedAction > adviceThreshold; - } else { - throw new AssertionError("PlayerNameAdviser only supports MCTS players"); - } - } } diff --git a/src/main/java/players/observers/UnderdogAdviser.java b/src/main/java/players/observers/UnderdogAdviser.java index bd172605f..40fae146d 100644 --- a/src/main/java/players/observers/UnderdogAdviser.java +++ b/src/main/java/players/observers/UnderdogAdviser.java @@ -3,16 +3,33 @@ import core.AbstractGameState; import core.AbstractPlayer; import core.actions.AbstractAction; +import core.interfaces.IStateHeuristic; +import it.unimi.dsi.fastutil.ints.Int2BooleanSortedMap; +import players.mcts.MCTSPlayer; -public class UnderdogAdviser implements IAdviceFilter { +import java.util.List; +import java.util.stream.IntStream; - @Override - public boolean payAttention(AbstractGameState state, AbstractAction proposedAction, AbstractPlayer advisee) { - return false; // TODO +public class UnderdogAdviser extends AbstractMCTSAdviser { + + IStateHeuristic heuristic; + double losingThreshold; + + public UnderdogAdviser(IStateHeuristic heuristic, double losingThreshold, double adviceThreshold) { + super(adviceThreshold); + this.heuristic = heuristic; + this.losingThreshold = losingThreshold; } @Override - public boolean provideAdvice(AbstractGameState state, AbstractAction proposedAction, AbstractPlayer advisee, AbstractAction advice, GameAdviser adviser) { - return false; // TODO + public boolean payAttention(AbstractGameState state, AbstractAction proposedAction, AbstractPlayer advisee) { + // we decide if the current player is losing by more than the losingThreshold + List stateValues = IntStream.range(0, state.getNPlayers()) + .mapToObj(playerId -> heuristic.evaluateState(state, playerId)) + .toList(); + double maxValue = stateValues.stream().max(Double::compare).orElseThrow(); + double adviseeValue = stateValues.get(advisee.getPlayerID()); + return maxValue - adviseeValue > losingThreshold; } + } diff --git a/src/test/java/players/observers/GameAdviserTest.java b/src/test/java/players/observers/GameAdviserTest.java index 7d6c4f624..8450c2b58 100644 --- a/src/test/java/players/observers/GameAdviserTest.java +++ b/src/test/java/players/observers/GameAdviserTest.java @@ -78,7 +78,7 @@ public boolean provideAdvice(core.AbstractGameState state, AbstractAction propos @Test public void testSetOutputDirectory() { AbstractPlayer adviserPlayer = new RandomPlayer(); - IAdviceFilter filter = new UnderdogAdviser(); + IAdviceFilter filter = new UnderdogAdviser((state, p) -> 0, 0.5, 0.5); // Dummy filter that doesn't actually advise GameAdviser adviser = new GameAdviser(adviserPlayer, filter, "test.txt"); adviser.setOutputDirectory("testDir1", "testDir2"); From 6588f64d6d9e219d07f16afbe022e0be8973fd71 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Wed, 6 May 2026 16:16:03 +0100 Subject: [PATCH 05/41] JSSON serialisation for BGGameState --- src/main/java/core/Game.java | 10 +- .../java/core/actions/AbstractAction.java | 10 + .../optimisation/TunableParameters.java | 3 +- .../java/games/backgammon/BGGameState.java | 36 ++- .../java/games/backgammon/BGStateJSON.java | 228 ++++++++++++++++++ 5 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 src/main/java/games/backgammon/BGStateJSON.java diff --git a/src/main/java/core/Game.java b/src/main/java/core/Game.java index 6efe7c94c..933e27414 100644 --- a/src/main/java/core/Game.java +++ b/src/main/java/core/Game.java @@ -2,8 +2,7 @@ import core.actions.AbstractAction; import core.actions.DoNothing; -import core.interfaces.IExtendedSequence; -import core.interfaces.IPrintable; +import core.interfaces.*; import core.turnorders.ReactiveTurnOrder; import evaluation.listeners.IGameListener; import evaluation.metrics.Event; @@ -29,8 +28,7 @@ import players.simple.FirstActionPlayer; import players.simple.OSLAPlayer; import players.simple.RandomPlayer; -import utilities.Pair; -import utilities.Utils; +import utilities.*; import javax.swing.Timer; import javax.swing.*; @@ -488,6 +486,10 @@ public final AbstractAction oneAction() { // System.out.println("Overriding action with " + overrideAction); overrideAction = null; } + if (action.saveGame() && gameState instanceof IToJSON serialisableGameState) { + String filename = String.format("%s_G%d_P%d_Tick%d.json", gameType, gameState.getGameID(), activePlayer, gameState.getGameTick()); + JSONUtils.writeJSON(serialisableGameState.toJSON(), filename); + } // we copy the action before using it...so that the action returned by oneAction() does not have a state link forwardModel.next(gameState, action.copy()); nextTime = (System.nanoTime() - s); diff --git a/src/main/java/core/actions/AbstractAction.java b/src/main/java/core/actions/AbstractAction.java index 11cad70f4..6921c1a0d 100644 --- a/src/main/java/core/actions/AbstractAction.java +++ b/src/main/java/core/actions/AbstractAction.java @@ -7,6 +7,7 @@ public abstract class AbstractAction implements IPrintable { + protected boolean saveGameBeforeAction = false; /** * Executes this action, applying its effect to the given game state. Can access any component IDs stored * through the AbstractGameState.getComponentById(int id) method. @@ -71,4 +72,13 @@ public String getString(AbstractGameState gs, Set perspectiveSet) { public String getTooltip(AbstractGameState gs) { return ""; } + + /** + * this boolean flag indicates to Game that the Player would like it to save a copy of the game state before applying the action + * This is to indicate that the move is a 'critical' one in some sense. + * This will only trigger action if the AbstractGameState implementation supports serialisation to a game state via the IToJson interface + */ + public boolean saveGame() { + return saveGameBeforeAction; + } } diff --git a/src/main/java/evaluation/optimisation/TunableParameters.java b/src/main/java/evaluation/optimisation/TunableParameters.java index 6233392c0..b5dbff3c5 100644 --- a/src/main/java/evaluation/optimisation/TunableParameters.java +++ b/src/main/java/evaluation/optimisation/TunableParameters.java @@ -60,7 +60,7 @@ public static void loadFromJSONFile(TunableParameters params, String filename * Instantiate parameters from a JSONObject */ @SuppressWarnings("unchecked") - public static void loadFromJSON(TunableParameters params, JSONObject rawData) { + public static TunableParameters loadFromJSON(TunableParameters params, JSONObject rawData) { List allParams = params.getParameterNames(); // Static parameter, load from json and exclude from tuning @@ -93,6 +93,7 @@ public static void loadFromJSON(TunableParameters params, JSONObject rawData) System.out.println("Unexpected key in JSON for TunableParameters : " + key); } } + return params; } private Class getParameterType(String pName) { diff --git a/src/main/java/games/backgammon/BGGameState.java b/src/main/java/games/backgammon/BGGameState.java index 6acf31124..f70f0d575 100644 --- a/src/main/java/games/backgammon/BGGameState.java +++ b/src/main/java/games/backgammon/BGGameState.java @@ -3,11 +3,14 @@ import core.AbstractGameState; import core.AbstractParameters; import core.components.*; +import core.interfaces.IToJSON; +import evaluation.optimisation.TunableParameters; import games.GameType; +import org.json.simple.JSONObject; import java.util.*; -public class BGGameState extends AbstractGameState { +public class BGGameState extends AbstractGameState implements IToJSON { // Backgammon involves moving 15 checkers around a board, aiming to be the first to "bear off" (remove) all your pieces before your opponent. Players move their pieces based on dice rolls, and a key strategy involves hitting opponent's pieces (blots) to send them to the bar, forcing them to re-enter the game. // Here's a more detailed breakdown of the rules: @@ -52,6 +55,13 @@ public BGGameState(AbstractParameters gameParameters, int nPlayers) { super(gameParameters, nPlayers); } + public BGGameState(JSONObject jsonObject) { + this(TunableParameters.loadFromJSON(new BGParameters(), (JSONObject) jsonObject.get("gameParams")), + ((Number) jsonObject.get("nPlayers")).intValue()); + reset(); + BGStateJSON.loadFromJSON(this, jsonObject); + } + /** * @return the enum value corresponding to this game, declared in {@link GameType}. */ @@ -69,9 +79,10 @@ protected GameType _getGameType() { @Override protected List _getAllComponents() { List components = new ArrayList<>(); - for (int playerId = 0; playerId < getNPlayers(); playerId++) { - components.addAll(counters.get(playerId)); + for (List counter : counters) { + components.addAll(counter); } + components.addAll(Arrays.asList(dice)); return components; } @@ -345,4 +356,23 @@ public int hashCode() { super.hashCode(); } + @Override + public JSONObject toJSON() { + return BGStateJSON.toJSON(this); + } + + protected void setAbstractFields(int round, int turn, int tick, int gameID) { + this.roundCounter = round; + this.turnCounter = turn; + try { + java.lang.reflect.Field f = core.AbstractGameState.class.getDeclaredField("tick"); + f.setAccessible(true); + f.set(this, tick); + java.lang.reflect.Field f2 = core.AbstractGameState.class.getDeclaredField("gameID"); + f2.setAccessible(true); + f2.set(this, gameID); + } catch (Exception e) { + e.printStackTrace(); + } + } } diff --git a/src/main/java/games/backgammon/BGStateJSON.java b/src/main/java/games/backgammon/BGStateJSON.java new file mode 100644 index 000000000..23d6af269 --- /dev/null +++ b/src/main/java/games/backgammon/BGStateJSON.java @@ -0,0 +1,228 @@ +package games.backgammon; + +import core.CoreConstants; +import core.components.Dice; +import core.components.Token; +import core.components.Component; +import evaluation.optimisation.TunableParameters; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; + +import java.util.*; + +public class BGStateJSON { + + public static BGGameState fromJSON(JSONObject json) { + BGGameState retValue = new BGGameState( + TunableParameters.loadFromJSON(new BGParameters(), (JSONObject) json.get("gameParams")), + ((Number) json.get("nPlayers")).intValue()); + loadFromJSON(retValue, json); + return retValue; + } + + public static void loadFromJSON(BGGameState state, JSONObject json) { + try { + state.setGameStatus(CoreConstants.GameResult.valueOf((String) json.get("gameStatus"))); + String phaseStr = (String) json.get("gamePhase"); + try { + state.setGamePhase(BGGamePhase.valueOf(phaseStr)); + } catch (Exception e) { + state.setGamePhase(CoreConstants.DefaultGamePhase.valueOf(phaseStr)); + } + state.setFirstPlayer(((Number) json.get("firstPlayer")).intValue()); + state.setTurnOwner(((Number) json.get("turnOwner")).intValue()); + state.setAbstractFields( + ((Number) json.get("roundCounter")).intValue(), + ((Number) json.get("turnCounter")).intValue(), + ((Number) json.get("gameTick")).intValue(), + json.containsKey("gameID") ? ((Number) json.get("gameID")).intValue() : -1 + ); + } catch (Exception e) { + throw e; + } + + JSONArray playerResults = (JSONArray) json.get("playerResults"); + for (int i = 0; i < playerResults.size(); i++) { + String resStr = (String) playerResults.get(i); + CoreConstants.GameResult res = CoreConstants.GameResult.valueOf(resStr); + state.setPlayerResult(res, i); + } + + state.piecesBorneOff = intArrayFromJSON((JSONArray) json.get("piecesBorneOff")); + state.blots = intArrayFromJSON((JSONArray) json.get("blots")); + state.diceUsed = booleanArrayFromJSON((JSONArray) json.get("diceUsed")); + state.availableDiceValues = intArrayFromJSON((JSONArray) json.get("availableDiceValues")); + + JSONArray diceArray = (JSONArray) json.get("dice"); + state.dice = new Dice[diceArray.size()]; + for (int i = 0; i < diceArray.size(); i++) { + JSONObject dJSON = (JSONObject) diceArray.get(i); + int nSides = ((Number) dJSON.get("nSides")).intValue(); + Dice d; + if (dJSON.containsKey("pdf")) { + JSONArray pdfArray = (JSONArray) dJSON.get("pdf"); + double[] pdf = new double[pdfArray.size()]; + for (int j = 0; j < pdfArray.size(); j++) pdf[j] = ((Number) pdfArray.get(j)).doubleValue(); + d = new Dice(pdf); + } else { + d = new Dice(nSides); + } + d.setValue(((Number) dJSON.get("value")).intValue()); + setComponentID(d, ((Number) dJSON.get("id")).intValue()); + state.dice[i] = d; + } + + JSONArray countersArray = (JSONArray) json.get("counters"); + state.counters = new ArrayList<>(); + Map tokenMap = new HashMap<>(); + for (int i = 0; i < countersArray.size(); i++) { + JSONArray pointArray = (JSONArray) countersArray.get(i); + List pointTokens = new ArrayList<>(); + for (int j = 0; j < pointArray.size(); j++) { + JSONObject tJSON = (JSONObject) pointArray.get(j); + Token t = new Token((String) tJSON.get("name"), ((Number) tJSON.get("id")).intValue()); + t.setOwnerId(((Number) tJSON.get("ownerId")).intValue()); + t.setTokenType((String) tJSON.get("tokenType")); + pointTokens.add(t); + tokenMap.put(t.getComponentID(), t); + } + state.counters.add(pointTokens); + } + + JSONArray movedArray = (JSONArray) json.get("movedThisTurn"); + state.movedThisTurn = new ArrayList<>(); + for (Object o : movedArray) { + int id = ((Number) o).intValue(); + if (tokenMap.containsKey(id)) { + state.movedThisTurn.add(tokenMap.get(id)); + } + } + + state.playerTrackMapping = intMatrixFromJSON((JSONArray) json.get("playerTrackMapping")); + } + + public static JSONObject toJSON(BGGameState state) { + JSONObject json = new JSONObject(); + json.put("gameParams", ((TunableParameters)state.getGameParameters()).instanceToJSON(false, new HashMap<>())); + json.put("nPlayers", state.getNPlayers()); + + json.put("gameStatus", state.getGameStatus().name()); + if (state.getGamePhase() instanceof Enum) { + json.put("gamePhase", ((Enum) state.getGamePhase()).name()); + } else { + json.put("gamePhase", state.getGamePhase().toString()); + } + json.put("turnOwner", state.getTurnOwner()); + json.put("turnCounter", state.getTurnCounter()); + json.put("roundCounter", state.getRoundCounter()); + json.put("gameTick", state.getGameTick()); + json.put("firstPlayer", state.getFirstPlayer()); + json.put("gameID", state.getGameID()); + + JSONArray playerResults = new JSONArray(); + for (CoreConstants.GameResult res : state.getPlayerResults()) { + playerResults.add(res.name()); + } + json.put("playerResults", playerResults); + + json.put("piecesBorneOff", intArrayToJSON(state.piecesBorneOff)); + json.put("blots", intArrayToJSON(state.blots)); + json.put("diceUsed", booleanArrayToJSON(state.diceUsed)); + json.put("availableDiceValues", intArrayToJSON(state.availableDiceValues)); + + JSONArray dice = new JSONArray(); + for (Dice d : state.dice) { + JSONObject dJSON = new JSONObject(); + dJSON.put("value", d.getValue()); + dJSON.put("nSides", d.getNumberOfSides()); + double[] pdf = d.getPdf(); + if (pdf != null && pdf.length > 0) { + JSONArray pdfArray = new JSONArray(); + for (double p : pdf) pdfArray.add(p); + dJSON.put("pdf", pdfArray); + } + dJSON.put("id", d.getComponentID()); + dice.add(dJSON); + } + json.put("dice", dice); + + JSONArray counters = new JSONArray(); + for (List point : state.counters) { + JSONArray pointArray = new JSONArray(); + for (Token t : point) { + JSONObject tJSON = new JSONObject(); + tJSON.put("name", t.getComponentName()); + tJSON.put("ownerId", t.getOwnerId()); + tJSON.put("id", t.getComponentID()); + tJSON.put("tokenType", t.getTokenType()); + pointArray.add(tJSON); + } + counters.add(pointArray); + } + json.put("counters", counters); + + JSONArray movedThisTurn = new JSONArray(); + for (Token t : state.movedThisTurn) { + movedThisTurn.add(t.getComponentID()); + } + json.put("movedThisTurn", movedThisTurn); + json.put("playerTrackMapping", intMatrixToJSON(state.playerTrackMapping)); + + return json; + } + + private static void setComponentID(Component c, int id) { + try { + java.lang.reflect.Field f = Component.class.getDeclaredField("componentID"); + f.setAccessible(true); + f.set(c, id); + } catch (Exception e) { + e.printStackTrace(); + } + } + + private static JSONArray intArrayToJSON(int[] array) { + JSONArray res = new JSONArray(); + if (array != null) { + for (int i : array) res.add(i); + } + return res; + } + + private static int[] intArrayFromJSON(JSONArray array) { + if (array == null) return null; + int[] res = new int[array.size()]; + for (int i = 0; i < array.size(); i++) res[i] = ((Number) array.get(i)).intValue(); + return res; + } + + private static JSONArray booleanArrayToJSON(boolean[] array) { + JSONArray res = new JSONArray(); + if (array != null) { + for (boolean b : array) res.add(b); + } + return res; + } + + private static boolean[] booleanArrayFromJSON(JSONArray array) { + if (array == null) return null; + boolean[] res = new boolean[array.size()]; + for (int i = 0; i < array.size(); i++) res[i] = (boolean) array.get(i); + return res; + } + + private static JSONArray intMatrixToJSON(int[][] matrix) { + JSONArray res = new JSONArray(); + if (matrix != null) { + for (int[] row : matrix) res.add(intArrayToJSON(row)); + } + return res; + } + + private static int[][] intMatrixFromJSON(JSONArray array) { + if (array == null) return null; + int[][] res = new int[array.size()][]; + for (int i = 0; i < array.size(); i++) res[i] = intArrayFromJSON((JSONArray) array.get(i)); + return res; + } +} From d470a2dba26e119574966d793f2599849c1c8c89 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Wed, 6 May 2026 19:35:59 +0100 Subject: [PATCH 06/41] Initial JSON serialization for core game state --- src/main/java/core/AbstractGameState.java | 63 +++++++++- .../java/games/backgammon/BGGameState.java | 14 --- .../java/games/backgammon/BGStateJSON.java | 116 +++--------------- src/main/java/utilities/JSONUtils.java | 50 +++++++- 4 files changed, 127 insertions(+), 116 deletions(-) diff --git a/src/main/java/core/AbstractGameState.java b/src/main/java/core/AbstractGameState.java index 2a37b1f45..7c3cc33af 100644 --- a/src/main/java/core/AbstractGameState.java +++ b/src/main/java/core/AbstractGameState.java @@ -11,9 +11,12 @@ import core.interfaces.IGamePhase; import evaluation.listeners.IGameListener; import evaluation.metrics.Event; +import evaluation.optimisation.TunableParameters; import games.GameType; import utilities.ElapsedCpuChessTimer; import utilities.Pair; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; import java.util.*; import java.util.function.BiFunction; @@ -21,6 +24,7 @@ import java.util.function.Supplier; import static core.CoreConstants.GameResult.*; +import static evaluation.optimisation.TunableParameters.loadFromJSON; /** * Contains all game state information. @@ -35,7 +39,7 @@ public abstract class AbstractGameState { // Parameters, forward model and turn order for the game - protected final AbstractParameters gameParameters; + protected AbstractParameters gameParameters; // Game being played protected final GameType gameType = _getGameType(); private Area allComponents; @@ -714,6 +718,63 @@ public final int[] superHashCodeArray() { }; } + public JSONObject abstractGameStateToJSON() { + if (!actionsInProgress.isEmpty()) + throw new IllegalStateException("Cannot yet serialize game state with ongoing actions"); + JSONObject json = new JSONObject(); + json.put("gameStatus", gameStatus.name()); + if (gamePhase instanceof Enum) { + json.put("gamePhase", ((Enum) gamePhase).name()); + } else if (gamePhase != null) { + json.put("gamePhase", gamePhase.toString()); + } + json.put("turnOwner", turnOwner); + json.put("turnCounter", turnCounter); + json.put("roundCounter", roundCounter); + json.put("gameTick", tick); + json.put("firstPlayer", firstPlayer); + json.put("gameID", gameID); + json.put("nPlayers", nPlayers); + if (gameParameters instanceof TunableParameters tunableParameters) { + json.put("gameParams", tunableParameters.instanceToJSON(true, new HashMap<>())); + } + + JSONArray playerResultsJson = new JSONArray(); + for (CoreConstants.GameResult res : playerResults) { + playerResultsJson.add(res.name()); + } + json.put("playerResults", playerResultsJson); + return json; + } + + public void loadAbstractGameStateFromJSON(JSONObject json) { + this.gameStatus = CoreConstants.GameResult.valueOf((String) json.get("gameStatus")); + this.turnOwner = ((Number) json.get("turnOwner")).intValue(); + this.turnCounter = ((Number) json.get("turnCounter")).intValue(); + this.roundCounter = ((Number) json.get("roundCounter")).intValue(); + this.tick = ((Number) json.get("gameTick")).intValue(); + this.firstPlayer = ((Number) json.get("firstPlayer")).intValue(); + this.gameID = json.containsKey("gameID") ? ((Number) json.get("gameID")).intValue() : -1; + this.nPlayers = ((Number) json.get("nPlayers")).intValue(); + if (json.containsKey("gameParams")) { // We only support serialization of tunable parameters (otherwise we just pick up the defaults) + this.gameParameters = loadFromJSON((TunableParameters) getGameParameters(), (JSONObject) json.get("gameParams")); + } + + JSONArray playerResultsJson = (JSONArray) json.get("playerResults"); + for (int i = 0; i < playerResultsJson.size(); i++) { + this.playerResults[i] = CoreConstants.GameResult.valueOf((String) playerResultsJson.get(i)); + } + + String phaseStr = (String) json.get("gamePhase"); + if (phaseStr != null) { + try { + this.gamePhase = CoreConstants.DefaultGamePhase.valueOf(phaseStr); + } catch (Exception e) { + // Ignore, maybe subclass will handle it + } + } + } + public boolean isGameOver() { return gameStatus.equals(GAME_END); } diff --git a/src/main/java/games/backgammon/BGGameState.java b/src/main/java/games/backgammon/BGGameState.java index f70f0d575..4319c5acf 100644 --- a/src/main/java/games/backgammon/BGGameState.java +++ b/src/main/java/games/backgammon/BGGameState.java @@ -361,18 +361,4 @@ public JSONObject toJSON() { return BGStateJSON.toJSON(this); } - protected void setAbstractFields(int round, int turn, int tick, int gameID) { - this.roundCounter = round; - this.turnCounter = turn; - try { - java.lang.reflect.Field f = core.AbstractGameState.class.getDeclaredField("tick"); - f.setAccessible(true); - f.set(this, tick); - java.lang.reflect.Field f2 = core.AbstractGameState.class.getDeclaredField("gameID"); - f2.setAccessible(true); - f2.set(this, gameID); - } catch (Exception e) { - e.printStackTrace(); - } - } } diff --git a/src/main/java/games/backgammon/BGStateJSON.java b/src/main/java/games/backgammon/BGStateJSON.java index 23d6af269..9c44c3d3d 100644 --- a/src/main/java/games/backgammon/BGStateJSON.java +++ b/src/main/java/games/backgammon/BGStateJSON.java @@ -8,50 +8,29 @@ import org.json.simple.JSONArray; import org.json.simple.JSONObject; +import utilities.JSONUtils; import java.util.*; public class BGStateJSON { - public static BGGameState fromJSON(JSONObject json) { - BGGameState retValue = new BGGameState( - TunableParameters.loadFromJSON(new BGParameters(), (JSONObject) json.get("gameParams")), - ((Number) json.get("nPlayers")).intValue()); - loadFromJSON(retValue, json); - return retValue; - } - public static void loadFromJSON(BGGameState state, JSONObject json) { try { - state.setGameStatus(CoreConstants.GameResult.valueOf((String) json.get("gameStatus"))); - String phaseStr = (String) json.get("gamePhase"); + JSONObject abstractGS = (JSONObject) json.get("abstractGameState"); + state.loadAbstractGameStateFromJSON(abstractGS); + String phaseStr = (String) abstractGS.get("gamePhase"); try { state.setGamePhase(BGGamePhase.valueOf(phaseStr)); } catch (Exception e) { - state.setGamePhase(CoreConstants.DefaultGamePhase.valueOf(phaseStr)); + // If this fails, then it was either DefaultGamePhase (already handled) or something else } - state.setFirstPlayer(((Number) json.get("firstPlayer")).intValue()); - state.setTurnOwner(((Number) json.get("turnOwner")).intValue()); - state.setAbstractFields( - ((Number) json.get("roundCounter")).intValue(), - ((Number) json.get("turnCounter")).intValue(), - ((Number) json.get("gameTick")).intValue(), - json.containsKey("gameID") ? ((Number) json.get("gameID")).intValue() : -1 - ); } catch (Exception e) { throw e; } - JSONArray playerResults = (JSONArray) json.get("playerResults"); - for (int i = 0; i < playerResults.size(); i++) { - String resStr = (String) playerResults.get(i); - CoreConstants.GameResult res = CoreConstants.GameResult.valueOf(resStr); - state.setPlayerResult(res, i); - } - - state.piecesBorneOff = intArrayFromJSON((JSONArray) json.get("piecesBorneOff")); - state.blots = intArrayFromJSON((JSONArray) json.get("blots")); - state.diceUsed = booleanArrayFromJSON((JSONArray) json.get("diceUsed")); - state.availableDiceValues = intArrayFromJSON((JSONArray) json.get("availableDiceValues")); + state.piecesBorneOff = JSONUtils.intArrayFromJSON((JSONArray) json.get("piecesBorneOff")); + state.blots = JSONUtils.intArrayFromJSON((JSONArray) json.get("blots")); + state.diceUsed = JSONUtils.booleanArrayFromJSON((JSONArray) json.get("diceUsed")); + state.availableDiceValues = JSONUtils.intArrayFromJSON((JSONArray) json.get("availableDiceValues")); JSONArray diceArray = (JSONArray) json.get("dice"); state.dice = new Dice[diceArray.size()]; @@ -98,37 +77,19 @@ public static void loadFromJSON(BGGameState state, JSONObject json) { } } - state.playerTrackMapping = intMatrixFromJSON((JSONArray) json.get("playerTrackMapping")); + state.playerTrackMapping = JSONUtils.intMatrixFromJSON((JSONArray) json.get("playerTrackMapping")); } public static JSONObject toJSON(BGGameState state) { JSONObject json = new JSONObject(); json.put("gameParams", ((TunableParameters)state.getGameParameters()).instanceToJSON(false, new HashMap<>())); - json.put("nPlayers", state.getNPlayers()); - json.put("gameStatus", state.getGameStatus().name()); - if (state.getGamePhase() instanceof Enum) { - json.put("gamePhase", ((Enum) state.getGamePhase()).name()); - } else { - json.put("gamePhase", state.getGamePhase().toString()); - } - json.put("turnOwner", state.getTurnOwner()); - json.put("turnCounter", state.getTurnCounter()); - json.put("roundCounter", state.getRoundCounter()); - json.put("gameTick", state.getGameTick()); - json.put("firstPlayer", state.getFirstPlayer()); - json.put("gameID", state.getGameID()); - - JSONArray playerResults = new JSONArray(); - for (CoreConstants.GameResult res : state.getPlayerResults()) { - playerResults.add(res.name()); - } - json.put("playerResults", playerResults); + json.put("abstractGameState", state.abstractGameStateToJSON()); - json.put("piecesBorneOff", intArrayToJSON(state.piecesBorneOff)); - json.put("blots", intArrayToJSON(state.blots)); - json.put("diceUsed", booleanArrayToJSON(state.diceUsed)); - json.put("availableDiceValues", intArrayToJSON(state.availableDiceValues)); + json.put("piecesBorneOff", JSONUtils.intArrayToJSON(state.piecesBorneOff)); + json.put("blots", JSONUtils.intArrayToJSON(state.blots)); + json.put("diceUsed", JSONUtils.booleanArrayToJSON(state.diceUsed)); + json.put("availableDiceValues", JSONUtils.intArrayToJSON(state.availableDiceValues)); JSONArray dice = new JSONArray(); for (Dice d : state.dice) { @@ -166,7 +127,7 @@ public static JSONObject toJSON(BGGameState state) { movedThisTurn.add(t.getComponentID()); } json.put("movedThisTurn", movedThisTurn); - json.put("playerTrackMapping", intMatrixToJSON(state.playerTrackMapping)); + json.put("playerTrackMapping", JSONUtils.intMatrixToJSON(state.playerTrackMapping)); return json; } @@ -180,49 +141,4 @@ private static void setComponentID(Component c, int id) { e.printStackTrace(); } } - - private static JSONArray intArrayToJSON(int[] array) { - JSONArray res = new JSONArray(); - if (array != null) { - for (int i : array) res.add(i); - } - return res; - } - - private static int[] intArrayFromJSON(JSONArray array) { - if (array == null) return null; - int[] res = new int[array.size()]; - for (int i = 0; i < array.size(); i++) res[i] = ((Number) array.get(i)).intValue(); - return res; - } - - private static JSONArray booleanArrayToJSON(boolean[] array) { - JSONArray res = new JSONArray(); - if (array != null) { - for (boolean b : array) res.add(b); - } - return res; - } - - private static boolean[] booleanArrayFromJSON(JSONArray array) { - if (array == null) return null; - boolean[] res = new boolean[array.size()]; - for (int i = 0; i < array.size(); i++) res[i] = (boolean) array.get(i); - return res; - } - - private static JSONArray intMatrixToJSON(int[][] matrix) { - JSONArray res = new JSONArray(); - if (matrix != null) { - for (int[] row : matrix) res.add(intArrayToJSON(row)); - } - return res; - } - - private static int[][] intMatrixFromJSON(JSONArray array) { - if (array == null) return null; - int[][] res = new int[array.size()][]; - for (int i = 0; i < array.size(); i++) res[i] = intArrayFromJSON((JSONArray) array.get(i)); - return res; - } } diff --git a/src/main/java/utilities/JSONUtils.java b/src/main/java/utilities/JSONUtils.java index 95ea3597a..675a878e6 100644 --- a/src/main/java/utilities/JSONUtils.java +++ b/src/main/java/utilities/JSONUtils.java @@ -549,6 +549,54 @@ public static boolean areValuesEqual(Object possibleValue, Object value) { } else if (possibleValue instanceof IHasName pName && value instanceof IHasName name) { return pName.getName().equals(name.getName()); } else - return possibleValue.equals(value); + return Objects.equals(possibleValue, value); + } + + @SuppressWarnings("unchecked") + public static JSONArray intArrayToJSON(int[] array) { + JSONArray res = new JSONArray(); + if (array != null) { + for (int i : array) res.add(i); + } + return res; + } + + public static int[] intArrayFromJSON(JSONArray array) { + if (array == null) return null; + int[] res = new int[array.size()]; + for (int i = 0; i < array.size(); i++) res[i] = ((Number) array.get(i)).intValue(); + return res; + } + + @SuppressWarnings("unchecked") + public static JSONArray booleanArrayToJSON(boolean[] array) { + JSONArray res = new JSONArray(); + if (array != null) { + for (boolean b : array) res.add(b); + } + return res; + } + + public static boolean[] booleanArrayFromJSON(JSONArray array) { + if (array == null) return null; + boolean[] res = new boolean[array.size()]; + for (int i = 0; i < array.size(); i++) res[i] = (boolean) array.get(i); + return res; + } + + @SuppressWarnings("unchecked") + public static JSONArray intMatrixToJSON(int[][] matrix) { + JSONArray res = new JSONArray(); + if (matrix != null) { + for (int[] row : matrix) res.add(intArrayToJSON(row)); + } + return res; + } + + public static int[][] intMatrixFromJSON(JSONArray array) { + if (array == null) return null; + int[][] res = new int[array.size()][]; + for (int i = 0; i < array.size(); i++) res[i] = intArrayFromJSON((JSONArray) array.get(i)); + return res; } } From 91bf58c0c2a878f886c9ae420e3c0864f4cfdd70 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Thu, 7 May 2026 17:42:01 +0100 Subject: [PATCH 07/41] JSON serialisation refactor for Dice and Tokens --- data/descent2e/heroes.json | 32 ++++---- data/descent2e/monsters.json | 36 ++++----- data/descent2e/searchCards.json | 72 ++++++++--------- data/pandemic/tokens.json | 32 ++++---- src/main/java/core/AbstractGameState.java | 81 +++++++++++++++---- src/main/java/core/components/Dice.java | 42 ++++++++-- src/main/java/core/components/Token.java | 38 ++++++++- .../java/games/backgammon/BGStateJSON.java | 69 +++------------- .../java/games/descent2e/DescentGameData.java | 8 +- .../games/descent2e/components/Figure.java | 8 +- .../components/cards/SearchCard.java | 6 +- 11 files changed, 242 insertions(+), 182 deletions(-) diff --git a/data/descent2e/heroes.json b/data/descent2e/heroes.json index 2b27cd269..396a65dd7 100644 --- a/data/descent2e/heroes.json +++ b/data/descent2e/heroes.json @@ -1,7 +1,7 @@ [ { - "id": "Widow Tarha", - "type": ["String", "Hero"], + "name": "Widow Tarha", + "tokenType": "Hero", "archetype": ["String", "Mage"], "MovePoints": ["Integer", 4], "Health": ["Integer", 10], @@ -17,8 +17,8 @@ "quote": ["String", "\"I have nothing left to lose. Yet, there is so much more power for me to gain.\""] }, { - "id": "Leoric of the Book", - "type": ["String", "Hero"], + "name": "Leoric of the Book", + "tokenType": "Hero", "archetype": ["String", "Mage"], "MovePoints": ["Integer", 4], "Health": ["Integer", 8], @@ -34,8 +34,8 @@ "quote": ["String", "\"If my years of study have taught me anything, it is that I am worthy of the knowledge I possess.\""] }, { - "id": "Avric Albright", - "type": ["String", "Hero"], + "name": "Avric Albright", + "tokenType": "Hero", "archetype": ["String", "Healer"], "MovePoints": ["Integer", 4], "Health": ["Integer", 12], @@ -51,8 +51,8 @@ "quote": ["String", "\"I pledge myself to those in need. I will be their shield, their light in the darkness.\""] }, { - "id": "Ashrian", - "type": ["String", "Hero"], + "name": "Ashrian", + "tokenType": "Hero", "archetype": ["String", "Healer"], "MovePoints": ["Integer", 5], "Health": ["Integer", 10], @@ -68,8 +68,8 @@ "quote": ["String", "\"I no more understand why I can hear the spirits speak than I understand why you cannot.\""] }, { - "id": "Syndrael", - "type": ["String", "Hero"], + "name": "Syndrael", + "tokenType": "Hero", "archetype": ["String", "Warrior"], "MovePoints": ["Integer", 4], "Health": ["Integer", 12], @@ -85,8 +85,8 @@ "quote": ["String", "\"You swear undying loyalty, yet you are mortal. What can you know of commitment?\""] }, { - "id": "Grisban the Thirsty", - "type": ["String", "Hero"], + "name": "Grisban the Thirsty", + "tokenType": "Hero", "archetype": ["String", "Warrior"], "MovePoints": ["Integer", 3], "Health": ["Integer", 14], @@ -102,8 +102,8 @@ "quote": ["String", "\"All this killing is thirsty work. Drink with me!\""] }, { - "id": "Tomble Burrowell", - "type": ["String", "Hero"], + "name": "Tomble Burrowell", + "tokenType": "Hero", "archetype": ["String", "Scout"], "MovePoints": ["Integer", 4], "Health": ["Integer", 8], @@ -119,8 +119,8 @@ "quote": ["String", "\"Don't think of this as you being robbed. Instead, think of it as you donating to a worthy cause.\""] }, { - "id": "Jain Fairwood", - "type": ["String", "Hero"], + "name": "Jain Fairwood", + "tokenType": "Hero", "archetype": ["String", "Scout"], "MovePoints": ["Integer", 5], "Health": ["Integer", 8], diff --git a/data/descent2e/monsters.json b/data/descent2e/monsters.json index 6ca4043f8..5470b3249 100644 --- a/data/descent2e/monsters.json +++ b/data/descent2e/monsters.json @@ -2,8 +2,8 @@ { "monster-sources": "https://descent2e.fandom.com/wiki/Monster", - "id": "Goblin Archer", - "type": ["String", "Monster"], + "name": "Goblin Archer", + "tokenType": "Monster", "setup": ["Integer[]", [2, 3, 4]], "traits": ["String[]", ["Building", "Cave"]], "size": ["String", "1x1"], @@ -53,8 +53,8 @@ }, { - "id": "Barghest", - "type": ["String", "Monster"], + "name": "Barghest", + "tokenType": "Monster", "setup": ["Integer[]", [1, 2, 3]], "traits": ["String[]", ["Wilderness", "Dark"]], "size": ["String", "1x2"], @@ -108,8 +108,8 @@ }, { - "id": "Zombie", - "type": ["String", "Monster"], + "name": "Zombie", + "tokenType": "Monster", "setup": ["Integer[]", [2, 3, 4]], "traits": ["String[]", ["Cursed", "Building"]], "size": ["String", "1x1"], @@ -161,8 +161,8 @@ }, { - "id": "Cave Spider", - "type": ["String", "Monster"], + "name": "Cave Spider", + "tokenType": "Monster", "setup": ["Integer[]", [2, 3, 4]], "traits": ["String[]", ["Wilderness", "Cave"]], "size": ["String", "1x1"], @@ -212,8 +212,8 @@ }, { - "id": "Flesh Moulder", - "type": ["String", "Monster"], + "name": "Flesh Moulder", + "tokenType": "Monster", "setup": ["Integer[]", [1, 2, 3]], "traits": ["String[]", ["Cursed", "Civilised"]], "size": ["String", "1x1"], @@ -265,8 +265,8 @@ }, { - "id": "Ettin", - "type": ["String", "Monster"], + "name": "Ettin", + "tokenType": "Monster", "setup": ["Integer[]", [1, 0, 1]], "traits": ["String[]", ["Mountain", "Cave"]], "size": ["String", "2x2"], @@ -318,8 +318,8 @@ }, { - "id": "Elemental", - "type": ["String", "Monster"], + "name": "Elemental", + "tokenType": "Monster", "setup": ["Integer[]", [1, 0, 1]], "traits": ["String[]", ["Cold", "Hot"]], "size": ["String", "2x2"], @@ -369,8 +369,8 @@ }, { - "id": "Merriod", - "type": ["String", "Monster"], + "name": "Merriod", + "tokenType": "Monster", "setup": ["Integer[]", [1, 0, 1]], "traits": ["String[]", ["Wilderness", "Water"]], "size": ["String", "2x2"], @@ -420,8 +420,8 @@ }, { - "id": "Shadow Dragon", - "type": ["String", "Monster"], + "name": "Shadow Dragon", + "tokenType": "Monster", "setup": ["Integer[]", [1, 0, 1]], "traits": ["String[]", ["Dark", "Cave"]], "size": ["String", "2x3"], diff --git a/data/descent2e/searchCards.json b/data/descent2e/searchCards.json index 118686e5b..88e61e3c0 100644 --- a/data/descent2e/searchCards.json +++ b/data/descent2e/searchCards.json @@ -1,62 +1,62 @@ [ { - "name": ["String", "Health Potion"], - "type": ["String", "Potion"], - "value": ["Integer", 25] + "name": "Health Potion", + "tokenType": "Potion", + "value": 25 }, { - "name": ["String", "Health Potion"], - "type": ["String", "Potion"], - "value": ["Integer", 25] + "name": "Health Potion", + "tokenType": "Potion", + "value": 25 }, { - "name": ["String", "Health Potion"], - "type": ["String", "Potion"], - "value": ["Integer", 25] + "name": "Health Potion", + "tokenType": "Potion", + "value": 25 }, { - "name": ["String", "Stamina Potion"], - "type": ["String", "Potion"], - "value": ["Integer", 25] + "name": "Stamina Potion", + "tokenType": "Potion", + "value": 25 }, { - "name": ["String", "Stamina Potion"], - "type": ["String", "Potion"], - "value": ["Integer", 25] + "name": "Stamina Potion", + "tokenType": "Potion", + "value": 25 }, { - "name": ["String", "Stamina Potion"], - "type": ["String", "Potion"], - "value": ["Integer", 25] + "name": "Stamina Potion", + "tokenType": "Potion", + "value": 25 }, { - "name": ["String", "Power Potion"], - "type": ["String", "Potion"], - "value": ["Integer", 50] + "name": "Power Potion", + "tokenType": "Potion", + "value": 50 }, { - "name": ["String", "Warding Talisman"], - "type": ["String", "Item"], - "value": ["Integer", 50] + "name": "Warding Talisman", + "tokenType": "Item", + "value": 50 }, { - "name": ["String", "Curse Doll"], - "type": ["String", "Item"], - "value": ["Integer", 50] + "name": "Curse Doll", + "tokenType": "Item", + "value": 50 }, { - "name": ["String", "Fire Flask"], - "type": ["String", "Item"], - "value": ["Integer", 50] + "name": "Fire Flask", + "tokenType": "Item", + "value": 50 }, { - "name": ["String", "Treasure Chest"], - "type": ["String", "Special"], - "value": ["Integer", 0] + "name": "Treasure Chest", + "tokenType": "Special", + "value": 0 }, { - "name": ["String", "Nothing"], - "type": ["String", "Special"], - "value": ["Integer", 0] + "name": "Nothing", + "tokenType": "Special", + "value": 0 } ] \ No newline at end of file diff --git a/data/pandemic/tokens.json b/data/pandemic/tokens.json index 4650e6973..a18808946 100644 --- a/data/pandemic/tokens.json +++ b/data/pandemic/tokens.json @@ -1,49 +1,49 @@ [ { - "id": "Green pawn", - "type": ["String", "Pawn"], + "name": "Green pawn", + "tokenType": "Pawn", "color": ["Color", "green"], "count": ["Integer", 1] }, { - "id": "Orange pawn", - "type": ["String", "Pawn"], + "name": "Orange pawn", + "tokenType": "Pawn", "color": ["Color", "orange"], "count": ["Integer", 1] }, { - "id": "White pawn", - "type": ["String","Pawn"], + "name": "White pawn", + "tokenType": "Pawn", "color": ["Color", "white"], "count": ["Integer", 1] }, { - "id": "Light green pawn", - "type": ["String", "Pawn"], + "name": "Light green pawn", + "tokenType": "Pawn", "color": ["Color", "light green"], "count": ["Integer", 1] }, { - "id": "Brown pawn", - "type": ["String", "Pawn"], + "name": "Brown pawn", + "tokenType": "Pawn", "color": ["Color", "brown"], "count": ["Integer", 1] }, { - "id": "Pink pawn", - "type": ["String", "Pawn"], + "name": "Pink pawn", + "tokenType": "Pawn", "color": ["Color", "pink"], "count": ["Integer", 1] }, { - "id": "Blue pawn", - "type": ["String", "Pawn"], + "name": "Blue pawn", + "tokenType": "Pawn", "color": ["Color", "blue"], "count": ["Integer", 1] }, { - "id": "Research Stations", - "type": ["String", "research station"], + "name": "Research Stations", + "tokenType": "research station", "color": ["Color", "white"], "count": ["Integer", 6] } diff --git a/src/main/java/core/AbstractGameState.java b/src/main/java/core/AbstractGameState.java index 7c3cc33af..99af762e0 100644 --- a/src/main/java/core/AbstractGameState.java +++ b/src/main/java/core/AbstractGameState.java @@ -38,39 +38,72 @@ */ public abstract class AbstractGameState { - // Parameters, forward model and turn order for the game - protected AbstractParameters gameParameters; + + /** + * The following fields are serializable into JSON with abstractGameStateToJSON. + * They are then populated on reconstruction from JSON with loadAbstractGameStateFromJSON. + * + * GameParameters are only fully supported in serialization/deserialization if they extend TunableParameters + * (which is the only case in which they can be easily changed from their default values) + */ // Game being played - protected final GameType gameType = _getGameType(); - private Area allComponents; + protected final GameType gameType = _getGameType(); // not re-loaded, but serialized as read-only // Game tick, number of iterations of game loop private int tick = 0; - - // Migrated from TurnOrder...may move later protected int roundCounter, turnCounter, turnOwner, firstPlayer; protected int nPlayers; protected int nTeams; - protected List listeners = new ArrayList<>(); + private int gameID; - // Timers for all players - protected ElapsedCpuChessTimer[] playerTimer; + // Status of the game, and status for each player (in cooperative games, the game status is also each player's status) + protected CoreConstants.GameResult gameStatus; + protected CoreConstants.GameResult[] playerResults; + + // Parameters, forward model and turn order for the game + protected AbstractParameters gameParameters; + + /** + * The fields below are NOT serialized / re-loaded + * + * allComponents is built from _getAllComponents() when the state is initialised + * history/historyText are omitted to save space, and they are intended to provide an audit trail for debugging, and no functionality should ever be based on them + * + * gamePhase needs to be serialized/reloaded, but that has to be done currently in the child implementation + * as we have no means of knowing the class to instantiate + * + * actionsInProgress could be serialized, but that requires the game implementation to support this for all ExtendedSequence implementations + * for the moment TAGG will throw an error if serialization is attempted with any active actions on the stack + * + * coreGameParameters deals with competition disqualification and non-standard action spaces. If could be serialized straightforwardly if needed + * but for the moment any reload will just pick up the defaults + * + * gameListeners and playerTimers are also not considered part of the 'real' game state, much like CoreParameters. If these are + * important then they will need to be set up post reload + * + * random number generators are excluded deliberately. Any future need to rerun a game multiple times with the same seed is not currently supported (but would not be difficult) + */ + // Current game phase + protected IGamePhase gamePhase; + + private Area allComponents; + protected List listeners = new ArrayList<>(); // A record of all actions taken to reach this game state // The history is stored as a list of pairs, where the first element is the player who took the action // this is in chronological order + // DO NOT implement game functionality that relies on history or historyText. If there is important state that the game depends on, + // then implement it as a separate field. These history fields are intended for auditing/debugging and should not be used for game logic. private List> history = new ArrayList<>(); private List historyText = new ArrayList<>(); - // Status of the game, and status for each player (in cooperative games, the game status is also each player's status) - protected CoreConstants.GameResult gameStatus; - protected CoreConstants.GameResult[] playerResults; - // Current game phase - protected IGamePhase gamePhase; // Stack for extended actions Stack actionsInProgress = new Stack<>(); CoreParameters coreGameParameters; - private int gameID; + + // Timers for all players + protected ElapsedCpuChessTimer[] playerTimer; + // rnd is used for all random number generation in the game - for events within the game protected Random rnd; // redeterminisationRnd is used for redeterminisation only - this is to ensure that the main game is not affected @@ -718,10 +751,16 @@ public final int[] superHashCodeArray() { }; } + /** + * This converts all fields within AbstractGameState to a JSON object. + * An extension of AbstractGameState that implements IToJSON (and hence is serializable), calls this + * method to create one JSON moiety in the serialized game state. + */ public JSONObject abstractGameStateToJSON() { - if (!actionsInProgress.isEmpty()) + if (isActionInProgress()) throw new IllegalStateException("Cannot yet serialize game state with ongoing actions"); JSONObject json = new JSONObject(); + json.put("gameType", gameType.name()); // for info json.put("gameStatus", gameStatus.name()); if (gamePhase instanceof Enum) { json.put("gamePhase", ((Enum) gamePhase).name()); @@ -735,6 +774,7 @@ public JSONObject abstractGameStateToJSON() { json.put("firstPlayer", firstPlayer); json.put("gameID", gameID); json.put("nPlayers", nPlayers); + json.put("nTeams", nTeams); if (gameParameters instanceof TunableParameters tunableParameters) { json.put("gameParams", tunableParameters.instanceToJSON(true, new HashMap<>())); } @@ -747,7 +787,12 @@ public JSONObject abstractGameStateToJSON() { return json; } + /** + * This is the obverse of abstractGameStateToJSON. It takes a JSON object created by that method + * and uses it to populate the fields of this AbstractGameState instance. + */ public void loadAbstractGameStateFromJSON(JSONObject json) { + this.gameStatus = CoreConstants.GameResult.valueOf((String) json.get("gameStatus")); this.turnOwner = ((Number) json.get("turnOwner")).intValue(); this.turnCounter = ((Number) json.get("turnCounter")).intValue(); @@ -756,6 +801,8 @@ public void loadAbstractGameStateFromJSON(JSONObject json) { this.firstPlayer = ((Number) json.get("firstPlayer")).intValue(); this.gameID = json.containsKey("gameID") ? ((Number) json.get("gameID")).intValue() : -1; this.nPlayers = ((Number) json.get("nPlayers")).intValue(); + this.nTeams = ((Number) json.get("nTeams")).intValue(); + if (json.containsKey("gameParams")) { // We only support serialization of tunable parameters (otherwise we just pick up the defaults) this.gameParameters = loadFromJSON((TunableParameters) getGameParameters(), (JSONObject) json.get("gameParams")); } @@ -770,7 +817,7 @@ public void loadAbstractGameStateFromJSON(JSONObject json) { try { this.gamePhase = CoreConstants.DefaultGamePhase.valueOf(phaseStr); } catch (Exception e) { - // Ignore, maybe subclass will handle it + // Ignore, subclass is expected to handle this } } } diff --git a/src/main/java/core/components/Dice.java b/src/main/java/core/components/Dice.java index 67c931517..3cb3a5f01 100644 --- a/src/main/java/core/components/Dice.java +++ b/src/main/java/core/components/Dice.java @@ -3,13 +3,14 @@ import java.util.*; import core.CoreConstants; +import core.interfaces.IToJSON; import org.json.simple.JSONArray; import org.json.simple.JSONObject; import utilities.JSONUtils; import static core.components.Dice.Type.*; -public class Dice extends Component { +public class Dice extends Component implements IToJSON { public enum Type{ d3(3), d4(4), @@ -58,11 +59,8 @@ public Dice(double[] pdf) { this.nSides = pdf.length; this.type = sidesToType(nSides); } - public Dice(String json) { - super(CoreConstants.ComponentType.DICE); - JSONObject data = JSONUtils.loadJSONFile(json); - this.nSides = ((Long) data.get("nSides")).intValue(); - this.type = sidesToType(nSides); + public Dice(JSONObject data) { + this(((Number) data.get("nSides")).intValue()); Object pdfObj = data.get("pdf"); if (pdfObj != null) { JSONArray arr = (JSONArray) pdfObj; @@ -74,6 +72,23 @@ public Dice(String json) { if (Math.abs(total - 1.0) > 0.000001) throw new IllegalArgumentException("Invalid PDF in Dice: " + Arrays.toString(pdf)); } + if (data.containsKey("value")) { + this.value = ((Number) data.get("value")).intValue(); + } + if (data.containsKey("id")) { + int id = ((Number) data.get("id")).intValue(); + try { + java.lang.reflect.Field f = Component.class.getDeclaredField("componentID"); + f.setAccessible(true); + f.set(this, id); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + + public Dice(String json) { + this(JSONUtils.loadJSONFile(json)); } private Dice(Type type, int nSides, int value, int ID) { @@ -143,4 +158,19 @@ public Dice copy() { public final int hashCode() { return componentID; } + + @Override + public JSONObject toJSON() { + JSONObject dJSON = new JSONObject(); + dJSON.put("value", value); + dJSON.put("nSides", nSides); + if (pdf != null && pdf.length > 0) { + JSONArray pdfArray = new JSONArray(); + for (double p : pdf) pdfArray.add(p); + dJSON.put("pdf", pdfArray); + } + dJSON.put("id", componentID); + return dJSON; + } + } diff --git a/src/main/java/core/components/Token.java b/src/main/java/core/components/Token.java index 0de01287e..ef55632b6 100644 --- a/src/main/java/core/components/Token.java +++ b/src/main/java/core/components/Token.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import core.CoreConstants; +import core.interfaces.IToJSON; import org.json.simple.JSONArray; import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; @@ -12,7 +13,7 @@ import java.util.List; -public class Token extends Component { +public class Token extends Component implements IToJSON { protected String tokenType; public Token(String name){ @@ -80,8 +81,18 @@ public static List loadTokens(String filename) * @param token - JSON to parse into Token object. */ protected void loadToken(JSONObject token) { - this.tokenType = (String) ( (JSONArray) token.get("type")).get(1); - this.componentName = (String) token.get("id"); + if (token.containsKey("tokenType")) { + this.tokenType = (String) token.get("tokenType"); + } + + if (token.containsKey("name")) { + this.componentName = (String) token.get("name"); + } + + if (token.containsKey("ownerId")) { + this.ownerId = ((Number) token.get("ownerId")).intValue(); + } + parseComponent(this, token); } @@ -94,4 +105,25 @@ public int hashCode() { public String toString() { return tokenType; } + + @Override + public JSONObject toJSON() { + JSONObject tJSON = new JSONObject(); + tJSON.put("name", componentName); + tJSON.put("ownerId", ownerId); + tJSON.put("id", componentID); + tJSON.put("tokenType", tokenType); + return tJSON; + } + + public static Token loadFromJSON(JSONObject tJSON) { + Token t; + if (tJSON.containsKey("id") && tJSON.get("id") instanceof Number) { + t = new Token("", ((Number) tJSON.get("id")).intValue()); + } else { + t = new Token(""); + } + t.loadToken(tJSON); + return t; + } } diff --git a/src/main/java/games/backgammon/BGStateJSON.java b/src/main/java/games/backgammon/BGStateJSON.java index 9c44c3d3d..cb05ad5a0 100644 --- a/src/main/java/games/backgammon/BGStateJSON.java +++ b/src/main/java/games/backgammon/BGStateJSON.java @@ -1,6 +1,5 @@ package games.backgammon; -import core.CoreConstants; import core.components.Dice; import core.components.Token; import core.components.Component; @@ -9,23 +8,16 @@ import org.json.simple.JSONObject; import utilities.JSONUtils; + import java.util.*; public class BGStateJSON { public static void loadFromJSON(BGGameState state, JSONObject json) { - try { - JSONObject abstractGS = (JSONObject) json.get("abstractGameState"); - state.loadAbstractGameStateFromJSON(abstractGS); - String phaseStr = (String) abstractGS.get("gamePhase"); - try { - state.setGamePhase(BGGamePhase.valueOf(phaseStr)); - } catch (Exception e) { - // If this fails, then it was either DefaultGamePhase (already handled) or something else - } - } catch (Exception e) { - throw e; - } + JSONObject abstractGS = (JSONObject) json.get("abstractGameState"); + state.loadAbstractGameStateFromJSON(abstractGS); + String phaseStr = (String) abstractGS.get("gamePhase"); + state.setGamePhase(BGGamePhase.valueOf(phaseStr)); state.piecesBorneOff = JSONUtils.intArrayFromJSON((JSONArray) json.get("piecesBorneOff")); state.blots = JSONUtils.intArrayFromJSON((JSONArray) json.get("blots")); @@ -35,20 +27,7 @@ public static void loadFromJSON(BGGameState state, JSONObject json) { JSONArray diceArray = (JSONArray) json.get("dice"); state.dice = new Dice[diceArray.size()]; for (int i = 0; i < diceArray.size(); i++) { - JSONObject dJSON = (JSONObject) diceArray.get(i); - int nSides = ((Number) dJSON.get("nSides")).intValue(); - Dice d; - if (dJSON.containsKey("pdf")) { - JSONArray pdfArray = (JSONArray) dJSON.get("pdf"); - double[] pdf = new double[pdfArray.size()]; - for (int j = 0; j < pdfArray.size(); j++) pdf[j] = ((Number) pdfArray.get(j)).doubleValue(); - d = new Dice(pdf); - } else { - d = new Dice(nSides); - } - d.setValue(((Number) dJSON.get("value")).intValue()); - setComponentID(d, ((Number) dJSON.get("id")).intValue()); - state.dice[i] = d; + state.dice[i] = new Dice((JSONObject) diceArray.get(i)); } JSONArray countersArray = (JSONArray) json.get("counters"); @@ -58,10 +37,7 @@ public static void loadFromJSON(BGGameState state, JSONObject json) { JSONArray pointArray = (JSONArray) countersArray.get(i); List pointTokens = new ArrayList<>(); for (int j = 0; j < pointArray.size(); j++) { - JSONObject tJSON = (JSONObject) pointArray.get(j); - Token t = new Token((String) tJSON.get("name"), ((Number) tJSON.get("id")).intValue()); - t.setOwnerId(((Number) tJSON.get("ownerId")).intValue()); - t.setTokenType((String) tJSON.get("tokenType")); + Token t = Token.loadFromJSON((JSONObject) pointArray.get(j)); pointTokens.add(t); tokenMap.put(t.getComponentID(), t); } @@ -82,8 +58,8 @@ public static void loadFromJSON(BGGameState state, JSONObject json) { public static JSONObject toJSON(BGGameState state) { JSONObject json = new JSONObject(); - json.put("gameParams", ((TunableParameters)state.getGameParameters()).instanceToJSON(false, new HashMap<>())); + // this includes parameters and game phase json.put("abstractGameState", state.abstractGameStateToJSON()); json.put("piecesBorneOff", JSONUtils.intArrayToJSON(state.piecesBorneOff)); @@ -93,17 +69,7 @@ public static JSONObject toJSON(BGGameState state) { JSONArray dice = new JSONArray(); for (Dice d : state.dice) { - JSONObject dJSON = new JSONObject(); - dJSON.put("value", d.getValue()); - dJSON.put("nSides", d.getNumberOfSides()); - double[] pdf = d.getPdf(); - if (pdf != null && pdf.length > 0) { - JSONArray pdfArray = new JSONArray(); - for (double p : pdf) pdfArray.add(p); - dJSON.put("pdf", pdfArray); - } - dJSON.put("id", d.getComponentID()); - dice.add(dJSON); + dice.add(d.toJSON()); } json.put("dice", dice); @@ -111,12 +77,7 @@ public static JSONObject toJSON(BGGameState state) { for (List point : state.counters) { JSONArray pointArray = new JSONArray(); for (Token t : point) { - JSONObject tJSON = new JSONObject(); - tJSON.put("name", t.getComponentName()); - tJSON.put("ownerId", t.getOwnerId()); - tJSON.put("id", t.getComponentID()); - tJSON.put("tokenType", t.getTokenType()); - pointArray.add(tJSON); + pointArray.add(t.toJSON()); } counters.add(pointArray); } @@ -131,14 +92,4 @@ public static JSONObject toJSON(BGGameState state) { return json; } - - private static void setComponentID(Component c, int id) { - try { - java.lang.reflect.Field f = Component.class.getDeclaredField("componentID"); - f.setAccessible(true); - f.set(c, id); - } catch (Exception e) { - e.printStackTrace(); - } - } } diff --git a/src/main/java/games/descent2e/DescentGameData.java b/src/main/java/games/descent2e/DescentGameData.java index 4b8a78e86..a840a0b63 100644 --- a/src/main/java/games/descent2e/DescentGameData.java +++ b/src/main/java/games/descent2e/DescentGameData.java @@ -309,19 +309,19 @@ private static HashMap> loadMonsters(String dat for (Object o : data) { JSONObject obj = (JSONObject) o; - String key = (String) obj.get("id"); + String key = (String) obj.get("name"); Monster superT = new Monster(); HashSet ignoreKeys = new HashSet(){{ add("act1"); add("act2"); - add("id"); + add("name"); }}; superT.loadFigure(obj, ignoreKeys); ignoreKeys.clear(); - ignoreKeys.add("type"); - ignoreKeys.add("id"); + ignoreKeys.add("tokenType"); + ignoreKeys.add("name"); HashMap monsterDef = new HashMap<>(); Monster act1m = new Monster(); diff --git a/src/main/java/games/descent2e/components/Figure.java b/src/main/java/games/descent2e/components/Figure.java index 3e55a1cab..b1bcb4831 100644 --- a/src/main/java/games/descent2e/components/Figure.java +++ b/src/main/java/games/descent2e/components/Figure.java @@ -370,11 +370,11 @@ public void copyComponentTo(Figure copyTo) { } public void loadFigure(JSONObject figure, Set ignoreKeys) { - if (!ignoreKeys.contains("id")) { - this.componentName = (String) figure.get("id"); + if (figure.containsKey("name") && !ignoreKeys.contains("name")) { + this.componentName = (String) figure.get("name"); } - if (!ignoreKeys.contains("type")) { - this.tokenType = (String) ((JSONArray) figure.get("type")).get(1); + if (figure.containsKey("tokenType") && !ignoreKeys.contains("tokenType")) { + this.tokenType = (String) figure.get("tokenType"); } // TODO: custom load of figure properties parseComponent(this, figure, ignoreKeys); diff --git a/src/main/java/games/descent2e/components/cards/SearchCard.java b/src/main/java/games/descent2e/components/cards/SearchCard.java index 5629cd167..c753c6964 100644 --- a/src/main/java/games/descent2e/components/cards/SearchCard.java +++ b/src/main/java/games/descent2e/components/cards/SearchCard.java @@ -49,9 +49,9 @@ public static Deck loadCards(String filename) { } public void loadCard(JSONObject dice) { - this.componentName = ((String) ( (JSONArray) dice.get("name")).get(1)); - this.itemType = ((String) ( (JSONArray) dice.get("type")).get(1)); - this.value = ((Long) ( (JSONArray) dice.get("value")).get(1)).intValue(); + this.componentName = (String) dice.get("name"); + this.itemType = (String) dice.get("tokenType"); + this.value = ((Number) dice.get("value")).intValue(); parseComponent(this, dice); } From 964e7f1885b8e0475e8f3252c5365ee56941d092 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Sat, 9 May 2026 13:30:24 +0100 Subject: [PATCH 08/41] Added custom heuristic to lose a game narrowly --- .../heuristics/CoarseTunableHeuristic.java | 2 +- .../heuristics/LoseLongGameHeuristic.java | 107 ++++++++++++++ .../heuristics/LoseLongGameHeuristicTest.java | 133 ++++++++++++++++++ 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 src/main/java/players/heuristics/LoseLongGameHeuristic.java create mode 100644 src/test/java/players/heuristics/LoseLongGameHeuristicTest.java diff --git a/src/main/java/players/heuristics/CoarseTunableHeuristic.java b/src/main/java/players/heuristics/CoarseTunableHeuristic.java index f6392a71b..3d76bdf0c 100644 --- a/src/main/java/players/heuristics/CoarseTunableHeuristic.java +++ b/src/main/java/players/heuristics/CoarseTunableHeuristic.java @@ -17,7 +17,7 @@ * - SCORE_PLUS: the raw score, plus 50% if you won; -50% if you lost * - LEADER: the difference of your score to the next best player (+/-50% if you won/lost) */ -public class CoarseTunableHeuristic extends TunableParameters implements IStateHeuristic { +public class CoarseTunableHeuristic extends TunableParameters implements IStateHeuristic { HeuristicType heuristicType; public enum HeuristicType { diff --git a/src/main/java/players/heuristics/LoseLongGameHeuristic.java b/src/main/java/players/heuristics/LoseLongGameHeuristic.java new file mode 100644 index 000000000..9a98f54eb --- /dev/null +++ b/src/main/java/players/heuristics/LoseLongGameHeuristic.java @@ -0,0 +1,107 @@ +package players.heuristics; + +import core.AbstractGameState; +import core.CoreConstants; +import core.interfaces.IStateHeuristic; +import evaluation.optimisation.TunableParameters; + + +/** + * A Heuristic to target the narrow loss of a long game. + * weightForGameLength: how much to weight the game length in the heuristic (between 0 and 1) + * approxMaxGameLength: the approximate maximum game length (used to normalise the game length component of the heuristic) + * approxExpectedScore: the approximate expected score at the end of the game (used to normalise the score component of the heuristic) + * targetWinDiff: the target win difference to aim for (used to normalise the score component of the heuristic) + * + * We reward long games (and penalise anything less than minGameLength). + * We reward losing by a small margin (targetLossDiff) and penalise losing by a large margin or winning. + * + * The total heuristic value is: + * sigmoid((length - minGameLength) / CT) * weightForGameLength + + * sigmoid((targetLossDiff - lossDiff) / CS) * (1 - weightForGameLength) - + * winPenalty (if the game is won) + * + * CS, CT control the steepness of the sigmoid functions for the score and game length components, respectively. + * They are calculated as scale * minGameLength and targetLossDiff respectively. + */ +public class LoseLongGameHeuristic extends TunableParameters implements IStateHeuristic { + + double weightForGameLength = 0.5; + int minGameLength = 50; + int targetLossDiff = 5; + double winPenalty = 0.5; // how much to penalise winning (between 0 and 1) + double sigmoidScale = 0.2; // how steep the sigmoid functions are (between 0 and 1) + + public LoseLongGameHeuristic() { + addTunableParameter("weightForGameLength", 0.5); + addTunableParameter("minGameLength", 50); + addTunableParameter("targetLossDiff", 5); + addTunableParameter("winPenalty", 0.5); + addTunableParameter("sigmoidScale", 0.2); + } + + private double sigmoid(double x) { + return 1 / (1 + Math.exp(-x)); + } + + @Override + public double evaluateState(AbstractGameState gs, int playerId) { + int gameLength = gs.getGameTick(); + double lengthComponent = sigmoid((gameLength - minGameLength) / (sigmoidScale * minGameLength)) * weightForGameLength; + + double score = gs.getGameScore(playerId); + double bestOpponentScore = Double.NEGATIVE_INFINITY; + for (int p = 0; p < gs.getNPlayers(); p++) { + if (p == playerId) continue; + if (gs.getGameScore(p) > bestOpponentScore) { + bestOpponentScore = gs.getGameScore(p); + } + } + double lossDiff = bestOpponentScore - score; + double scoreComponent = sigmoid((targetLossDiff - lossDiff) / (sigmoidScale * targetLossDiff)); + // at this stage, 0.5 means we are at the target loss diff, which needs to be 1.0, declining in either direction + // so we scale + scoreComponent = (1.0 - Math.abs(scoreComponent - 0.5) * 2) * (1 - weightForGameLength); + + boolean isWin = gs.getPlayerResults()[playerId] == CoreConstants.GameResult.WIN_GAME; + double winComponent = isWin ? winPenalty : 0; + + double finalValue = lengthComponent + scoreComponent - winComponent; + if (finalValue < 0.0) finalValue = 0.0; // ensure we don't return negative values + return finalValue; + } + + @Override + public LoseLongGameHeuristic instantiate() { + return this._copy(); + } + + @Override + public void _reset() { + weightForGameLength = (double) getParameterValue("weightForGameLength"); + minGameLength = (int) getParameterValue("minGameLength"); + targetLossDiff = (int) getParameterValue("targetLossDiff"); + winPenalty = (double) getParameterValue("winPenalty"); + sigmoidScale = (double) getParameterValue("sigmoidScale"); + } + + @Override + protected LoseLongGameHeuristic _copy() { + return new LoseLongGameHeuristic(); // the value is then set in TunableParameters + } + + @Override + protected boolean _equals(Object o) { + return o instanceof LoseLongGameHeuristic; + } + + @Override + public String toString() { + return "LoseLongGameHeuristic with weightForGameLength " + weightForGameLength + + ", minGameLength " + minGameLength + + ", targetLossDiff " + targetLossDiff + + ", winPenalty " + winPenalty + + ", sigmoidScale " + sigmoidScale; + } + +} diff --git a/src/test/java/players/heuristics/LoseLongGameHeuristicTest.java b/src/test/java/players/heuristics/LoseLongGameHeuristicTest.java new file mode 100644 index 000000000..8f3235e1f --- /dev/null +++ b/src/test/java/players/heuristics/LoseLongGameHeuristicTest.java @@ -0,0 +1,133 @@ +package players.heuristics; + +import core.AbstractGameState; +import core.CoreConstants; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.*; + +public class LoseLongGameHeuristicTest { + + private final LoseLongGameHeuristic heuristic = new LoseLongGameHeuristic(); + private final int playerId = 0; + private final int minGameLength = 50; + + private double sigmoid(double x) { + return 1 / (1 + Math.exp(-x)); + } + + private AbstractGameState setupMockState(int gameTick, double player0Score, double player1Score, CoreConstants.GameResult p0Result, CoreConstants.GameResult p1Result) { + AbstractGameState gs = mock(AbstractGameState.class); + when(gs.getGameTick()).thenReturn(gameTick); + when(gs.getGameScore(0)).thenReturn(player0Score); + when(gs.getGameScore(1)).thenReturn(player1Score); + when(gs.getNPlayers()).thenReturn(2); + when(gs.getPlayerResults()).thenReturn(new CoreConstants.GameResult[]{p0Result, p1Result}); + return gs; + } + + private double calculateExpected(int gameTick, double player0Score, double player1Score, boolean isWin) { + double sigmoidScale = 0.2; + double weightForGameLength = 0.5; + double lengthComponent = sigmoid((double) (gameTick - minGameLength) / (sigmoidScale * minGameLength)) * weightForGameLength; + double lossDiff = player1Score - player0Score; + int targetLossDiff = 5; + double scoreComponent = sigmoid((targetLossDiff - lossDiff) / (sigmoidScale * targetLossDiff)); + scoreComponent = (1.0 - Math.abs(scoreComponent - 0.5) * 2) * (1 - weightForGameLength); + double winPenalty = 0.5; + double winComponent = isWin ? winPenalty : 0; + return Math.max(0, lengthComponent + scoreComponent - winComponent); + } + + + @Test + public void gameInProgress_MinLengthGame_ExactDiff() { + // We have reached the min length (so 0.5 for the length) and are exactly at the target loss diff (so 1.0 for the score). + AbstractGameState gs = setupMockState(minGameLength, 5.0, 10.0, CoreConstants.GameResult.GAME_ONGOING, CoreConstants.GameResult.GAME_ONGOING); + double expected = 0.75; + System.out.println("Expected score for min length and exact diff: " + expected); + assertEquals(expected, heuristic.evaluateState(gs, playerId), 0.0001); + } + + @Test + public void gameInProgress_MinLengthGame_ABitOff() { + // We have reached the min length (so 0.5 for the length) and are exactly at the target loss diff (so 1.0 for the score). + AbstractGameState gs = setupMockState(minGameLength, 5.0, 6.0, CoreConstants.GameResult.GAME_ONGOING, CoreConstants.GameResult.GAME_ONGOING); + double expected = calculateExpected(minGameLength, 5.0, 6.0, false); + System.out.println("Expected score for min length and exact diff: " + expected); + assertEquals(expected, heuristic.evaluateState(gs, playerId), 0.0001); + expected = calculateExpected(minGameLength, 5.0, 4.0, false); + gs = setupMockState(minGameLength, 5.0, 4.0, CoreConstants.GameResult.GAME_ONGOING, CoreConstants.GameResult.GAME_ONGOING); + assertEquals(expected, heuristic.evaluateState(gs, playerId), 0.0001); + } + + @Test + public void gameInProgress_LongGame_EqualScores() { + // 1) Game is in progress, and the game length is 10 moves over the minimum. Scores are Equal. + int gameTick = minGameLength + 10; + AbstractGameState gs = setupMockState(gameTick, 10.0, 10.0, CoreConstants.GameResult.GAME_ONGOING, CoreConstants.GameResult.GAME_ONGOING); + double expected = calculateExpected(gameTick, 10.0, 10.0, false); + System.out.println("Expected +10 moves, equal scores: " + expected); + assertEquals(expected, heuristic.evaluateState(gs, playerId), 0.0001); + } + + @Test + public void gameInProgress_LongGame_WinningScore() { + // 2) As above, but Score is 5 points above opponent (i.e. winning) + int gameTick = minGameLength + 10; + AbstractGameState gs = setupMockState(gameTick, 15.0, 10.0, CoreConstants.GameResult.GAME_ONGOING, CoreConstants.GameResult.GAME_ONGOING); + double expected = calculateExpected(gameTick, 15.0, 10.0, false); + System.out.println("Expected score for +10 moves, winning score +5: " + expected); + assertEquals(expected, heuristic.evaluateState(gs, playerId), 0.0001); + } + + @Test + public void gameInProgress_LongGame_LosingScore() { + // 3) As above, but Score is 3 points below opponent (i.e. losing) + int gameTick = minGameLength + 10; + AbstractGameState gs = setupMockState(gameTick, 5.0, 10.0, CoreConstants.GameResult.GAME_ONGOING, CoreConstants.GameResult.GAME_ONGOING); + double expected = calculateExpected(gameTick, 5.0, 10.0, false); + System.out.println("Expected score for +10 moves, losing score -5: " + expected); + assertEquals(expected, heuristic.evaluateState(gs, playerId), 0.0001); + } + + @Test + public void gameInProgress_ShortGame_EqualScores() { + // 4) As above, but game length is 5 moves below minimum length. + int gameTick = minGameLength - 5; + AbstractGameState gs = setupMockState(gameTick, 10.0, 10.0, CoreConstants.GameResult.GAME_ONGOING, CoreConstants.GameResult.GAME_ONGOING); + double expected = calculateExpected(gameTick, 10.0, 10.0, false); + System.out.println("Expected score for short game (-5) with equal scores: " + expected); + assertEquals(expected, heuristic.evaluateState(gs, playerId), 0.0001); + } + + @Test + public void terminalWin_LongGame_WinningScore() { + // 5) Repeat 2 (Score 5 above opponent, 10 over min length), but with the game being Terminal (WIN). + int gameTick = minGameLength + 10; + AbstractGameState gs = setupMockState(gameTick, 15.0, 10.0, CoreConstants.GameResult.WIN_GAME, CoreConstants.GameResult.LOSE_GAME); + double expected = calculateExpected(gameTick, 15.0, 10.0, true); + System.out.println("Expected score for terminal win (+10) with winning score (+5): " + expected); + assertEquals(expected, heuristic.evaluateState(gs, playerId), 0.0001); + } + + @Test + public void terminalLoss_LongGame_LosingScore() { + // 5) Repeat 2 (Score 5 above opponent, 10 over min length), but with the game being Terminal (WIN). + int gameTick = minGameLength + 10; + AbstractGameState gs = setupMockState(gameTick, 0.0, 10.0, CoreConstants.GameResult.LOSE_GAME, CoreConstants.GameResult.LOSE_GAME); + double expected = calculateExpected(gameTick, 0.0, 10.0, false); + System.out.println("Expected score for terminal loss (+10 moves) with losing score (-10): " + expected); + assertEquals(expected, heuristic.evaluateState(gs, playerId), 0.0001); + } + + @Test + public void terminalLoss_ShortGame_EqualScores() { + // 6) Repeat 4 (Scores equal, 5 below min length), but with the game being Terminal (LOSE). + int gameTick = minGameLength - 5; + AbstractGameState gs = setupMockState(gameTick, 10.0, 10.0, CoreConstants.GameResult.LOSE_GAME, CoreConstants.GameResult.WIN_GAME); + double expected = calculateExpected(gameTick, 10.0, 10.0, false); + System.out.println("Expected score for terminal loss with equal scores: " + expected); + assertEquals(expected, heuristic.evaluateState(gs, playerId), 0.0001); + } +} From 407accda5174efe2ead7e279bcfc68c509b7e9e1 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Sun, 10 May 2026 12:12:57 +0100 Subject: [PATCH 09/41] Added ability to specify a Heuristic as a target in AbstractLearner/ExpertIteration --- src/main/java/evaluation/ExpertIteration.java | 7 +++-- .../evaluation/listeners/FeatureListener.java | 18 ++++++++++++ .../RolloutStateFeatureListener.java | 14 +++++++--- .../listeners/StateFeatureListener.java | 6 ++++ .../heuristics/LoseLongGameHeuristic.java | 14 +++++----- .../players/learners/AbstractLearner.java | 28 ++++++++++++++++--- .../java/players/learners/ApacheLearner.java | 4 +++ .../players/learners/LogisticLearner.java | 9 +++++- .../java/players/learners/OLSLearner.java | 7 +++++ .../heuristics/LoseLongGameHeuristicTest.java | 6 ++-- 10 files changed, 91 insertions(+), 22 deletions(-) diff --git a/src/main/java/evaluation/ExpertIteration.java b/src/main/java/evaluation/ExpertIteration.java index a1ec83566..0efd9d5c5 100644 --- a/src/main/java/evaluation/ExpertIteration.java +++ b/src/main/java/evaluation/ExpertIteration.java @@ -20,6 +20,7 @@ import org.apache.spark.sql.catalyst.types.PhysicalArrayType; import players.IAnyTimePlayer; import players.PlayerFactory; +import players.learners.AbstractLearner; import players.learners.LearnFromData; import players.mcts.MCTSExpertIterationListener; import players.mcts.MCTSPlayer; @@ -509,8 +510,6 @@ private int getTotalDataSize(String fileType) { } private RoundRobinTournament runTournament(List localAgents, Map runGamesConfig) { - - boolean allDataAsOne = config.get(RunArg.expertTrainingMode) == TrainingMode.Exponential; RoundRobinTournament tournament = new RoundRobinTournament(localAgents, gameToPlay, nPlayers, params, runGamesConfig); tournament.setTournamentResults(runningTournamentResults); @@ -542,16 +541,17 @@ private RoundRobinTournament runTournament(List localAgents, Map }; String fileName = String.format("State_%s_%02d.txt", prefix, allDataAsOne ? 0 : iter); stateDataFilesByIteration[allDataAsOne ? 0 : iter] = dataDir + File.separator + fileName; + AbstractLearner stateLearner = loadClass(stateLearnerFile); if (stateListener != null) { stateListener = stateListener .setSampleRate(stateSampleRate) + .setStateHeuristic(stateLearner.getHeuristic()) .setLogger(new FileStatsLogger(fileName, "\t", allDataAsOne)); stateListener.setOutputDirectory(dataDir); tournament.addListener(stateListener); } } if (actionLearnerFile != null) { - actionListener = switch (config.get(RunArg.actionTarget)) { case ActionTarget.Base -> new ActionFeatureListener(actionFeatureVector, stateFeatureVector, Event.GameEvent.ACTION_CHOSEN, @@ -576,6 +576,7 @@ private RoundRobinTournament runTournament(List localAgents, Map oracle.getParameters().setParameterValue("K", 1.0); yield new MCTSExpertIterationListener(oracle, actionFeatureVector, stateFeatureVector, 100, 0, stateLearnerFile != null && stateListener == null); + // TODO: MCTS listeners do not currently support Heuristic or FinalHeuristic Targets } // we record the MCTS stats for every action, plus the state features if we are not already recording them with a separate listener default -> diff --git a/src/main/java/evaluation/listeners/FeatureListener.java b/src/main/java/evaluation/listeners/FeatureListener.java index 19b78bae9..e9cf74d78 100644 --- a/src/main/java/evaluation/listeners/FeatureListener.java +++ b/src/main/java/evaluation/listeners/FeatureListener.java @@ -2,6 +2,7 @@ import core.*; import core.actions.AbstractAction; +import core.interfaces.IStateHeuristic; import core.interfaces.IStatisticLogger; import evaluation.loggers.FileStatsLogger; import evaluation.metrics.Event; @@ -27,11 +28,16 @@ public abstract class FeatureListener implements IGameListener { protected double sampleRate = 1.0; // what proportion of events to record protected Random rnd = new Random(); protected boolean recordEndGameState = true; + protected IStateHeuristic heuristic; protected FeatureListener(Event.GameEvent frequency, boolean currentPlayerOnly) { this.currentPlayerOnly = currentPlayerOnly; this.frequency = frequency; } + protected FeatureListener(Event.GameEvent frequency, boolean currentPlayerOnly, IStateHeuristic heuristic) { + this(frequency, currentPlayerOnly); + this.heuristic = heuristic; + } public FeatureListener setLogger(IStatisticLogger logger) { if (logger != null) { @@ -47,6 +53,11 @@ public FeatureListener setSampleRate(double rate) { return this; } + public FeatureListener setStateHeuristic(IStateHeuristic heuristic) { + this.heuristic = heuristic; + return this; + } + @Override public boolean setOutputDirectory(String... nestedDirectories) { @@ -125,6 +136,13 @@ public void writeDataWithStandardHeaders(AbstractGameState state) { if (!data.containsKey("Win")) { data.put("Win", winLoss[record.player]); } + double heuristicScore = heuristic == null ? + state.getHeuristicScore(record.player) : + heuristic.evaluateState(state, record.player); + data.put("Heuristic", heuristicScore); + if (!data.containsKey("FinalHeuristic")) { + data.put("FinalHeuristic", heuristicScore); + } data.put("ActualOrdinal", ordinal[record.player]); if (!data.containsKey("Ordinal")) { data.put("Ordinal", ordinal[record.player]); diff --git a/src/main/java/evaluation/listeners/RolloutStateFeatureListener.java b/src/main/java/evaluation/listeners/RolloutStateFeatureListener.java index 7a77f0123..2c2b9285e 100644 --- a/src/main/java/evaluation/listeners/RolloutStateFeatureListener.java +++ b/src/main/java/evaluation/listeners/RolloutStateFeatureListener.java @@ -46,8 +46,9 @@ public RolloutStateFeatureListener setRollouts(int rollouts) { @Override public String[] names() { // we add Value and Ordinal to the list, which will be calculated from the rollouts - String[] names = new String[super.names().length + 4]; + String[] names = new String[super.names().length + 5]; System.arraycopy(super.names(), 0, names, 0, super.names().length); + names[names.length - 5] = "FinalHeuristic"; names[names.length - 4] = "Win"; names[names.length - 3] = "FinalScore"; names[names.length - 2] = "FinalScoreAdv"; @@ -63,24 +64,26 @@ public void preProcessing(AbstractGameState state, AbstractAction action) { @Override public double[] extractDoubleVector(AbstractAction action, AbstractGameState state, int perspectivePlayer) { double[] base = super.extractDoubleVector(action, state, perspectivePlayer); - double[] retValue = new double[base.length + 4]; + double[] retValue = new double[base.length + 5]; System.arraycopy(base, 0, retValue, 0, base.length); retValue[base.length] = rolloutResults[0][perspectivePlayer]; retValue[base.length + 1] = rolloutResults[1][perspectivePlayer]; retValue[base.length + 2] = rolloutResults[2][perspectivePlayer]; retValue[base.length + 3] = rolloutResults[3][perspectivePlayer]; + retValue[base.length + 4] = rolloutResults[4][perspectivePlayer]; return retValue; } @Override public Object[] extractFeatureVector(AbstractAction action, AbstractGameState state, int perspectivePlayer) { Object[] base = super.extractFeatureVector(action, state, perspectivePlayer); - Object[] retValue = new Object[base.length + 4]; + Object[] retValue = new Object[base.length + 5]; System.arraycopy(base, 0, retValue, 0, base.length); retValue[base.length] = rolloutResults[0][perspectivePlayer]; retValue[base.length + 1] = rolloutResults[1][perspectivePlayer]; retValue[base.length + 2] = rolloutResults[2][perspectivePlayer]; retValue[base.length + 3] = rolloutResults[3][perspectivePlayer]; + retValue[base.length + 4] = rolloutResults[4][perspectivePlayer]; return retValue; } @@ -95,6 +98,7 @@ protected double[][] rolloutFrom(AbstractGameState state) { double[] totalOrdinal = new double[state.getNPlayers()]; double[] totalWin = new double[state.getNPlayers()]; double[] totalLead = new double[state.getNPlayers()]; + double[] totalHeuristic = new double[state.getNPlayers()]; for (int i = 0; i < rollouts; i++) { // firstly we reset our players (to remove any information they may have from previous rollouts) for (AbstractPlayer p : rolloutPlayers) { @@ -123,12 +127,14 @@ protected double[][] rolloutFrom(AbstractGameState state) { } } totalOrdinal[p] += copy.getOrdinalPosition(p); + totalHeuristic[p] += heuristic == null ? copy.getHeuristicScore(p) : heuristic.evaluateState(copy, p); } } + Arrays.setAll(totalHeuristic, p -> totalHeuristic[p] / rollouts); Arrays.setAll(totalWin, p -> totalWin[p] / rollouts); Arrays.setAll(totalScore, p -> totalScore[p] / rollouts); Arrays.setAll(totalLead, p -> totalLead[p] / rollouts); Arrays.setAll(totalOrdinal, p -> totalOrdinal[p] / rollouts); - return new double[][] {totalWin, totalLead, totalScore, totalOrdinal}; + return new double[][]{totalHeuristic, totalWin, totalLead, totalScore, totalOrdinal}; } } diff --git a/src/main/java/evaluation/listeners/StateFeatureListener.java b/src/main/java/evaluation/listeners/StateFeatureListener.java index 878193563..a6c4b6949 100644 --- a/src/main/java/evaluation/listeners/StateFeatureListener.java +++ b/src/main/java/evaluation/listeners/StateFeatureListener.java @@ -3,6 +3,7 @@ import core.AbstractGameState; import core.actions.AbstractAction; import core.interfaces.IStateFeatureVector; +import core.interfaces.IStateHeuristic; import evaluation.loggers.FileStatsLogger; import evaluation.metrics.Event; @@ -23,6 +24,11 @@ public StateFeatureListener(IStateFeatureVector phi, Event.GameEvent frequency, this.phiFn = phi; } + public StateFeatureListener(IStateFeatureVector phi, Event.GameEvent frequency, boolean currentPlayerOnly, IStateHeuristic heuristic) { + super(frequency, currentPlayerOnly, heuristic); + this.phiFn = phi; + } + @Override public String[] names() { return phiFn.names(); diff --git a/src/main/java/players/heuristics/LoseLongGameHeuristic.java b/src/main/java/players/heuristics/LoseLongGameHeuristic.java index 9a98f54eb..aaa9fc1d8 100644 --- a/src/main/java/players/heuristics/LoseLongGameHeuristic.java +++ b/src/main/java/players/heuristics/LoseLongGameHeuristic.java @@ -28,14 +28,14 @@ public class LoseLongGameHeuristic extends TunableParameters heuristic = Optional.empty(); public enum Target { WIN("Win", false), // 0 or 1 for loss/win @@ -33,6 +35,8 @@ public enum Target { ORD_MEAN("Ordinal", true), // as ORDINAL, but discounted to middle of the range based on rounds to final result ORD_SCALE("Ordinal", false), // as ORDINAL, but scaled to 0 to 1 (for Logistic regression targeting) ORD_MEAN_SCALE("Ordinal", true), // as ORD_MEAN, but scaled to 0 to 1 ( for Logistic regression targeting) + HEURISTIC("Heuristic", false), // targets a heuristic value (the heuristic needs to be supplied, with game.getHeuristicScore() being the default) + FINAL_HEURISTIC("FinalHeuristic", false), // targets the heuristic value at the end of the game (the heuristic needs to be supplied, with game.getHeuristicScore() being the default) ACTION_CHOSEN("CHOSEN", false), // targets the probability of the action taken ACTION_VISITS("VISIT_PROPORTION", false), ACTION_ADV("ADVANTAGE", false), // targets the advantage of the action taken @@ -59,16 +63,22 @@ public AbstractLearner() { } public AbstractLearner(double gamma, Target target, IStateFeatureVector stateFeatureVector) { - this.gamma = gamma; - this.targetType = target; - this.stateFeatureVector = stateFeatureVector; + this(gamma, target, stateFeatureVector, null, null); } public AbstractLearner(double gamma, Target target, IStateFeatureVector stateFeatureVector, IActionFeatureVector actionFeatureVector) { + this(gamma, target, stateFeatureVector, actionFeatureVector, null); + } + + public AbstractLearner(double gamma, Target target, + IStateFeatureVector stateFeatureVector, + IActionFeatureVector actionFeatureVector, + IStateHeuristic stateHeuristic) { this.gamma = gamma; this.targetType = target; this.stateFeatureVector = stateFeatureVector; this.actionFeatureVector = actionFeatureVector; + this.heuristic = stateHeuristic == null ? Optional.empty() : Optional.of(stateHeuristic); } public AbstractLearner setStateFeatureVector(IStateFeatureVector stateFeatureVector) { @@ -81,6 +91,11 @@ public AbstractLearner setActionFeatureVector(IActionFeatureVector actionFeature return this; } + public AbstractLearner setHeuristic(IStateHeuristic heuristic) { + this.heuristic = Optional.of(heuristic); + return this; + } + public IActionFeatureVector getActionFeatureVector() { return actionFeatureVector; } @@ -89,6 +104,10 @@ public IStateFeatureVector getStateFeatureVector() { return stateFeatureVector; } + public IStateHeuristic getHeuristic() { + return heuristic.orElse(null); + } + public AbstractLearner setGamma(double gamma) { this.gamma = gamma; return this; @@ -113,7 +132,8 @@ protected void loadData(String... files) { String[] specialColumns = {"GameID", "Player", "Turn", "Round", "Tick", "CurrentScore", "Win", "Ordinal", "FinalScore", "FinalScoreAdv", "TotalRounds", "PlayerCount", "TotalTurns", "TotalTicks", "ActualWin", "ActualOrdinal", "ActualScore", "ActualScoreAdv", - "CHOSEN", "ACTION_VISITS", "ADVANTAGE", "ACTION_VALUE", "VISIT_PROPORTION"}; + "CHOSEN", "ACTION_VISITS", "ADVANTAGE", "ACTION_VALUE", "VISIT_PROPORTION", + "Heuristic", "FinalHeuristic"}; Map indexForSpecialColumns = new HashMap<>(); // then set descriptions to the rest of the data diff --git a/src/main/java/players/learners/ApacheLearner.java b/src/main/java/players/learners/ApacheLearner.java index 83d1aea3c..e3d020d19 100644 --- a/src/main/java/players/learners/ApacheLearner.java +++ b/src/main/java/players/learners/ApacheLearner.java @@ -3,6 +3,7 @@ import com.globalmentor.apache.hadoop.fs.BareLocalFileSystem; import core.interfaces.IActionFeatureVector; import core.interfaces.IStateFeatureVector; +import core.interfaces.IStateHeuristic; import org.apache.hadoop.fs.FileSystem; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Row; @@ -42,6 +43,9 @@ public ApacheLearner(double gamma, Target target, IStateFeatureVector stateFeatu public ApacheLearner(double gamma, Target target, IStateFeatureVector stateFeatureVector, IActionFeatureVector actionFeatureVector) { super(gamma, target, stateFeatureVector, actionFeatureVector); } + public ApacheLearner(double gamma, Target target, IStateFeatureVector stateFeatureVector, IActionFeatureVector actionFeatureVector, IStateHeuristic heuristic) { + super(gamma, target, stateFeatureVector, actionFeatureVector, heuristic); + } @Override diff --git a/src/main/java/players/learners/LogisticLearner.java b/src/main/java/players/learners/LogisticLearner.java index 55892ed86..f2fbfbccd 100644 --- a/src/main/java/players/learners/LogisticLearner.java +++ b/src/main/java/players/learners/LogisticLearner.java @@ -1,7 +1,9 @@ package players.learners; import core.interfaces.IActionFeatureVector; +import core.interfaces.IStateFeatureJSON; import core.interfaces.IStateFeatureVector; +import core.interfaces.IStateHeuristic; import org.apache.spark.ml.feature.RFormula; import org.apache.spark.ml.regression.GeneralizedLinearRegression; import org.apache.spark.ml.regression.GeneralizedLinearRegressionModel; @@ -44,6 +46,11 @@ public LogisticLearner(double gamma, double regParam, Target target, IStateFeatu this.regParam = regParam; } + public LogisticLearner(double gamma, double regParam, IStateHeuristic heuristic) { + super(gamma, Target.FINAL_HEURISTIC, null, null, heuristic); + this.regParam = regParam; + } + public LogisticLearner(double gamma, double regParam, Target target, IStateFeatureVector stateFeatureVector, IActionFeatureVector actionFeatureVector) { super(gamma, target, stateFeatureVector, actionFeatureVector); this.regParam = regParam; @@ -77,7 +84,7 @@ public Object learnFromApacheData() { System.out.println(lrModel.coefficients()); if (this.actionFeatureVector == null) { - LogisticStateHeuristic retValue = new LogisticStateHeuristic(stateFeatureVector, coefficients, new WinOnlyHeuristic()); + LogisticStateHeuristic retValue = new LogisticStateHeuristic(stateFeatureVector, coefficients, heuristic.orElse(new WinOnlyHeuristic())); retValue.setModel(lrModel); return retValue; } else { diff --git a/src/main/java/players/learners/OLSLearner.java b/src/main/java/players/learners/OLSLearner.java index c4ce644f2..4daad4b6f 100644 --- a/src/main/java/players/learners/OLSLearner.java +++ b/src/main/java/players/learners/OLSLearner.java @@ -3,6 +3,8 @@ import core.interfaces.IActionFeatureVector; import core.interfaces.IStateFeatureVector; +import core.interfaces.IStateHeuristic; +import org.apache.spark.HeartbeatReceiver; import org.apache.spark.ml.feature.RFormula; import org.apache.spark.ml.regression.GeneralizedLinearRegression; import org.apache.spark.ml.regression.GeneralizedLinearRegressionModel; @@ -23,6 +25,10 @@ public OLSLearner() { public OLSLearner(double gamma, double regParam, Target target) { this(gamma, regParam, target, null, null); } + public OLSLearner(double gamma, double regParam, IStateHeuristic stateHeuristic) { + super(gamma, Target.FINAL_HEURISTIC, null, null, stateHeuristic); + this.regParam = regParam; + } public OLSLearner(Target target, IStateFeatureVector stateFeatureVector) { super(1.0, target, stateFeatureVector); @@ -86,6 +92,7 @@ Object learnFromApacheData() { case ORDINAL, ORD_MEAN, ORD_SCALE, ORD_MEAN_SCALE -> new OrdinalPosition(); case SCORE -> new PureScoreHeuristic(); case SCORE_DELTA -> new LeaderHeuristic(); + case HEURISTIC, FINAL_HEURISTIC -> heuristic.orElseThrow(() -> new IllegalStateException("If target is FINAL_HEURISTIC, then a heuristic must be provided")); default -> new WinOnlyHeuristic(); }); retValue.setModel(lrModel); diff --git a/src/test/java/players/heuristics/LoseLongGameHeuristicTest.java b/src/test/java/players/heuristics/LoseLongGameHeuristicTest.java index 8f3235e1f..776d27581 100644 --- a/src/test/java/players/heuristics/LoseLongGameHeuristicTest.java +++ b/src/test/java/players/heuristics/LoseLongGameHeuristicTest.java @@ -31,8 +31,8 @@ private double calculateExpected(int gameTick, double player0Score, double playe double weightForGameLength = 0.5; double lengthComponent = sigmoid((double) (gameTick - minGameLength) / (sigmoidScale * minGameLength)) * weightForGameLength; double lossDiff = player1Score - player0Score; - int targetLossDiff = 5; - double scoreComponent = sigmoid((targetLossDiff - lossDiff) / (sigmoidScale * targetLossDiff)); + int maxScore = 15; + double scoreComponent = sigmoid( - lossDiff / (sigmoidScale * maxScore)); scoreComponent = (1.0 - Math.abs(scoreComponent - 0.5) * 2) * (1 - weightForGameLength); double winPenalty = 0.5; double winComponent = isWin ? winPenalty : 0; @@ -43,7 +43,7 @@ private double calculateExpected(int gameTick, double player0Score, double playe @Test public void gameInProgress_MinLengthGame_ExactDiff() { // We have reached the min length (so 0.5 for the length) and are exactly at the target loss diff (so 1.0 for the score). - AbstractGameState gs = setupMockState(minGameLength, 5.0, 10.0, CoreConstants.GameResult.GAME_ONGOING, CoreConstants.GameResult.GAME_ONGOING); + AbstractGameState gs = setupMockState(minGameLength, 10.0, 10.0, CoreConstants.GameResult.GAME_ONGOING, CoreConstants.GameResult.GAME_ONGOING); double expected = 0.75; System.out.println("Expected score for min length and exact diff: " + expected); assertEquals(expected, heuristic.evaluateState(gs, playerId), 0.0001); From 45819ff89384cc6020ab2f84a55b89b45169542f Mon Sep 17 00:00:00 2001 From: hopshackle Date: Sun, 10 May 2026 18:15:38 +0100 Subject: [PATCH 10/41] Metrics for DDA --- src/main/java/players/mcts/MCTSMetrics.java | 12 +++++----- src/main/java/players/mcts/MCTSParams.java | 20 +++++++++++------ .../java/players/mcts/SingleTreeNode.java | 22 +++++++++++++------ 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/main/java/players/mcts/MCTSMetrics.java b/src/main/java/players/mcts/MCTSMetrics.java index 53a10d9cd..d53d83c35 100644 --- a/src/main/java/players/mcts/MCTSMetrics.java +++ b/src/main/java/players/mcts/MCTSMetrics.java @@ -64,16 +64,12 @@ protected boolean _run(MetricsGameListener listener, Event e, Map 0.00001 + actionValueEstimates.get(e.action).valueOf(e.playerID)) { - System.out.println("Warning: second action has higher value than chosen action" + - String.format(" (%.4f to %.4f)", - actionValueEstimates.get(secondAction).valueOf(e.playerID), - actionValueEstimates.get(e.action).valueOf(e.playerID))); - throw new AssertionError("as above"); - } records.put("SecondActionValue", actionValueEstimates.get(secondAction).valueOf(e.playerID)); // this may just be the same as the best action AbstractAction worstAction = sortedActions.getLast(); @@ -126,6 +122,8 @@ public Map> getColumns(int nPlayersPerGame, Set playerN cols.put("maxVisitProportion", Double.class); cols.put("Action", String.class); cols.put("ActionValue", Double.class); + cols.put("BestAction", String.class); + cols.put("BestActionValue", Double.class); cols.put("SecondAction", String.class); cols.put("SecondActionValue", Double.class); cols.put("WorstAction", String.class); diff --git a/src/main/java/players/mcts/MCTSParams.java b/src/main/java/players/mcts/MCTSParams.java index ae1f04c71..5c44b767e 100644 --- a/src/main/java/players/mcts/MCTSParams.java +++ b/src/main/java/players/mcts/MCTSParams.java @@ -44,9 +44,9 @@ public class MCTSParams extends PlayerParameters { public MCTSEnums.Strategies oppModelType = MCTSEnums.Strategies.DEFAULT; // Default is to use the same as rolloutType public String rolloutClass, oppModelClass = ""; public AbstractPlayer rolloutPolicy; - public ITunableParameters rolloutPolicyParams; + public ITunableParameters rolloutPolicyParams; public AbstractPlayer opponentModel; - public ITunableParameters opponentModelParams; + public ITunableParameters opponentModelParams; public double exploreEpsilon = 0.1; public int omaVisits = 30; public boolean normaliseRewards = true; // This will automatically normalise rewards to be in the range [0,1] @@ -77,6 +77,8 @@ public class MCTSParams extends PlayerParameters { public Class instantiationClass; public int numDeterminizations = 1; public MCTSEnums.PerfectInformationPolicy perfectInformationPolicy = AverageValue; + double DDAGameThreshold = 1_000_000.0; // If our projected result is higher than this, then we play badly + double DDAMoveThreshold = 0.0; // When playing badly, we will take actions up to this amount less than the best action public MCTSParams() { addTunableParameter("K", 1.0, Arrays.asList(0.03, 0.1, 0.3, 1.0, 3.0, 10.0, 30.0, 100.0)); @@ -128,6 +130,8 @@ public MCTSParams() { addTunableParameter("instantiationClass", "players.mcts.MCTSPlayer"); addTunableParameter("perfectInformationPolicy", AverageValue, Arrays.asList(MCTSEnums.PerfectInformationPolicy.values())); addTunableParameter("numDeterminizations", 1, Arrays.asList(1, 10, 30, 100, 300, 1000)); + addTunableParameter("DDAGameThreshold", 1_000_000.0); + addTunableParameter("DDAMoveThreshold", 0.0); } @Override @@ -178,7 +182,7 @@ public void _reset() { heuristic = (IStateHeuristic) getParameterValue("heuristic"); MCGSStateKey = (IStateKey) getParameterValue("MCGSStateKey"); MCGSExpandAfterClash = (boolean) getParameterValue("MCGSExpandAfterClash"); - rolloutPolicyParams = (TunableParameters) getParameterValue("rolloutPolicyParams"); + rolloutPolicyParams = (TunableParameters) getParameterValue("rolloutPolicyParams"); opponentModelParams = (TunableParameters) getParameterValue("opponentModelParams"); // we then null those elements of params which are constructed (lazily) from the above firstPlayUrgency = (double) getParameterValue("FPU"); @@ -214,6 +218,8 @@ public void _reset() { if (numDeterminizations > 1) { budget = budget / numDeterminizations; } + DDAGameThreshold = (double) getParameterValue("DDAGameThreshold"); + DDAMoveThreshold = (double) getParameterValue("DDAMoveThreshold"); } @Override @@ -227,12 +233,12 @@ protected MCTSParams _copy() { public AbstractPlayer getOpponentModel() { if (opponentModel == null) { if (oppModelType == PARAMS) - opponentModel = (AbstractPlayer) opponentModelParams.instantiate(); + opponentModel = opponentModelParams.instantiate(); else if (oppModelType == MCTSEnums.Strategies.DEFAULT) opponentModel = getRolloutStrategy(); else opponentModel = constructStrategy(oppModelType, oppModelClass); - opponentModel.getParameters().actionSpace = actionSpace; // TODO makes sense? + opponentModel.getParameters().actionSpace = actionSpace; } return opponentModel; } @@ -247,11 +253,11 @@ public AbstractPlayer getRolloutStrategy() { if (rolloutPolicyParams == null) { throw new AssertionError("Rollout policy parameters have not been set"); } - rolloutPolicy = (AbstractPlayer) rolloutPolicyParams.instantiate(); + rolloutPolicy = rolloutPolicyParams.instantiate(); } else { rolloutPolicy = constructStrategy(rolloutType, rolloutClass); } - rolloutPolicy.getParameters().actionSpace = actionSpace; // TODO makes sense? + rolloutPolicy.getParameters().actionSpace = actionSpace; } return rolloutPolicy; } diff --git a/src/main/java/players/mcts/SingleTreeNode.java b/src/main/java/players/mcts/SingleTreeNode.java index a4bdae840..bfbf5ed03 100644 --- a/src/main/java/players/mcts/SingleTreeNode.java +++ b/src/main/java/players/mcts/SingleTreeNode.java @@ -8,6 +8,7 @@ import java.util.*; import java.util.function.*; +import java.util.stream.Collectors; import java.util.stream.IntStream; import static java.util.stream.Collectors.*; @@ -1166,13 +1167,6 @@ public AbstractAction bestAction() { double bestValue = -Double.MAX_VALUE; AbstractAction bestAction = null; - MCTSEnums.SelectionPolicy policy = params.selectionPolicy; - // check to see if all nodes have the same number of visits - // if they do, then we use average score instead - if (params.selectionPolicy == ROBUST && - Arrays.stream(actionVisits()).boxed().collect(toSet()).size() == 1) { - policy = SIMPLE; - } if (params.treePolicy == EXP3) { // EXP3 uses the tree policy (without exploration) bestAction = treePolicyAction(false); @@ -1193,6 +1187,7 @@ public AbstractAction bestAction() { // we only consider the ones that are valid in the caller (in MCGS case it is possible that we have a loop round to the root) availableActions = actionsToConsider(forwardModel.computeAvailableActions(state, params.actionSpace)); } + List> tempValues = new ArrayList<>(); for (AbstractAction action : availableActions) { if (!actionValues.containsKey(action)) { throw new AssertionError("Hashcode / equals contract issue for " + action); @@ -1201,12 +1196,25 @@ public AbstractAction bestAction() { // Apply small noise to break ties randomly childValue = noise(childValue, params.noiseEpsilon, rnd.nextDouble()); + tempValues.add(Pair.of(action, childValue)); // Save best value if (childValue > bestValue) { bestValue = childValue; bestAction = action; } } + // DDA + if (params.DDAGameThreshold < bestValue) { + // we are in DDA territory. We deliberately take a sub-optimal action if we can + tempValues.sort(Comparator.comparing(p -> -p.b)); + for (Pair pair : tempValues) { + if (pair.b > bestValue - params.DDAMoveThreshold ) { + bestAction = pair.a; + } else { + break; // now in the really poor moves + } + } + } } if (bestAction == null) { From 583c40042b8d9773226d1ad869b7748af1552d00 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Tue, 12 May 2026 11:55:50 +0100 Subject: [PATCH 11/41] Initial JSON serialization for core game state --- src/test/java/games/backgammon/JSONTest.java | 48 ++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/test/java/games/backgammon/JSONTest.java diff --git a/src/test/java/games/backgammon/JSONTest.java b/src/test/java/games/backgammon/JSONTest.java new file mode 100644 index 000000000..cdf8bb9d3 --- /dev/null +++ b/src/test/java/games/backgammon/JSONTest.java @@ -0,0 +1,48 @@ +package games.backgammon; + +import core.AbstractGameState; +import core.CoreConstants; +import org.json.simple.JSONObject; +import org.junit.Test; +import static org.junit.Assert.*; + +public class JSONTest { + + @Test + public void testJSON() throws Exception { + BGParameters params = new BGParameters(); + BGGameState state = new BGGameState(params, 2); + BGForwardModel fm = new BGForwardModel(); + fm.setup(state); + state.rollDice(); + + // Use reflection to call setGameID() + java.lang.reflect.Method setGameIDMethod = AbstractGameState.class.getDeclaredMethod("setGameID", int.class); + setGameIDMethod.setAccessible(true); + setGameIDMethod.invoke(state, 1234); + + state.setFirstPlayer(1); + state.setTurnOwner(0); + state.setGameStatus(CoreConstants.GameResult.GAME_ONGOING); + + JSONObject json = state.toJSON(); + + // Check if abstractGameState key exists + assertTrue(json.containsKey("abstractGameState")); + JSONObject abstractGS = (JSONObject) json.get("abstractGameState"); + assertEquals(1234L, ((Number) abstractGS.get("gameID")).longValue()); + assertEquals(1L, ((Number) abstractGS.get("firstPlayer")).longValue()); + assertEquals(0L, ((Number) abstractGS.get("turnOwner")).longValue()); + + // Load it back + BGGameState newState = new BGGameState(params, 2); + fm.setup(newState); // Initialize it + BGStateJSON.loadFromJSON(newState, json); + + assertEquals(state.getGameID(), newState.getGameID()); + assertEquals(state.getFirstPlayer(), newState.getFirstPlayer()); + assertEquals(state.getTurnOwner(), newState.getTurnOwner()); + assertEquals(state.getGameStatus(), newState.getGameStatus()); + assertEquals(state.getGameTick(), newState.getGameTick()); + } +} From 3b040e2cf220849f33e7485f4439a9b831d914e0 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Tue, 12 May 2026 11:55:53 +0100 Subject: [PATCH 12/41] Initial JSON serialization for core game state --- src/main/java/games/backgammon/BGStateJSON.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/games/backgammon/BGStateJSON.java b/src/main/java/games/backgammon/BGStateJSON.java index cb05ad5a0..5b6e54a3d 100644 --- a/src/main/java/games/backgammon/BGStateJSON.java +++ b/src/main/java/games/backgammon/BGStateJSON.java @@ -2,8 +2,6 @@ import core.components.Dice; import core.components.Token; -import core.components.Component; -import evaluation.optimisation.TunableParameters; import org.json.simple.JSONArray; import org.json.simple.JSONObject; From c39170e641d62173bd4a2c65fd3354204b2af584 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Tue, 12 May 2026 16:34:42 +0100 Subject: [PATCH 13/41] JSON prettyPrint now copes with nested JSONArrays --- src/main/java/core/Game.java | 6 ++- .../java/core/actions/AbstractAction.java | 4 ++ .../java/players/mcts/SingleTreeNode.java | 1 + .../java/players/observers/GameAdviser.java | 1 + src/main/java/utilities/JSONUtils.java | 54 ++++++++++++------- 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/src/main/java/core/Game.java b/src/main/java/core/Game.java index 933e27414..b7fe31ea1 100644 --- a/src/main/java/core/Game.java +++ b/src/main/java/core/Game.java @@ -33,6 +33,7 @@ import javax.swing.Timer; import javax.swing.*; import java.awt.*; +import java.io.File; import java.util.List; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; @@ -486,8 +487,11 @@ public final AbstractAction oneAction() { // System.out.println("Overriding action with " + overrideAction); overrideAction = null; } + // if requested, save a copy of the full undeterminized game state (we do this in the Game loop to avoid passing the real state to agents) if (action.saveGame() && gameState instanceof IToJSON serialisableGameState) { - String filename = String.format("%s_G%d_P%d_Tick%d.json", gameType, gameState.getGameID(), activePlayer, gameState.getGameTick()); + String directory = String.format("SavedStates%s%s%sG%d", File.separator, gameType.name(), File.separator, gameState.getGameID()); + Utils.createDirectory(directory); + String filename = String.format("%sP%d_Tick%d.json", directory + File.separator, activePlayer, gameState.getGameTick()); JSONUtils.writeJSON(serialisableGameState.toJSON(), filename); } // we copy the action before using it...so that the action returned by oneAction() does not have a state link diff --git a/src/main/java/core/actions/AbstractAction.java b/src/main/java/core/actions/AbstractAction.java index 6921c1a0d..301f2dd2a 100644 --- a/src/main/java/core/actions/AbstractAction.java +++ b/src/main/java/core/actions/AbstractAction.java @@ -81,4 +81,8 @@ public String getTooltip(AbstractGameState gs) { public boolean saveGame() { return saveGameBeforeAction; } + + public void setSaveGame(boolean saveGame) { + this.saveGameBeforeAction = saveGame; + } } diff --git a/src/main/java/players/mcts/SingleTreeNode.java b/src/main/java/players/mcts/SingleTreeNode.java index bfbf5ed03..5776c8eb3 100644 --- a/src/main/java/players/mcts/SingleTreeNode.java +++ b/src/main/java/players/mcts/SingleTreeNode.java @@ -1210,6 +1210,7 @@ public AbstractAction bestAction() { for (Pair pair : tempValues) { if (pair.b > bestValue - params.DDAMoveThreshold ) { bestAction = pair.a; + bestAction.setSaveGame(true); } else { break; // now in the really poor moves } diff --git a/src/main/java/players/observers/GameAdviser.java b/src/main/java/players/observers/GameAdviser.java index 90a4e89cf..5c10bcd74 100644 --- a/src/main/java/players/observers/GameAdviser.java +++ b/src/main/java/players/observers/GameAdviser.java @@ -73,6 +73,7 @@ public void onEvent(Event event) { // only advise if we have more than one option // and if the action they plan on taking is one we recognise AbstractAction action = player.getAction(event.state, event.actions); + action.setSaveGame(true); if (filter.provideAdvice(event.state, event.action, actingPlayer, action, this)) { game.setOverrideAction(action); logIntervention(event, action, actingPlayer); diff --git a/src/main/java/utilities/JSONUtils.java b/src/main/java/utilities/JSONUtils.java index 675a878e6..4e346cc6c 100644 --- a/src/main/java/utilities/JSONUtils.java +++ b/src/main/java/utilities/JSONUtils.java @@ -439,28 +439,10 @@ public static String prettyPrint(JSONObject json, int tabDepth) { if (value instanceof JSONObject subJSON) { sb.append(prettyPrint(subJSON, tabDepth + 1)); } else if (value instanceof JSONArray array) { - sb.append("[\n"); - tabDepth++; - for (int index = 0; index < array.size(); index++) { - Object v = array.get(index); - sb.append("\t".repeat(Math.max(0, tabDepth))); - if (v instanceof JSONObject subJSON) { - sb.append(prettyPrint(subJSON, tabDepth + 1)); - } else if (v instanceof String) { - sb.append("\"").append(v).append("\""); - } else if (v instanceof Long || v instanceof Integer || v instanceof Boolean) { - sb.append(v); - } else if (v instanceof Number n) { - sb.append(String.format("%.3g", n.doubleValue())); - } - if (index < array.size() - 1) - sb.append(",").append("\n"); - } - sb.append("\t".repeat(Math.max(0, tabDepth - 1))).append("]"); - tabDepth--; + sb.append(prettyPrintArray(array, tabDepth + 1)); } else if (value instanceof IToJSON toJSON) { JSONObject subJSON = toJSON.toJSON(); - sb.append(prettyPrint(subJSON, tabDepth + 1)); + sb.append("\n").append(prettyPrint(subJSON, tabDepth + 1)); } else if (value instanceof String) { sb.append("\"").append(value).append("\""); } else if (value instanceof Long || value instanceof Integer || @@ -483,6 +465,38 @@ public static String prettyPrint(JSONObject json, int tabDepth) { return sb.toString(); } + // Caller is responsible for adding linefeed/tabs before the start of the array + private static String prettyPrintArray(JSONArray array, int tabDepth) { + StringBuilder sb = new StringBuilder("[ "); + boolean allOnOneLine = true; + for (int index = 0; index < array.size(); index++) { + Object v = array.get(index); + // sb.append("\t".repeat(Math.max(0, tabDepth))); + if (v instanceof JSONObject subJSON) { + sb.append("\n").repeat("\t", Math.max(0, tabDepth)); + sb.append(prettyPrint(subJSON, tabDepth + 1)); + allOnOneLine = false; + } else if (v instanceof JSONArray vArray) { + sb.append("\n").repeat("\t", Math.max(0, tabDepth)); + sb.append(prettyPrintArray(vArray, tabDepth + 1)); + allOnOneLine = false; + } else if (v instanceof String) { + sb.append("\"").append(v).append("\""); + } else if (v instanceof Long || v instanceof Integer || v instanceof Boolean) { + sb.append(v); + } else if (v instanceof Number n) { + sb.append(String.format("%.3g", n.doubleValue())); + } + if (index < array.size() - 1) + sb.append(", "); + } + if (allOnOneLine) + sb.append(" ]"); + else + sb.append("\n").repeat("\t", Math.max(0, tabDepth - 1)).append(" ]"); + return sb.toString(); + } + @SuppressWarnings("unchecked") public static JSONObject createJSONFromMap(Map stuff) { // first of all we partition the keys by the contents of the key before the first '.' From 63878f8e6d61209caa5bed363a7fc73bf14c6089 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Tue, 12 May 2026 17:29:24 +0100 Subject: [PATCH 14/41] BGParameters fix for equals --- src/main/java/core/AbstractGameState.java | 5 +-- .../optimisation/TunableParameters.java | 7 +++-- .../java/games/backgammon/BGGameState.java | 2 +- .../java/games/backgammon/BGParameters.java | 4 +-- .../java/games/backgammon/BGStateJSON.java | 3 ++ src/test/java/games/backgammon/JSONTest.java | 31 +++++++++++++++++++ 6 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/main/java/core/AbstractGameState.java b/src/main/java/core/AbstractGameState.java index 99af762e0..47fc8eeeb 100644 --- a/src/main/java/core/AbstractGameState.java +++ b/src/main/java/core/AbstractGameState.java @@ -752,7 +752,7 @@ public final int[] superHashCodeArray() { } /** - * This converts all fields within AbstractGameState to a JSON object. + * This converts all fields within AbstractGameState to a JSON object (excpet for GameParameters, which need to be done in the caller). * An extension of AbstractGameState that implements IToJSON (and hence is serializable), calls this * method to create one JSON moiety in the serialized game state. */ @@ -775,9 +775,6 @@ public JSONObject abstractGameStateToJSON() { json.put("gameID", gameID); json.put("nPlayers", nPlayers); json.put("nTeams", nTeams); - if (gameParameters instanceof TunableParameters tunableParameters) { - json.put("gameParams", tunableParameters.instanceToJSON(true, new HashMap<>())); - } JSONArray playerResultsJson = new JSONArray(); for (CoreConstants.GameResult res : playerResults) { diff --git a/src/main/java/evaluation/optimisation/TunableParameters.java b/src/main/java/evaluation/optimisation/TunableParameters.java index b5dbff3c5..0eb549f43 100644 --- a/src/main/java/evaluation/optimisation/TunableParameters.java +++ b/src/main/java/evaluation/optimisation/TunableParameters.java @@ -521,11 +521,12 @@ public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof TunableParameters that)) return false; // getRandomSeed() == that.getRandomSeed() && removed, so that equals (and hashcode) covers parameters only + // for equality we just check parameter names and current values return _equals(o) && that.parameterNames.equals(parameterNames) - && that.possibleValues.equals(possibleValues) - && that.currentValues.equals(currentValues) - && that.defaultValues.equals(defaultValues); + // && that.possibleValues.equals(possibleValues) + && that.currentValues.equals(currentValues); + // && that.defaultValues.equals(defaultValues); } public boolean allParametersAndValuesEqual(TunableParameters other) { diff --git a/src/main/java/games/backgammon/BGGameState.java b/src/main/java/games/backgammon/BGGameState.java index 4319c5acf..6906eb7ae 100644 --- a/src/main/java/games/backgammon/BGGameState.java +++ b/src/main/java/games/backgammon/BGGameState.java @@ -57,7 +57,7 @@ public BGGameState(AbstractParameters gameParameters, int nPlayers) { public BGGameState(JSONObject jsonObject) { this(TunableParameters.loadFromJSON(new BGParameters(), (JSONObject) jsonObject.get("gameParams")), - ((Number) jsonObject.get("nPlayers")).intValue()); + ((Number) (((JSONObject)jsonObject.get("abstractGameState")).get("nPlayers"))).intValue()); reset(); BGStateJSON.loadFromJSON(this, jsonObject); } diff --git a/src/main/java/games/backgammon/BGParameters.java b/src/main/java/games/backgammon/BGParameters.java index 09584ce67..6fefc053a 100644 --- a/src/main/java/games/backgammon/BGParameters.java +++ b/src/main/java/games/backgammon/BGParameters.java @@ -81,8 +81,8 @@ protected BGParameters _copy() { @Override protected boolean _equals(Object o) { - return o instanceof BGParameters && super.equals(o) - && ((customDie == null && ((BGParameters) o).customDie == null) + return o instanceof BGParameters && + ((customDie == null && ((BGParameters) o).customDie == null) || (customDie != null && customDie.equals(((BGParameters) o).customDie))); } diff --git a/src/main/java/games/backgammon/BGStateJSON.java b/src/main/java/games/backgammon/BGStateJSON.java index 5b6e54a3d..079a3a901 100644 --- a/src/main/java/games/backgammon/BGStateJSON.java +++ b/src/main/java/games/backgammon/BGStateJSON.java @@ -2,6 +2,7 @@ import core.components.Dice; import core.components.Token; +import evaluation.optimisation.TunableParameters; import org.json.simple.JSONArray; import org.json.simple.JSONObject; @@ -56,9 +57,11 @@ public static void loadFromJSON(BGGameState state, JSONObject json) { public static JSONObject toJSON(BGGameState state) { JSONObject json = new JSONObject(); + json.put("class", "games.backgammon.BGGameState"); // this includes parameters and game phase json.put("abstractGameState", state.abstractGameStateToJSON()); + json.put("gameParams", ((BGParameters) state.getGameParameters()).instanceToJSON(true, new HashMap<>())); json.put("piecesBorneOff", JSONUtils.intArrayToJSON(state.piecesBorneOff)); json.put("blots", JSONUtils.intArrayToJSON(state.blots)); diff --git a/src/test/java/games/backgammon/JSONTest.java b/src/test/java/games/backgammon/JSONTest.java index cdf8bb9d3..3b544c876 100644 --- a/src/test/java/games/backgammon/JSONTest.java +++ b/src/test/java/games/backgammon/JSONTest.java @@ -4,6 +4,11 @@ import core.CoreConstants; import org.json.simple.JSONObject; import org.junit.Test; +import utilities.JSONUtils; + +import java.io.File; +import java.lang.reflect.InvocationTargetException; + import static org.junit.Assert.*; public class JSONTest { @@ -45,4 +50,30 @@ public void testJSON() throws Exception { assertEquals(state.getGameStatus(), newState.getGameStatus()); assertEquals(state.getGameTick(), newState.getGameTick()); } + + @Test + public void testJSONLoadFromFile() throws Exception { + BGParameters params = new BGParameters(); + BGGameState state = new BGGameState(params, 2); + BGForwardModel fm = new BGForwardModel(); + fm.setup(state); + state.rollDice(); + + // Use reflection to call setGameID() + java.lang.reflect.Method setGameIDMethod = AbstractGameState.class.getDeclaredMethod("setGameID", int.class); + setGameIDMethod.setAccessible(true); + setGameIDMethod.invoke(state, 1234); + + state.setFirstPlayer(1); + state.setTurnOwner(0); + state.setGameStatus(CoreConstants.GameResult.GAME_ONGOING); + + JSONObject json = state.toJSON(); + JSONUtils.writeJSON(json, "testFile.json"); + + BGGameState loadedState = JSONUtils.loadClassFromJSON(json); + + assertEquals(state, loadedState); + (new File("testFile.json")).delete(); + } } From 4dfc70dadb1d45c5181f222b2e66930edcb096fe Mon Sep 17 00:00:00 2001 From: hopshackle Date: Tue, 12 May 2026 19:06:48 +0100 Subject: [PATCH 15/41] Rerun games from saved JSON state --- src/main/java/core/AbstractGameState.java | 3 +++ src/main/java/core/Game.java | 5 +++++ src/main/java/evaluation/RunArg.java | 3 +++ .../java/evaluation/optimisation/TunableParameters.java | 2 -- .../java/evaluation/tournaments/RoundRobinTournament.java | 7 +++++++ src/main/java/games/backgammon/BGGameState.java | 7 +++++-- src/main/java/games/backgammon/BGStateJSON.java | 3 ++- src/test/java/games/backgammon/JSONTest.java | 6 ++++-- 8 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/main/java/core/AbstractGameState.java b/src/main/java/core/AbstractGameState.java index 47fc8eeeb..b9678042b 100644 --- a/src/main/java/core/AbstractGameState.java +++ b/src/main/java/core/AbstractGameState.java @@ -775,6 +775,9 @@ public JSONObject abstractGameStateToJSON() { json.put("gameID", gameID); json.put("nPlayers", nPlayers); json.put("nTeams", nTeams); + if (gameParameters instanceof TunableParameters tunableParameters) { + json.put("gameParams", tunableParameters.instanceToJSON(true, new HashMap<>())); + } JSONArray playerResultsJson = new JSONArray(); for (CoreConstants.GameResult res : playerResults) { diff --git a/src/main/java/core/Game.java b/src/main/java/core/Game.java index b7fe31ea1..e3f55d5e4 100644 --- a/src/main/java/core/Game.java +++ b/src/main/java/core/Game.java @@ -257,6 +257,11 @@ public final void reset(List players, long newRandomSeed) { resetStats(); } + public void reset(AbstractGameState gameState) { + this.gameState = gameState; + resetStats(); + } + /** * All timers and game tick set to 0. */ diff --git a/src/main/java/evaluation/RunArg.java b/src/main/java/evaluation/RunArg.java index de7bf639c..83c32ed09 100644 --- a/src/main/java/evaluation/RunArg.java +++ b/src/main/java/evaluation/RunArg.java @@ -77,6 +77,9 @@ public enum RunArg { "\t Specifying all|-name1|-name2... will run all games except for name1, name2...", "all", new Usage[]{Usage.RunGames, Usage.ParameterSearch, Usage.ExpertIteration}), + gameState("A JSON file that defines the initial state of the game (if this is supported by the game).", + "", + new Usage[]{Usage.RunGames}), function("The name of the function to be used for the NTBEA process.", new TestFunction001(), new Usage[]{Usage.ParameterSearch}), diff --git a/src/main/java/evaluation/optimisation/TunableParameters.java b/src/main/java/evaluation/optimisation/TunableParameters.java index 0eb549f43..b5fc700a0 100644 --- a/src/main/java/evaluation/optimisation/TunableParameters.java +++ b/src/main/java/evaluation/optimisation/TunableParameters.java @@ -524,9 +524,7 @@ public boolean equals(Object o) { // for equality we just check parameter names and current values return _equals(o) && that.parameterNames.equals(parameterNames) - // && that.possibleValues.equals(possibleValues) && that.currentValues.equals(currentValues); - // && that.defaultValues.equals(defaultValues); } public boolean allParametersAndValuesEqual(TunableParameters other) { diff --git a/src/main/java/evaluation/tournaments/RoundRobinTournament.java b/src/main/java/evaluation/tournaments/RoundRobinTournament.java index caaa70388..e7b4f7431 100644 --- a/src/main/java/evaluation/tournaments/RoundRobinTournament.java +++ b/src/main/java/evaluation/tournaments/RoundRobinTournament.java @@ -1,5 +1,6 @@ package evaluation.tournaments; +import core.AbstractGameState; import core.AbstractParameters; import core.AbstractPlayer; import evaluation.RunArg; @@ -337,6 +338,12 @@ protected void evaluateMatchUp(List agentIDsInThisGame, int nGames, Lis // so we override the standard random seeds game.reset(matchUpPlayers, seeds.get(i)); + // if we are starting from a specific state, load it and reset the game + if (!((String) config.getOrDefault(RunArg.gameState, "")).isEmpty()) { + AbstractGameState startState = JSONUtils.loadClassFromFile((String) config.get(RunArg.gameState)); + game.reset(startState); + } + // Randomize parameters if (randomGameParams) { game.getGameState().getGameParameters().randomize(); diff --git a/src/main/java/games/backgammon/BGGameState.java b/src/main/java/games/backgammon/BGGameState.java index 6906eb7ae..2c53c7798 100644 --- a/src/main/java/games/backgammon/BGGameState.java +++ b/src/main/java/games/backgammon/BGGameState.java @@ -6,6 +6,7 @@ import core.interfaces.IToJSON; import evaluation.optimisation.TunableParameters; import games.GameType; +import games.XIIScripta.XIIParameters; import org.json.simple.JSONObject; import java.util.*; @@ -56,9 +57,11 @@ public BGGameState(AbstractParameters gameParameters, int nPlayers) { } public BGGameState(JSONObject jsonObject) { - this(TunableParameters.loadFromJSON(new BGParameters(), (JSONObject) jsonObject.get("gameParams")), - ((Number) (((JSONObject)jsonObject.get("abstractGameState")).get("nPlayers"))).intValue()); + this(TunableParameters.loadFromJSON(new XIIParameters(), (JSONObject) ((JSONObject) jsonObject.get("abstractGameState")).get("gameParams")), + ((Number) (((JSONObject) jsonObject.get("abstractGameState")).get("nPlayers"))).intValue()); reset(); + BGForwardModel fm = new BGForwardModel(); + fm.setup(this); BGStateJSON.loadFromJSON(this, jsonObject); } diff --git a/src/main/java/games/backgammon/BGStateJSON.java b/src/main/java/games/backgammon/BGStateJSON.java index 079a3a901..ce739af59 100644 --- a/src/main/java/games/backgammon/BGStateJSON.java +++ b/src/main/java/games/backgammon/BGStateJSON.java @@ -1,5 +1,6 @@ package games.backgammon; +import core.AbstractParameters; import core.components.Dice; import core.components.Token; import evaluation.optimisation.TunableParameters; @@ -61,7 +62,7 @@ public static JSONObject toJSON(BGGameState state) { // this includes parameters and game phase json.put("abstractGameState", state.abstractGameStateToJSON()); - json.put("gameParams", ((BGParameters) state.getGameParameters()).instanceToJSON(true, new HashMap<>())); + // json.put("gameParams", ((TunableParameters) state.getGameParameters()).instanceToJSON(true, new HashMap<>())); json.put("piecesBorneOff", JSONUtils.intArrayToJSON(state.piecesBorneOff)); json.put("blots", JSONUtils.intArrayToJSON(state.blots)); diff --git a/src/test/java/games/backgammon/JSONTest.java b/src/test/java/games/backgammon/JSONTest.java index 3b544c876..7f9a92888 100644 --- a/src/test/java/games/backgammon/JSONTest.java +++ b/src/test/java/games/backgammon/JSONTest.java @@ -2,6 +2,8 @@ import core.AbstractGameState; import core.CoreConstants; +import games.GameType; +import games.XIIScripta.XIIParameters; import org.json.simple.JSONObject; import org.junit.Test; import utilities.JSONUtils; @@ -53,8 +55,8 @@ public void testJSON() throws Exception { @Test public void testJSONLoadFromFile() throws Exception { - BGParameters params = new BGParameters(); - BGGameState state = new BGGameState(params, 2); + BGParameters params = new XIIParameters(); + BGGameState state = (BGGameState) GameType.XIIScripta.createGameState(params, 2); BGForwardModel fm = new BGForwardModel(); fm.setup(state); state.rollDice(); From 29f90efe28e83fae26fb113f3ad7331d51b8b25a Mon Sep 17 00:00:00 2001 From: hopshackle Date: Tue, 12 May 2026 21:33:35 +0100 Subject: [PATCH 16/41] GameID now updated correctly when rerunning games from same state --- src/main/java/core/AbstractGameState.java | 2 -- src/main/java/core/Game.java | 4 ++-- src/test/java/games/backgammon/JSONTest.java | 8 +------- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/main/java/core/AbstractGameState.java b/src/main/java/core/AbstractGameState.java index b9678042b..8e357dd5d 100644 --- a/src/main/java/core/AbstractGameState.java +++ b/src/main/java/core/AbstractGameState.java @@ -772,7 +772,6 @@ public JSONObject abstractGameStateToJSON() { json.put("roundCounter", roundCounter); json.put("gameTick", tick); json.put("firstPlayer", firstPlayer); - json.put("gameID", gameID); json.put("nPlayers", nPlayers); json.put("nTeams", nTeams); if (gameParameters instanceof TunableParameters tunableParameters) { @@ -799,7 +798,6 @@ public void loadAbstractGameStateFromJSON(JSONObject json) { this.roundCounter = ((Number) json.get("roundCounter")).intValue(); this.tick = ((Number) json.get("gameTick")).intValue(); this.firstPlayer = ((Number) json.get("firstPlayer")).intValue(); - this.gameID = json.containsKey("gameID") ? ((Number) json.get("gameID")).intValue() : -1; this.nPlayers = ((Number) json.get("nPlayers")).intValue(); this.nTeams = ((Number) json.get("nTeams")).intValue(); diff --git a/src/main/java/core/Game.java b/src/main/java/core/Game.java index e3f55d5e4..97be746ec 100644 --- a/src/main/java/core/Game.java +++ b/src/main/java/core/Game.java @@ -252,8 +252,6 @@ public final void reset(List players, long newRandomSeed) { // Allow player to initialize player.initializePlayer(observation); } - int gameID = idFountain.incrementAndGet(); - gameState.setGameID(gameID); resetStats(); } @@ -276,6 +274,8 @@ public void resetStats() { nActionsPerTurn = 1; nActionsPerTurnCount = 0; lastPlayer = -1; + int gameID = idFountain.incrementAndGet(); + gameState.setGameID(gameID); } /** diff --git a/src/test/java/games/backgammon/JSONTest.java b/src/test/java/games/backgammon/JSONTest.java index 7f9a92888..1ab7e1313 100644 --- a/src/test/java/games/backgammon/JSONTest.java +++ b/src/test/java/games/backgammon/JSONTest.java @@ -22,12 +22,7 @@ public void testJSON() throws Exception { BGForwardModel fm = new BGForwardModel(); fm.setup(state); state.rollDice(); - - // Use reflection to call setGameID() - java.lang.reflect.Method setGameIDMethod = AbstractGameState.class.getDeclaredMethod("setGameID", int.class); - setGameIDMethod.setAccessible(true); - setGameIDMethod.invoke(state, 1234); - + state.setFirstPlayer(1); state.setTurnOwner(0); state.setGameStatus(CoreConstants.GameResult.GAME_ONGOING); @@ -37,7 +32,6 @@ public void testJSON() throws Exception { // Check if abstractGameState key exists assertTrue(json.containsKey("abstractGameState")); JSONObject abstractGS = (JSONObject) json.get("abstractGameState"); - assertEquals(1234L, ((Number) abstractGS.get("gameID")).longValue()); assertEquals(1L, ((Number) abstractGS.get("firstPlayer")).longValue()); assertEquals(0L, ((Number) abstractGS.get("turnOwner")).longValue()); From 81bbf0067a4d32143355454e748f4a9d5261736b Mon Sep 17 00:00:00 2001 From: hopshackle Date: Wed, 10 Jun 2026 18:12:57 +0100 Subject: [PATCH 17/41] Correct creation of log file in GameAdviser --- src/main/java/players/mcts/MCTSPlayer.java | 2 +- src/main/java/players/observers/GameAdviser.java | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/players/mcts/MCTSPlayer.java b/src/main/java/players/mcts/MCTSPlayer.java index 83931d2fc..65eed88b0 100644 --- a/src/main/java/players/mcts/MCTSPlayer.java +++ b/src/main/java/players/mcts/MCTSPlayer.java @@ -234,7 +234,7 @@ protected SingleTreeNode backtrack(SingleTreeNode startingRoot, AbstractGameStat } } if (!foundPointInHistory) { - String message = String.format("Agent: %s, P%d unable to find matching action in history : %s%n%t%t%b:%s%d%n%s", this, rootPlayer, lastExpected, + String message = String.format("Agent: %s, P%d unable to find matching action in history : %s%n\t\t%b:%s%d%n%s", this, rootPlayer, lastExpected, params.reuseTree, params.opponentTreePolicy, params.maxTreeDepth, history.reversed().stream().limit(20).map(p -> String.format("%d:%s", p.a, p.b)).collect(Collectors.joining("\n\t\t"))); System.out.println(message); diff --git a/src/main/java/players/observers/GameAdviser.java b/src/main/java/players/observers/GameAdviser.java index 5c10bcd74..4e072a387 100644 --- a/src/main/java/players/observers/GameAdviser.java +++ b/src/main/java/players/observers/GameAdviser.java @@ -46,7 +46,6 @@ public GameAdviser(@NotNull AbstractPlayer player, @NotNull IAdviceFilter filter this.player = player; this.filter = filter; this.fileName = fileName == null ? this.getClass().getSimpleName() + ".txt" : fileName; - setupWriter(); } private void setupWriter() { @@ -117,7 +116,11 @@ public Game getGame() { } protected void logIntervention(Event event, AbstractAction action, AbstractPlayer actingPlayer) { - if (writer == null) return; + if (writer == null){ + setupWriter(); + if (writer == null) + return; + } double agentValue = player instanceof MCTSPlayer mcts ? mcts.getValue(event.action) : 0.0; double adviserValue = player instanceof MCTSPlayer mcts ? mcts.getValue(action) : 0.0; From 3afa2ecf7a35a58de4264ffc641a19b032da1707 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Thu, 11 Jun 2026 17:51:50 +0100 Subject: [PATCH 18/41] Metrics for BG Actions (to reduce R code) --- .../backgammon/metrics/BGActionMetrics.java | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/main/java/games/backgammon/metrics/BGActionMetrics.java diff --git a/src/main/java/games/backgammon/metrics/BGActionMetrics.java b/src/main/java/games/backgammon/metrics/BGActionMetrics.java new file mode 100644 index 000000000..7a659dd99 --- /dev/null +++ b/src/main/java/games/backgammon/metrics/BGActionMetrics.java @@ -0,0 +1,84 @@ +package games.backgammon.metrics; + +import core.AbstractPlayer; +import core.Game; +import core.actions.AbstractAction; +import core.interfaces.IGameEvent; +import evaluation.listeners.MetricsGameListener; +import evaluation.metrics.AbstractMetric; +import evaluation.metrics.Event; +import evaluation.metrics.IMetricsCollection; +import games.backgammon.BGGameState; +import games.backgammon.actions.LoadDice; +import games.backgammon.actions.MovePiece; +import games.backgammon.actions.RollDice; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import static evaluation.metrics.Event.GameEvent.ACTION_CHOSEN; + +public class BGActionMetrics implements IMetricsCollection { + + /** + * Records the actions taken during the game + * This is 'Reduced' compared to the Actions metric in that it does not have separate columns for each player + * Instead of having separate columns for 'Player-2', 'Player-3' etc, it has a single column 'Player' with + * the player ID taking the action. (And similarly for PlayerName) + * This simplifies data analysis in some circumstances (in others the Actions metric is more useful) + */ + public static class BGActions extends AbstractMetric { + public BGActions() { + super(); + } + + public BGActions(Event.GameEvent... args) { + super(args); + } + + @Override + public boolean _run(MetricsGameListener listener, Event e, Map records) { + BGGameState state = (BGGameState) e.state; + AbstractAction action = e.action; + String type = switch(action) { + case RollDice ignored -> "RollDice"; + case LoadDice ignored -> "LoadDice"; + case MovePiece mp when (mp.from == -1) -> "BearOn"; + case MovePiece mp when (mp.to == -1) -> "BearOff"; + default -> "Move"; + }; + if (type.equals("Move")) { + MovePiece move = (MovePiece) action; + // get number of pieces on from/to points + int fromCount = state.getPiecesOnPoint(e.playerID, move.from); + int toCountOwn = state.getPiecesOnPoint(e.playerID, move.to); + int toCountOpp = state.getPiecesOnPoint(1 - e.playerID, move.to); + if (toCountOpp == 1) { + type = "Blot"; + } + records.put("From", fromCount); + records.put("To", toCountOwn + toCountOpp); + } + records.put("Type", type); + return true; + } + + @Override + public Set getDefaultEventTypes() { + return Collections.singleton(ACTION_CHOSEN); + } + + @Override + public Map> getColumns(int nPlayersPerGame, Set playerNames) { + Map> columns = new HashMap<>(); + columns.put("Type", String.class); + columns.put("From", String.class); + columns.put("To", Integer.class); + return columns; + } + } + + +} From 49f3040ca0ac018bf4d823a596ac56f0948d43b1 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Fri, 12 Jun 2026 09:44:40 +0100 Subject: [PATCH 19/41] SavedState directory no longer global, and part of destDir in RunGames --- src/main/java/core/Game.java | 10 +++++++++- .../evaluation/tournaments/RoundRobinTournament.java | 1 + src/main/java/games/backgammon/BGStateJSON.java | 2 -- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/core/Game.java b/src/main/java/core/Game.java index 97be746ec..0825e5807 100644 --- a/src/main/java/core/Game.java +++ b/src/main/java/core/Game.java @@ -75,6 +75,7 @@ public class Game { int snapsPerSecond = 10; private int turnPause; protected AbstractAction overrideAction; + protected String savedStateDirectory = "SavedStates"; /** * Game constructor. Receives a list of players, a forward model and a game state. Sets unique and final @@ -494,7 +495,7 @@ public final AbstractAction oneAction() { } // if requested, save a copy of the full undeterminized game state (we do this in the Game loop to avoid passing the real state to agents) if (action.saveGame() && gameState instanceof IToJSON serialisableGameState) { - String directory = String.format("SavedStates%s%s%sG%d", File.separator, gameType.name(), File.separator, gameState.getGameID()); + String directory = String.format("%s%s%s%sG%d", savedStateDirectory, File.separator, gameType.name(), File.separator, gameState.getGameID()); Utils.createDirectory(directory); String filename = String.format("%sP%d_Tick%d.json", directory + File.separator, activePlayer, gameState.getGameTick()); JSONUtils.writeJSON(serialisableGameState.toJSON(), filename); @@ -669,6 +670,13 @@ public void clearListeners() { getGameState().clearListeners(); } + public void setSavedStatesDirectory(String dir) { + savedStateDirectory = dir; + } + public String getSavedStatesDirectory() { + return savedStateDirectory; + } + /** * Retrieves the list of players in the game. * diff --git a/src/main/java/evaluation/tournaments/RoundRobinTournament.java b/src/main/java/evaluation/tournaments/RoundRobinTournament.java index e7b4f7431..4601abbff 100644 --- a/src/main/java/evaluation/tournaments/RoundRobinTournament.java +++ b/src/main/java/evaluation/tournaments/RoundRobinTournament.java @@ -158,6 +158,7 @@ public void run() { gameTracker.init(game, nPlayers, agentNames); game.addListener(gameTracker); } + game.setSavedStatesDirectory(config.getOrDefault(RunArg.destDir, "").toString() + File.separator + "SavedStates"); LinkedList matchUp = new LinkedList<>(); // add outer loop if we have tournamentSeeds enabled; if not this will just run once diff --git a/src/main/java/games/backgammon/BGStateJSON.java b/src/main/java/games/backgammon/BGStateJSON.java index ce739af59..b76440f1f 100644 --- a/src/main/java/games/backgammon/BGStateJSON.java +++ b/src/main/java/games/backgammon/BGStateJSON.java @@ -1,9 +1,7 @@ package games.backgammon; -import core.AbstractParameters; import core.components.Dice; import core.components.Token; -import evaluation.optimisation.TunableParameters; import org.json.simple.JSONArray; import org.json.simple.JSONObject; From cff0365b48e4a966e76fced52a407d4f83654b91 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Fri, 12 Jun 2026 09:46:20 +0100 Subject: [PATCH 20/41] Removed old video filed not actually being used --- src/main/java/core/Game.java | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/main/java/core/Game.java b/src/main/java/core/Game.java index 0825e5807..0080c695f 100644 --- a/src/main/java/core/Game.java +++ b/src/main/java/core/Game.java @@ -66,13 +66,6 @@ public class Game { private int nActionsPerTurn, nActionsPerTurnSum, nActionsPerTurnCount; private boolean pause, stop; private boolean debug = false; - // Video recording - private Rectangle areaBounds; - private boolean recordingVideo = false; - String fileName = "output.mp4"; - String formatName = "mp4"; - String codecName = null; - int snapsPerSecond = 10; private int turnPause; protected AbstractAction overrideAction; protected String savedStateDirectory = "SavedStates"; @@ -162,11 +155,6 @@ public static Game runOne(GameType gameToPlay, String parameterConfigFile, List< frame.validate(); frame.pack(); - // Video recording setup - if (game.recordingVideo) { - game.areaBounds = new Rectangle(0, 0, frame.getWidth(), frame.getHeight()); - } - Timer guiUpdater = new Timer((int) game.getCoreParameters().frameSleepMS, event -> game.updateGUI(gui, frame)); guiUpdater.start(); From 87d4d39d1c03869b2a7260b0e34b00ed001a19e2 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Fri, 12 Jun 2026 10:49:22 +0100 Subject: [PATCH 21/41] Fixed incorrect action string in adviser log --- src/main/java/players/observers/GameAdviser.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/players/observers/GameAdviser.java b/src/main/java/players/observers/GameAdviser.java index 4e072a387..8fe581ea6 100644 --- a/src/main/java/players/observers/GameAdviser.java +++ b/src/main/java/players/observers/GameAdviser.java @@ -127,7 +127,7 @@ protected void logIntervention(Event event, AbstractAction action, AbstractPlaye try { writer.write(String.format("%s\t%s\t%s\t%.3g\t%s\t%.3g\t%d\t%d\t%d\t%d\n", event.playerID, actingPlayer.toString(), - event.action.toString(), agentValue, action.toString(), adviserValue, + action.getString(event.state), agentValue, event.action.getString(event.state), adviserValue, event.state.getGameID(), event.state.getTurnCounter(), event.state.getRoundCounter(), event.state.getGameTick())); } catch (IOException e) { throw new RuntimeException(e); From 3b1f71ccf3a8bd09e3f2788953669248dbec1c10 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Fri, 19 Jun 2026 16:15:30 +0100 Subject: [PATCH 22/41] Backgammon save states, and robust dice --- src/main/java/core/components/Dice.java | 9 ++++++++- src/main/java/games/backgammon/BGGameState.java | 16 +++++++++++++++- src/test/java/players/mcts/TreeReuseTests.java | 2 -- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/main/java/core/components/Dice.java b/src/main/java/core/components/Dice.java index 3cb3a5f01..79e0bccfa 100644 --- a/src/main/java/core/components/Dice.java +++ b/src/main/java/core/components/Dice.java @@ -69,8 +69,15 @@ public Dice(JSONObject data) { pdf[i] = ((Number) arr.get(i)).doubleValue(); } double total = Arrays.stream(pdf).sum(); - if (Math.abs(total - 1.0) > 0.000001) + // The standard JSON format stores numbers to 3 sig fig. + // We may therefore need to adjust to make sure the PDF sums to 1 + if (Math.abs(total - 1.0) > 0.01) throw new IllegalArgumentException("Invalid PDF in Dice: " + Arrays.toString(pdf)); + else if (Math.abs(total - 1.0) > 1e-6) { + for (int i = 0; i < pdf.length; i++) { + pdf[i] /= total; + } + } } if (data.containsKey("value")) { this.value = ((Number) data.get("value")).intValue(); diff --git a/src/main/java/games/backgammon/BGGameState.java b/src/main/java/games/backgammon/BGGameState.java index 30a6085a7..aa4b83b60 100644 --- a/src/main/java/games/backgammon/BGGameState.java +++ b/src/main/java/games/backgammon/BGGameState.java @@ -1,5 +1,6 @@ package games.backgammon; +import com.github.javaparser.javadoc.description.JavadocSnippet; import core.AbstractGameState; import core.AbstractParameters; import core.components.*; @@ -59,8 +60,21 @@ public BGGameState(AbstractParameters gameParameters, int nPlayers) { } public BGGameState(JSONObject jsonObject) { - this(TunableParameters.loadFromJSON(new XIIParameters(), (JSONObject) ((JSONObject) jsonObject.get("abstractGameState")).get("gameParams")), + this(new BGParameters(), ((Number) (((JSONObject) jsonObject.get("abstractGameState")).get("nPlayers"))).intValue()); + // Now we want to update the parameters based on the contents of the JSON object + // We have to do this after the super constructor, because the super constructor will have initialised the gameParameters to a default value + // and we can't put this code earlier before Java 25 + JSONObject abstractGameStateJSON = (JSONObject) jsonObject.get("abstractGameState"); + JSONObject gameParamsJSON = (JSONObject) abstractGameStateJSON.get("gameParams"); + String paramsClassName = (String) gameParamsJSON.get("class"); + if (paramsClassName.equals("games.backgammon.BGParameters")) { + this.gameParameters = TunableParameters.loadFromJSON(new BGParameters(), gameParamsJSON); + } else if (paramsClassName.equals("games.XIIScripta.XIIParameters")) { + this.gameParameters = TunableParameters.loadFromJSON(new XIIParameters(), gameParamsJSON); + } else { + throw new IllegalArgumentException("Unknown parameters class: " + paramsClassName); + } reset(); BGForwardModel fm = new BGForwardModel(); fm.setup(this); diff --git a/src/test/java/players/mcts/TreeReuseTests.java b/src/test/java/players/mcts/TreeReuseTests.java index c72de4192..10922b56b 100644 --- a/src/test/java/players/mcts/TreeReuseTests.java +++ b/src/test/java/players/mcts/TreeReuseTests.java @@ -2,7 +2,6 @@ import core.*; import core.actions.AbstractAction; -import core.interfaces.IGamePhase; import games.GameType; import games.cantstop.CantStopForwardModel; import games.dominion.DominionConstants; @@ -13,7 +12,6 @@ import games.tictactoe.TicTacToeForwardModel; import org.junit.Before; import org.junit.Test; -import org.netlib.lapack.Dgetrf; import players.PlayerConstants; import players.simple.RandomPlayer; From b7dd4fbf835b68f6255e4e892acef4384718f713 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Fri, 19 Jun 2026 17:45:50 +0100 Subject: [PATCH 23/41] Load Game State added to Frontend plus refactor --- src/main/java/gui/Frontend.java | 290 +++++++++++++----- src/main/java/gui/models/AITableModel.java | 27 +- src/test/java/gui/FrontendLoadStateTest.java | 101 ++++++ .../java/gui/models/AITableModelTest.java | 65 ++++ 4 files changed, 398 insertions(+), 85 deletions(-) create mode 100644 src/test/java/gui/FrontendLoadStateTest.java create mode 100644 src/test/java/gui/models/AITableModelTest.java diff --git a/src/main/java/gui/Frontend.java b/src/main/java/gui/Frontend.java index 15e52059e..5e3ce4745 100644 --- a/src/main/java/gui/Frontend.java +++ b/src/main/java/gui/Frontend.java @@ -7,16 +7,21 @@ import evaluation.metrics.Event; import games.GameType; import gui.models.AITableModel; +import org.json.simple.JSONObject; import players.PlayerParameters; import players.PlayerType; import players.human.ActionController; +import utilities.JSONUtils; import javax.swing.Timer; import javax.swing.*; import javax.swing.filechooser.FileNameExtensionFilter; import java.awt.*; +import java.io.File; import java.util.List; import java.util.*; +import java.util.function.Consumer; +import java.util.function.Function; public class Frontend extends GUI { private final int nMaxPlayers = 20; @@ -28,6 +33,8 @@ public class Frontend extends GUI { private Game gameRunning; private boolean showAll, paused, started, showAIWindow; private ActionController humanInputQueue; + // The most recently loaded game state (from a saved JSON file), or null if none has been loaded + private AbstractGameState loadedGameState; public Frontend() { @@ -49,6 +56,9 @@ public Frontend() { String[] gameNames = new String[GameType.values().length]; TunableParameters[] gameParameters = new TunableParameters[GameType.values().length]; gameParameterEditWindow = new JFrame[GameType.values().length]; + // Keep a handle on the parameter combo-boxes per game, so a loaded game state can update them + @SuppressWarnings("unchecked") + HashMap>[] gameParamValueOptions = new HashMap[GameType.values().length]; for (int i = 0; i < gameNames.length; i++) { gameNames[i] = GameType.values()[i].name(); AbstractParameters params = GameType.values()[i].createParameters(0); @@ -57,29 +67,13 @@ public Frontend() { gameParameterEditWindow[i] = new JFrame(); gameParameterEditWindow[i].getContentPane().setLayout(new BoxLayout(gameParameterEditWindow[i].getContentPane(), BoxLayout.Y_AXIS)); - List paramNames = gameParameters[i].getParameterNames(); - HashMap> paramValueOptions = createParameterWindow(paramNames, gameParameters[i], gameParameterEditWindow[i]); - int idx = i; - JButton submit = new JButton("Submit"); - submit.addActionListener(e -> { - for (String param : paramNames) { - gameParameters[idx].setParameterValue(param, paramValueOptions.get(param).getSelectedItem()); - } - gameParameterEditWindow[idx].dispose(); - }); - JButton reset = new JButton("Reset"); - reset.addActionListener(e -> { - gameParameters[idx].reset(); - for (String param : paramNames) { - paramValueOptions.get(param).setSelectedItem(gameParameters[idx].getDefaultParameterValue(param)); - } - }); - JPanel buttons = new JPanel(); - buttons.add(submit); - buttons.add(reset); - - gameParameterEditWindow[i].getContentPane().add(buttons); + gameParamValueOptions[i] = buildParameterEditWindow(gameParameters[i], gameParameterEditWindow[i], + options -> { + for (String param : options.keySet()) + gameParameters[idx].setParameterValue(param, options.get(param).getSelectedItem()); + }, + param -> gameParameters[idx].getDefaultParameterValue(param)); } } GameType firstGameType = GameType.valueOf(gameNames[0]); @@ -94,6 +88,8 @@ public Frontend() { GameType gameType = GameType.valueOf((String) gameOptions.getSelectedItem()); nPlayersText.setText(" # players (min " + gameType.getMinPlayers() + ", max " + gameType.getMaxPlayers() + "):"); + // Changing the game type invalidates any loaded saved state (it belongs to a different game) + loadedGameState = null; pack(); }); gameSelect.add(BorderLayout.EAST, gameParameterEdit); @@ -138,11 +134,7 @@ public Frontend() { paramButtonPanel.add(paramButton); paramButtonPanel.add(fileButton); - JFileChooser fileChooser = new JFileChooser(); - fileChooser.setAcceptAllFileFilterUsed(false); - fileChooser.addChoosableFileFilter( - new FileNameExtensionFilter("JSON files only", "json") - ); + JFileChooser fileChooser = jsonFileChooser(); fileButton.addActionListener(e -> { int retVal = fileChooser.showOpenDialog(this); @@ -164,24 +156,27 @@ public Frontend() { playerParameterEditWindow[i] = new JFrame(); playerParameterEditWindow[i].getContentPane().setLayout(new BoxLayout(playerParameterEditWindow[i].getContentPane(), BoxLayout.Y_AXIS)); + // The Edit button opens the parameter window for whichever agent is currently selected. + // Attached once here (rather than inside the combo listener) so it does not accumulate a + // fresh listener - and so open a window per past selection - every time the agent changes. + paramButton.addActionListener(f -> { + int agentIndex = playerOptionsChoice[playerIdx].getSelectedIndex(); + initialisePlayerParameterWindow(playerIdx, agentIndex); + playerParameterEditWindow[playerIdx].setTitle("Edit parameters " + playerOptionsChoice[playerIdx].getSelectedItem()); + playerParameterEditWindow[playerIdx].pack(); + playerParameterEditWindow[playerIdx].setVisible(true); + playerParameterEditWindow[playerIdx].setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); + }); + playerOptionsChoice[i].addActionListener(e -> { int agentIndex = playerOptionsChoice[playerIdx].getSelectedIndex(); - // set Edit button visible if we have parameters to edit; else make it invisible - paramButton.setVisible(agentParameters[agentIndex] != null); - fileButton.setVisible(agentParameters[agentIndex] != null); + // show the Edit/Load buttons only if this agent type has parameters to edit + boolean hasParams = agentParameters[agentIndex] != null; + paramButton.setVisible(hasParams); + fileButton.setVisible(hasParams); // set up the player parameters with the current saved default for that agent type - - paramButton.removeAll(); - try { + if (hasParams) playerParameters[playerIdx] = (PlayerParameters) agentParameters[agentIndex].copy(); - paramButton.addActionListener(f -> { - initialisePlayerParameterWindow(playerIdx, agentIndex); - playerParameterEditWindow[playerIdx].setTitle("Edit parameters " + playerOptionsChoice[playerIdx].getSelectedItem()); - playerParameterEditWindow[playerIdx].pack(); - playerParameterEditWindow[playerIdx].setVisible(true); - playerParameterEditWindow[playerIdx].setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); - }); - } catch (Exception ignored) {} pack(); }); playerOptions[i].add(BorderLayout.CENTER, playerOptionsChoice[i]); @@ -190,9 +185,11 @@ public Frontend() { } JButton updateNPlayers = new JButton("Update"); updateNPlayers.addActionListener(e -> { + // Changing the number of players invalidates any loaded saved state (its player count is fixed) + loadedGameState = null; if (!nPlayerField.getText().isEmpty()) { int nP = Integer.parseInt(nPlayerField.getText()); - if (nP > 0 && nP < nMaxPlayers) { + if (nP > 0 && nP <= nMaxPlayers) { for (int i = 0; i < nP; i++) { playerOptions[i].setVisible(true); } @@ -202,33 +199,73 @@ public Frontend() { pack(); } else { JOptionPane.showMessageDialog(null, - "Error: Please enter number bigger than 0 and less than " + nMaxPlayers, "Error Message", + "Error: Please enter a number between 1 and " + nMaxPlayers, "Error Message", JOptionPane.ERROR_MESSAGE); } } }); nPlayers.add(BorderLayout.EAST, updateNPlayers); + // Load a saved game state from a JSON file. If one is selected, the game state is instantiated + // from the file and the game type, number of players and game parameters are read from it and + // applied to the controls above. This requires the game state to implement IToJSON and to provide + // a constructor taking a JSONObject (currently only Backgammon / XII Scripta do so). + JButton loadStateButton = new JButton("Load JSON"); + JPanel loadStatePanel = labelledRow(" Load game state:", null, loadStateButton); + JFileChooser stateFileChooser = jsonFileChooser(); + loadStateButton.addActionListener(e -> { + if (stateFileChooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) + return; + File stateFile = stateFileChooser.getSelectedFile(); + try { + LoadedGameState loaded = loadGameStateFromFile(stateFile); + int gameIdx = Arrays.asList(gameNames).indexOf(loaded.gameType().name()); + + // Apply to the GUI: game type first (its listener resets the player count limits)... + gameOptions.setSelectedIndex(gameIdx); + + // ...then the game parameters read from the loaded state... + if (gameParameters[gameIdx] != null && loaded.state().getGameParameters() instanceof TunableParameters loadedParams) { + List paramNames = gameParameters[gameIdx].getParameterNames(); + for (String param : paramNames) { + Object value = loadedParams.getParameterValue(param); + gameParameters[gameIdx].setParameterValue(param, value); + if (gameParamValueOptions[gameIdx] != null && gameParamValueOptions[gameIdx].containsKey(param)) + gameParamValueOptions[gameIdx].get(param).setSelectedItem(value); + } + } + + // ...and the number of players (doClick refreshes the per-player rows). + nPlayerField.setText("" + loaded.nPlayers()); + updateNPlayers.doClick(); + + // Remember the loaded state last: setting the game type and player count above both + // clear loadedGameState (as a guard against a stale state), so we record it afterwards. + // The game will now start from this state until the game type or player count changes. + loadedGameState = loaded.state(); + + System.out.println("Loaded " + loaded.gameType().name() + " game state (" + + loaded.nPlayers() + " players) from " + stateFile.getName()); + } catch (Throwable loadingException) { + JOptionPane.showMessageDialog(this, + "Could not load game state: " + loadingException.getMessage(), + "Error", JOptionPane.ERROR_MESSAGE); + } + }); + // Select visuals on/off - JPanel visualSelect = new JPanel(new BorderLayout(5, 5)); - visualSelect.add(BorderLayout.WEST, new JLabel(" Visuals ON/OFF:")); JComboBox visualOptions = new JComboBox<>(new Boolean[]{false, true}); // index here is visuals on/off visualOptions.setSelectedItem(true); - visualSelect.add(BorderLayout.EAST, visualOptions); + JPanel visualSelect = labelledRow(" Visuals ON/OFF:", null, visualOptions); - JPanel turnPause = new JPanel(new BorderLayout(5, 5)); - turnPause.add(BorderLayout.WEST, new JLabel(" Pause between turns (ms):")); JTextField turnPauseValue = new JTextField(" 200"); - turnPause.add(BorderLayout.EAST, turnPauseValue); + JPanel turnPause = labelledRow(" Pause between turns (ms):", null, turnPauseValue); // Select random seed - JPanel seedSelect = new JPanel(new BorderLayout(5, 5)); - seedSelect.add(BorderLayout.WEST, new JLabel(" Seed:")); - JTextField seedOption = new JTextField("" + System.currentTimeMillis()); // integer of this is seed + JTextField seedOption = new JTextField("" + System.currentTimeMillis()); // parsed as the random seed JButton seedRefresh = new JButton("Refresh"); seedRefresh.addActionListener(e -> seedOption.setText("" + System.currentTimeMillis())); - seedSelect.add(BorderLayout.CENTER, seedOption); - seedSelect.add(BorderLayout.EAST, seedRefresh); + JPanel seedSelect = labelledRow(" Seed:", seedOption, seedRefresh); // Game run core parameters select CoreParameters coreParameters = new CoreParameters(); @@ -303,6 +340,11 @@ public Frontend() { if (gameRunning != null) { // Reset game instance, passing the players for this game gameRunning.reset(players); + // If a saved state was loaded, start the game from it rather than a fresh setup. + // A copy is used so the loaded state survives intact for a subsequent replay. + if (loadedGameState != null) { + gameRunning.reset(loadedGameState.copy()); + } try { gameRunning.setTurnPause(Integer.parseInt(turnPauseValue.getText().trim())); } catch (NumberFormatException notAnInteger) { @@ -340,7 +382,6 @@ public Frontend() { if (guiUpdater != null) guiUpdater.stop(); gameThread.interrupt(); - guiUpdater.stop(); } }; @@ -404,6 +445,7 @@ public Frontend() { JPanel gameOptionFullPanel = new JPanel(); gameOptionFullPanel.setLayout(new BoxLayout(gameOptionFullPanel, BoxLayout.Y_AXIS)); gameOptionFullPanel.add(leftJustify(gameSelect)); + gameOptionFullPanel.add(leftJustify(loadStatePanel)); gameOptionFullPanel.add(leftJustify(playerSelect)); gameOptionFullPanel.add(leftJustify(visualSelect)); gameOptionFullPanel.add(leftJustify(turnPause)); @@ -449,34 +491,68 @@ public static void main(String[] args) { new Frontend(); } + /** + * The result of loading a saved game-state JSON file: the resolved game type, the number of + * players and the instantiated game state. + */ + public record LoadedGameState(GameType gameType, int nPlayers, AbstractGameState state) {} + + /** + * Loads a saved game state from a JSON file. The file is expected to have a top-level "class" + * naming the {@link AbstractGameState} subclass and an "abstractGameState" object holding the + * number of players and the game parameters (see {@link core.interfaces.IToJSON}). + *

+ * The {@link GameType} is resolved by matching the state class and (where present) the + * parameters class. Matching on the parameters class is required to disambiguate games that + * share a state class - e.g. Backgammon and XII Scripta both use {@code BGGameState}, whose + * {@code _getGameType()} always reports Backgammon. + * + * @param file the JSON file to load + * @return the resolved game type, player count and instantiated state + * @throws IllegalArgumentException if no game matches the state/parameters classes in the file + */ + public static LoadedGameState loadGameStateFromFile(File file) { + JSONObject json = JSONUtils.loadJSONFile(file.getPath()); + String stateClassName = (String) json.get("class"); + JSONObject abstractGS = (JSONObject) json.get("abstractGameState"); + int nPlayers = ((Number) abstractGS.get("nPlayers")).intValue(); + JSONObject gameParamsJSON = (JSONObject) abstractGS.get("gameParams"); + String paramsClassName = gameParamsJSON == null ? null : (String) gameParamsJSON.get("class"); + + GameType gameType = null; + for (GameType gt : GameType.values()) { + if (gt.getGameStateClass() == null || !gt.getGameStateClass().getName().equals(stateClassName)) + continue; + if (paramsClassName == null || + (gt.getParameterClass() != null && gt.getParameterClass().getName().equals(paramsClassName))) { + gameType = gt; + break; + } + } + if (gameType == null) + throw new IllegalArgumentException("No game matches state class " + stateClassName + + (paramsClassName == null ? "" : " with parameters " + paramsClassName)); + + // Instantiate the game state from the file (this also validates that the file is loadable) + AbstractGameState state = JSONUtils.loadClassFromJSON(json); + return new LoadedGameState(gameType, nPlayers, state); + } + private void initialisePlayerParameterWindow(int playerIndex, int agentIndex) { if (playerParameters[playerIndex] != null) { - List paramNames = playerParameters[playerIndex].getParameterNames(); - HashMap> paramValueOptions = createParameterWindow(paramNames, playerParameters[playerIndex], playerParameterEditWindow[playerIndex]); - - JButton submit = new JButton("Submit"); - submit.addActionListener(e -> { - for (String param : paramNames) { - playerParameters[playerIndex].setParameterValue(param, paramValueOptions.get(param).getSelectedItem()); - agentParameters[agentIndex].setParameterValue(param, paramValueOptions.get(param).getSelectedItem()); - // we also update the central copy, so this change is inherited by future new players - } - playerParameterEditWindow[playerIndex].dispose(); - }); - JButton reset = new JButton("Reset"); - reset.addActionListener(e -> { - playerParameters[playerIndex].reset(); - PlayerParameters defaultParams = PlayerType.values()[agentIndex].createParameterSet(); - if (defaultParams != null) - for (String param : paramNames) { - paramValueOptions.get(param).setSelectedItem(defaultParams.getDefaultParameterValue(param)); - } - }); - JPanel buttons = new JPanel(); - buttons.add(submit); - buttons.add(reset); - - playerParameterEditWindow[playerIndex].getContentPane().add(buttons); + PlayerParameters defaultParams = PlayerType.values()[agentIndex].createParameterSet(); + buildParameterEditWindow(playerParameters[playerIndex], playerParameterEditWindow[playerIndex], + options -> { + for (String param : options.keySet()) { + Object value = options.get(param).getSelectedItem(); + playerParameters[playerIndex].setParameterValue(param, value); + // also update the central copy, so this change is inherited by future new players + agentParameters[agentIndex].setParameterValue(param, value); + } + }, + param -> defaultParams != null + ? defaultParams.getDefaultParameterValue(param) + : playerParameters[playerIndex].getDefaultParameterValue(param)); } } @@ -488,6 +564,58 @@ private Component leftJustify(JPanel panel) { return b; } + /** + * A standard settings row: a label on the left, an optional stretchable control in the centre, + * and an optional control (typically a button) on the right. + */ + private static JPanel labelledRow(String label, JComponent centre, JComponent east) { + JPanel panel = new JPanel(new BorderLayout(5, 5)); + panel.add(BorderLayout.WEST, new JLabel(label)); + if (centre != null) panel.add(BorderLayout.CENTER, centre); + if (east != null) panel.add(BorderLayout.EAST, east); + return panel; + } + + /** A file chooser restricted to JSON files. */ + private static JFileChooser jsonFileChooser() { + JFileChooser chooser = new JFileChooser(); + chooser.setAcceptAllFileFilterUsed(false); + chooser.addChoosableFileFilter(new FileNameExtensionFilter("JSON files only", "json")); + return chooser; + } + + /** + * Populates a parameter-edit window with one combo-box per parameter plus a Submit/Reset button pair. + * Submit hands the combo-boxes to {@code onSubmit} (which writes the chosen values back to the relevant + * parameter object(s)) and closes the window; Reset restores each parameter to the value supplied by + * {@code resetDefault}. The map of parameter name to combo-box is returned so callers can drive the + * controls later (e.g. when a saved game state is loaded). + */ + private HashMap> buildParameterEditWindow( + TunableParameters params, JFrame window, + Consumer>> onSubmit, + Function resetDefault) { + List paramNames = params.getParameterNames(); + HashMap> paramValueOptions = createParameterWindow(paramNames, params, window); + + JButton submit = new JButton("Submit"); + submit.addActionListener(e -> { + onSubmit.accept(paramValueOptions); + window.dispose(); + }); + JButton reset = new JButton("Reset"); + reset.addActionListener(e -> { + params.reset(); + for (String param : paramNames) + paramValueOptions.get(param).setSelectedItem(resetDefault.apply(param)); + }); + JPanel buttons = new JPanel(); + buttons.add(submit); + buttons.add(reset); + window.getContentPane().add(buttons); + return paramValueOptions; + } + private void listenForDecisions() { // add a listener to detect every time an action has been taken gameRunning.addListener(new MetricsGameListener() { diff --git a/src/main/java/gui/models/AITableModel.java b/src/main/java/gui/models/AITableModel.java index 9245018b2..549a994b2 100644 --- a/src/main/java/gui/models/AITableModel.java +++ b/src/main/java/gui/models/AITableModel.java @@ -7,6 +7,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.StringJoiner; public class AITableModel extends AbstractTableModel { @@ -21,7 +22,10 @@ public AITableModel(Map> data) Map localData = data.get(action); List actionValues = new ArrayList<>(localData.size()); for (String dataType : localData.keySet()) { - Object datum = localData.get(dataType); + // Scalars (Integer/Double/String) display in their own right; anything else - notably + // the per-player double[] values MCTS reports - is rendered as text so the table can + // still be shown rather than throwing. + Object datum = displayValue(localData.get(dataType)); int index = columnNames.indexOf(dataType); if (index > -1) { actionValues.add(index, datum); @@ -32,10 +36,8 @@ public AITableModel(Map> data) klass = Integer.class; } else if (datum instanceof Double) { klass = Double.class; - } else if (datum instanceof String) { - klass = String.class; } else { - throw new AssertionError("Unknown class for " + datum); + klass = String.class; } actionValues.add(columnNames.size(), datum); columnNames.add(dataType); @@ -46,6 +48,23 @@ public AITableModel(Map> data) } } + /** + * Keeps scalar values (Integer/Double/String) as-is so they sort and render naturally, and turns + * everything else into a readable string. Per-player {@code double[]} stats are formatted to two + * decimal places, e.g. {@code [0.52, 0.48]}. + */ + private static Object displayValue(Object datum) { + if (datum instanceof Integer || datum instanceof Double || datum instanceof String) + return datum; + if (datum instanceof double[] doubles) { + StringJoiner joiner = new StringJoiner(", ", "[", "]"); + for (double d : doubles) + joiner.add(String.format("%.2f", d)); + return joiner.toString(); + } + return String.valueOf(datum); + } + @Override public int getRowCount() { return dataValues.size(); diff --git a/src/test/java/gui/FrontendLoadStateTest.java b/src/test/java/gui/FrontendLoadStateTest.java new file mode 100644 index 000000000..739a67508 --- /dev/null +++ b/src/test/java/gui/FrontendLoadStateTest.java @@ -0,0 +1,101 @@ +package gui; + +import core.AbstractGameState; +import core.CoreConstants; +import evaluation.optimisation.TunableParameters; +import games.GameType; +import games.XIIScripta.XIIParameters; +import games.backgammon.BGForwardModel; +import games.backgammon.BGGameState; +import games.backgammon.BGParameters; +import gui.Frontend.LoadedGameState; +import org.json.simple.JSONObject; +import org.junit.Test; +import utilities.JSONUtils; + +import java.io.File; + +import static org.junit.Assert.*; + +/** + * Tests for {@link Frontend#loadGameStateFromFile(File)}, the logic behind the GUI's + * "Load game state" file chooser. Each test round-trips a state through {@code toJSON}, + * writes it to a temporary file, and reloads it - self-contained, so no external fixtures + * are required. + */ +public class FrontendLoadStateTest { + + private File writeStateToTempFile(AbstractGameState state) throws Exception { + JSONObject json = ((core.interfaces.IToJSON) state).toJSON(); + File file = File.createTempFile("tagState", ".json"); + file.deleteOnExit(); + JSONUtils.writeJSON(json, file.getPath()); + return file; + } + + @Test + public void loadsBackgammonState() throws Exception { + BGGameState state = new BGGameState(new BGParameters(), 2); + BGForwardModel fm = new BGForwardModel(); + fm.setup(state); + state.rollDice(); + state.setGameStatus(CoreConstants.GameResult.GAME_ONGOING); + + File file = writeStateToTempFile(state); + LoadedGameState loaded = Frontend.loadGameStateFromFile(file); + + assertEquals(GameType.Backgammon, loaded.gameType()); + assertEquals(2, loaded.nPlayers()); + assertTrue(loaded.state() instanceof BGGameState); + assertEquals(state, loaded.state()); + } + + @Test + public void disambiguatesXIIScriptaByParametersClass() throws Exception { + // XII Scripta shares BGGameState with Backgammon, so the game type can only be told apart + // by the parameters class (BGGameState._getGameType() always reports Backgammon). + BGParameters params = new XIIParameters(); + BGGameState state = (BGGameState) GameType.XIIScripta.createGameState(params, 2); + BGForwardModel fm = new BGForwardModel(); + fm.setup(state); + state.rollDice(); + + File file = writeStateToTempFile(state); + LoadedGameState loaded = Frontend.loadGameStateFromFile(file); + + assertEquals(GameType.XIIScripta, loaded.gameType()); + assertEquals(2, loaded.nPlayers()); + assertEquals(state, loaded.state()); + } + + @Test + public void parametersAreReadFromFile() throws Exception { + BGGameState state = new BGGameState(new BGParameters(), 2); + new BGForwardModel().setup(state); + + File file = writeStateToTempFile(state); + LoadedGameState loaded = Frontend.loadGameStateFromFile(file); + + // The loaded parameters should match the originals value-for-value, which is what the GUI + // copies into its parameter controls. + TunableParameters original = (TunableParameters) state.getGameParameters(); + TunableParameters loadedParams = (TunableParameters) loaded.state().getGameParameters(); + for (Object paramName : original.getParameterNames()) { + String p = (String) paramName; + assertEquals("parameter " + p, original.getParameterValue(p), loadedParams.getParameterValue(p)); + } + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsUnknownGameClass() throws Exception { + BGGameState state = new BGGameState(new BGParameters(), 2); + new BGForwardModel().setup(state); + JSONObject json = state.toJSON(); + json.put("class", "games.nonexistent.NoSuchGameState"); + File file = File.createTempFile("tagBadState", ".json"); + file.deleteOnExit(); + JSONUtils.writeJSON(json, file.getPath()); + + Frontend.loadGameStateFromFile(file); + } +} diff --git a/src/test/java/gui/models/AITableModelTest.java b/src/test/java/gui/models/AITableModelTest.java new file mode 100644 index 000000000..b49b38d06 --- /dev/null +++ b/src/test/java/gui/models/AITableModelTest.java @@ -0,0 +1,65 @@ +package gui.models; + +import core.actions.AbstractAction; +import core.actions.DoNothing; +import org.junit.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.*; + +/** + * Tests for {@link AITableModel}, the model behind the GUI's "AI Window". The window is built from + * each player's decision statistics, whose values may be scalars or per-player {@code double[]} + * arrays (MCTS reports {@code nodeValue} / {@code heuristicValue} this way). The model must render + * all of these rather than throwing. + */ +public class AITableModelTest { + + private Map> statsWith(Map values) { + Map> stats = new LinkedHashMap<>(); + stats.put(new DoNothing(), values); + return stats; + } + + @Test + public void rendersArrayValuedStatsAsText() { + Map values = new LinkedHashMap<>(); + values.put("visits", 42); + values.put("visitProportion", 0.5); + values.put("nodeValue", new double[]{0.52, 0.48}); // the case that used to throw + + AITableModel model = new AITableModel(statsWith(values)); + + // one row, plus an Action column and one column per stat + assertEquals(1, model.getRowCount()); + assertEquals(4, model.getColumnCount()); + + int nodeValueCol = columnIndex(model, "nodeValue"); + assertEquals(String.class, model.getColumnClass(nodeValueCol)); + assertEquals("[0.52, 0.48]", model.getValueAt(0, nodeValueCol)); + } + + @Test + public void keepsScalarStatsTyped() { + Map values = new LinkedHashMap<>(); + values.put("visits", 42); + values.put("visitProportion", 0.5); + values.put("label", "best"); + + AITableModel model = new AITableModel(statsWith(values)); + + assertEquals(Integer.class, model.getColumnClass(columnIndex(model, "visits"))); + assertEquals(Double.class, model.getColumnClass(columnIndex(model, "visitProportion"))); + assertEquals(String.class, model.getColumnClass(columnIndex(model, "label"))); + assertEquals(42, model.getValueAt(0, columnIndex(model, "visits"))); + } + + private int columnIndex(AITableModel model, String name) { + for (int c = 0; c < model.getColumnCount(); c++) + if (name.equals(model.getColumnName(c))) + return c; + throw new AssertionError("No column named " + name); + } +} From 6a38b562b789d0c936df0b2533ec5ee84c8947ce Mon Sep 17 00:00:00 2001 From: hopshackle Date: Sat, 20 Jun 2026 18:12:58 +0100 Subject: [PATCH 24/41] Support for actionsInProgress serialization and deserialization --- src/main/java/core/AbstractGameState.java | 139 +++++++++++++++++----- 1 file changed, 106 insertions(+), 33 deletions(-) diff --git a/src/main/java/core/AbstractGameState.java b/src/main/java/core/AbstractGameState.java index 8e357dd5d..811efbde9 100644 --- a/src/main/java/core/AbstractGameState.java +++ b/src/main/java/core/AbstractGameState.java @@ -5,15 +5,13 @@ import core.components.Area; import core.components.Component; import core.components.PartialObservableDeck; -import core.interfaces.IComponentContainer; -import core.interfaces.IExtendedSequence; -import core.interfaces.IGameEvent; -import core.interfaces.IGamePhase; +import core.interfaces.*; import evaluation.listeners.IGameListener; import evaluation.metrics.Event; import evaluation.optimisation.TunableParameters; import games.GameType; import utilities.ElapsedCpuChessTimer; +import utilities.JSONUtils; import utilities.Pair; import org.json.simple.JSONArray; import org.json.simple.JSONObject; @@ -42,7 +40,7 @@ public abstract class AbstractGameState { /** * The following fields are serializable into JSON with abstractGameStateToJSON. * They are then populated on reconstruction from JSON with loadAbstractGameStateFromJSON. - * + *

* GameParameters are only fully supported in serialization/deserialization if they extend TunableParameters * (which is the only case in which they can be easily changed from their default values) */ @@ -65,23 +63,23 @@ public abstract class AbstractGameState { /** * The fields below are NOT serialized / re-loaded - * + *

* allComponents is built from _getAllComponents() when the state is initialised * history/historyText are omitted to save space, and they are intended to provide an audit trail for debugging, and no functionality should ever be based on them - * + *

* gamePhase needs to be serialized/reloaded, but that has to be done currently in the child implementation - * as we have no means of knowing the class to instantiate - * + * as we have no means of knowing the class to instantiate + *

* actionsInProgress could be serialized, but that requires the game implementation to support this for all ExtendedSequence implementations - * for the moment TAGG will throw an error if serialization is attempted with any active actions on the stack - * - * coreGameParameters deals with competition disqualification and non-standard action spaces. If could be serialized straightforwardly if needed - * but for the moment any reload will just pick up the defaults - * - * gameListeners and playerTimers are also not considered part of the 'real' game state, much like CoreParameters. If these are - * important then they will need to be set up post reload - * - * random number generators are excluded deliberately. Any future need to rerun a game multiple times with the same seed is not currently supported (but would not be difficult) + * for the moment TAGG will throw an error if serialization is attempted with any active actions on the stack + *

+ * coreGameParameters deals with competition disqualification and non-standard action spaces. If could be serialized straightforwardly if needed + * but for the moment any reload will just pick up the defaults + *

+ * gameListeners and playerTimers are also not considered part of the 'real' game state, much like CoreParameters. If these are + * important then they will need to be set up post reload + *

+ * random number generators are excluded deliberately. Any future need to rerun a game multiple times with the same seed is not currently supported (but would not be difficult) */ // Current game phase protected IGamePhase gamePhase; @@ -155,24 +153,40 @@ void reset(long seed) { public CoreParameters getCoreGameParameters() { return coreGameParameters; } + public final CoreConstants.GameResult getGameStatus() { return gameStatus; } + public final AbstractParameters getGameParameters() { return this.gameParameters; } - public int getNPlayers() { return nPlayers; } - public int getNTeams() { return nTeams; } + + public int getNPlayers() { + return nPlayers; + } + + public int getNTeams() { + return nTeams; + } + /** * Returns the team number the specified player is on. * This defaults to one team per player and should be overridden * in child classes if relevant to the game */ - public int getTeam(int player) { return player;} + public int getTeam(int player) { + return player; + } + public int getCurrentPlayer() { return isActionInProgress() ? actionsInProgress.peek().getCurrentPlayer(this) : turnOwner; } - public final CoreConstants.GameResult[] getPlayerResults() {return playerResults;} + + public final CoreConstants.GameResult[] getPlayerResults() { + return playerResults; + } + public final Set getWinners() { Set winners = new HashSet<>(); for (int i = 0; i < playerResults.length; i++) { @@ -180,6 +194,7 @@ public final Set getWinners() { } return winners; } + public final Set getTied() { Set tied = new HashSet<>(); for (int i = 0; i < playerResults.length; i++) { @@ -187,12 +202,15 @@ public final Set getTied() { } return tied; } + public final IGamePhase getGamePhase() { return gamePhase; } + public final ElapsedCpuChessTimer[] getPlayerTimer() { return playerTimer; } + public final GameType getGameType() { return gameType; } @@ -201,18 +219,29 @@ public final GameType getGameType() { protected void setHistoryAt(int index, Pair action) { history.set(index, action); } + /** * @return All actions that have been executed on this state since reset()/initialisation */ - public List> getHistory() { return new ArrayList<>(history);} + public List> getHistory() { + return new ArrayList<>(history); + } + public List getHistoryAsText() { return new ArrayList<>(historyText); } + public int getGameID() { return gameID; } - public int getRoundCounter() {return roundCounter;} - public int getTurnCounter() {return turnCounter;} + + public int getRoundCounter() { + return roundCounter; + } + + public int getTurnCounter() { + return turnCounter; + } /** * In general getCurrentPlayer() should be used to find the current player. @@ -221,19 +250,27 @@ public int getGameID() { * * @return the player whose turn it currently is (which may be different to the next player to act) */ - public int getTurnOwner() {return turnOwner;} - public int getFirstPlayer() {return firstPlayer;} + public int getTurnOwner() { + return turnOwner; + } + + public int getFirstPlayer() { + return firstPlayer; + } // Setters void setCoreGameParameters(CoreParameters coreGameParameters) { this.coreGameParameters = coreGameParameters; } + public final void setGameStatus(CoreConstants.GameResult status) { this.gameStatus = status; } + public final void setPlayerResult(CoreConstants.GameResult result, int playerIdx) { this.playerResults[playerIdx] = result; } + public final void setGamePhase(IGamePhase gamePhase) { this.gamePhase = gamePhase; } @@ -241,9 +278,15 @@ public final void setGamePhase(IGamePhase gamePhase) { void setGameID(int id) { gameID = id; } // package level deliberately - void advanceGameTick() {tick++;} - public void setTurnOwner(int newTurnOwner) {turnOwner = newTurnOwner;} + void advanceGameTick() { + tick++; + } + + public void setTurnOwner(int newTurnOwner) { + turnOwner = newTurnOwner; + } + public void setFirstPlayer(int newFirstPlayer) { firstPlayer = newFirstPlayer; turnOwner = newFirstPlayer; @@ -254,11 +297,13 @@ public void setFirstPlayer(int newFirstPlayer) { * Use this as a default; there is no need to create your own. * The one exception to this guideline is in the copy() method, where redeterminisation should *not* use this generator. * It should use redeterminisationRnd instead. + * * @return the Random to use for game decisions/events */ public Random getRnd() { return rnd; } + public void addListener(IGameListener listener) { if (!listeners.contains(listener)) listeners.add(listener); @@ -276,7 +321,11 @@ public final boolean isNotTerminal() { public final boolean isNotTerminalForPlayer(int player) { return playerResults[player] == GAME_ONGOING && gameStatus == GAME_ONGOING; } - public final int getGameTick() {return tick;} + + public final int getGameTick() { + return tick; + } + public final Component getComponentById(int id) { Component c = allComponents.getComponent(id); if (c == null) { @@ -538,7 +587,9 @@ public double getTiebreak(int playerId, int tier) { * * @return the number of levels of tiebreaks in the game */ - public int getTiebreakLevels() {return 5;} + public int getTiebreakLevels() { + return 5; + } /** * Returns the ordinal position of a player using getGameScore(). @@ -752,10 +803,11 @@ public final int[] superHashCodeArray() { } /** - * This converts all fields within AbstractGameState to a JSON object (excpet for GameParameters, which need to be done in the caller). + * This converts all fields within AbstractGameState to a JSON object. * An extension of AbstractGameState that implements IToJSON (and hence is serializable), calls this * method to create one JSON moiety in the serialized game state. */ + @SuppressWarnings("unchecked") public JSONObject abstractGameStateToJSON() { if (isActionInProgress()) throw new IllegalStateException("Cannot yet serialize game state with ongoing actions"); @@ -774,7 +826,7 @@ public JSONObject abstractGameStateToJSON() { json.put("firstPlayer", firstPlayer); json.put("nPlayers", nPlayers); json.put("nTeams", nTeams); - if (gameParameters instanceof TunableParameters tunableParameters) { + if (gameParameters instanceof TunableParameters tunableParameters) { json.put("gameParams", tunableParameters.instanceToJSON(true, new HashMap<>())); } @@ -783,6 +835,21 @@ public JSONObject abstractGameStateToJSON() { playerResultsJson.add(res.name()); } json.put("playerResults", playerResultsJson); + + // we also process the actionStack - but everything on it needs to implement IToJSON + // note that iterating over a Stack is in FIFO order, not the LIFO of pop() + // this works here because we push in the first item in the array + if (isActionInProgress()) { + JSONArray actionsInProgressJson = new JSONArray(); + for (IExtendedSequence action : actionsInProgress) { + if (action instanceof IToJSON toJSONAction) { + actionsInProgressJson.add(toJSONAction.toJSON()); + } else { + throw new IllegalStateException("Cannot serialize game state with ongoing actions that do not implement IToJSON : " + action.getClass().getName()); + } + json.put("actionsInProgress", actionsInProgressJson); + } + } return json; } @@ -818,6 +885,12 @@ public void loadAbstractGameStateFromJSON(JSONObject json) { // Ignore, subclass is expected to handle this } } + JSONArray actionsInProgressJson = (JSONArray) json.get("actionsInProgress"); + if (actionsInProgressJson != null) { + for (Object action : actionsInProgressJson) { + this.actionsInProgress.push(JSONUtils.loadClassFromJSON((JSONObject) action)); + } + } } public boolean isGameOver() { From 8445808c4fa045858b2438704d2a9eb541d5ff78 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Wed, 24 Jun 2026 11:21:20 +0100 Subject: [PATCH 25/41] Connect4 corrected to use a 7x6 board as the default --- .../games/connect4/Connect4ForwardModel.java | 3 +- .../connect4/Connect4GameParameters.java | 30 +++++++------------ 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/main/java/games/connect4/Connect4ForwardModel.java b/src/main/java/games/connect4/Connect4ForwardModel.java index 2f1a37138..b11006afa 100644 --- a/src/main/java/games/connect4/Connect4ForwardModel.java +++ b/src/main/java/games/connect4/Connect4ForwardModel.java @@ -19,9 +19,8 @@ public class Connect4ForwardModel extends SequentialActionForwardModel { @Override protected void _setup(AbstractGameState firstState) { Connect4GameParameters c4gp = (Connect4GameParameters) firstState.getGameParameters(); - int gridSize = c4gp.gridSize; Connect4GameState state = (Connect4GameState) firstState; - state.gridBoard = new GridBoard(gridSize, gridSize, new BoardNode(Connect4Constants.emptyCell)); + state.gridBoard = new GridBoard(c4gp.width, c4gp.height, new BoardNode(Connect4Constants.emptyCell)); state.winnerCells = new LinkedList<>(); } diff --git a/src/main/java/games/connect4/Connect4GameParameters.java b/src/main/java/games/connect4/Connect4GameParameters.java index 9b6f29e70..a9f0b2fab 100644 --- a/src/main/java/games/connect4/Connect4GameParameters.java +++ b/src/main/java/games/connect4/Connect4GameParameters.java @@ -1,53 +1,45 @@ package games.connect4; import core.AbstractParameters; +import core.Game; import evaluation.optimisation.TunableParameters; import games.GameType; import java.util.*; -public class Connect4GameParameters extends TunableParameters { +public class Connect4GameParameters extends TunableParameters { - public int gridSize = 8; + public int width = 7; + public int height = 6; public int winCount = 4; public Connect4GameParameters() { - addTunableParameter("gridSize", 8, Arrays.asList(6, 8, 10, 12)); + addTunableParameter("width", 7, Arrays.asList(5, 6, 7, 8, 9, 10)); + addTunableParameter("height", 6, Arrays.asList(4, 5, 6, 7, 8)); addTunableParameter("winCount", 4, Arrays.asList(3, 4, 5, 6)); _reset(); } @Override public void _reset() { - gridSize = (int) getParameterValue("gridSize"); + width = (int) getParameterValue("width"); + height = (int) getParameterValue("height"); winCount = (int) getParameterValue("winCount"); } @Override protected AbstractParameters _copy() { - Connect4GameParameters gp = new Connect4GameParameters(); - gp.gridSize = gridSize; - gp.winCount = winCount; - return gp; + return new Connect4GameParameters(); } @Override public boolean _equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - if (!super.equals(o)) return false; - Connect4GameParameters that = (Connect4GameParameters) o; - return gridSize == that.gridSize && winCount == that.winCount; + return super.equals(o); } @Override - public int hashCode() { - return Objects.hash(super.hashCode(), gridSize, winCount); - } - - @Override - public Object instantiate() { + public Game instantiate() { return GameType.Connect4.createGameInstance(2, this); } From e00cb3685cff5ad710df4c2eecccfa1309afc8c0 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Wed, 24 Jun 2026 15:14:16 +0100 Subject: [PATCH 26/41] Removed error on actionsInProgress --- pom.xml | 6 +++--- src/main/java/core/AbstractGameState.java | 7 ++----- src/main/java/games/backgammon/BGStateJSON.java | 1 - 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/pom.xml b/pom.xml index 96e2dd531..7c26fb208 100644 --- a/pom.xml +++ b/pom.xml @@ -70,12 +70,12 @@ org.apache.spark spark-core_2.13 - 3.5.5 + 4.1.2 org.apache.spark spark-sql_2.13 - 3.5.5 + 4.1.2 com.github.javaparser @@ -85,7 +85,7 @@ org.apache.spark spark-mllib_2.13 - 3.5.1 + 4.1.2 commons-io diff --git a/src/main/java/core/AbstractGameState.java b/src/main/java/core/AbstractGameState.java index 811efbde9..85b348067 100644 --- a/src/main/java/core/AbstractGameState.java +++ b/src/main/java/core/AbstractGameState.java @@ -36,7 +36,6 @@ */ public abstract class AbstractGameState { - /** * The following fields are serializable into JSON with abstractGameStateToJSON. * They are then populated on reconstruction from JSON with loadAbstractGameStateFromJSON. @@ -70,8 +69,8 @@ public abstract class AbstractGameState { * gamePhase needs to be serialized/reloaded, but that has to be done currently in the child implementation * as we have no means of knowing the class to instantiate *

- * actionsInProgress could be serialized, but that requires the game implementation to support this for all ExtendedSequence implementations - * for the moment TAGG will throw an error if serialization is attempted with any active actions on the stack + * actionsInProgress is serialized, but that requires the game implementation to support this for all ExtendedSequence implementations + * TAG will throw an error if serialization is attempted with any active actions on the stack that do not implement IToJSON *

* coreGameParameters deals with competition disqualification and non-standard action spaces. If could be serialized straightforwardly if needed * but for the moment any reload will just pick up the defaults @@ -809,8 +808,6 @@ public final int[] superHashCodeArray() { */ @SuppressWarnings("unchecked") public JSONObject abstractGameStateToJSON() { - if (isActionInProgress()) - throw new IllegalStateException("Cannot yet serialize game state with ongoing actions"); JSONObject json = new JSONObject(); json.put("gameType", gameType.name()); // for info json.put("gameStatus", gameStatus.name()); diff --git a/src/main/java/games/backgammon/BGStateJSON.java b/src/main/java/games/backgammon/BGStateJSON.java index b76440f1f..11f949459 100644 --- a/src/main/java/games/backgammon/BGStateJSON.java +++ b/src/main/java/games/backgammon/BGStateJSON.java @@ -60,7 +60,6 @@ public static JSONObject toJSON(BGGameState state) { // this includes parameters and game phase json.put("abstractGameState", state.abstractGameStateToJSON()); - // json.put("gameParams", ((TunableParameters) state.getGameParameters()).instanceToJSON(true, new HashMap<>())); json.put("piecesBorneOff", JSONUtils.intArrayToJSON(state.piecesBorneOff)); json.put("blots", JSONUtils.intArrayToJSON(state.blots)); From 28e48f042b900e0e87b86b2b26a6eee3a9152da9 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Wed, 24 Jun 2026 15:55:06 +0100 Subject: [PATCH 27/41] Connect4 serializable --- src/main/java/core/components/BoardNode.java | 21 +++- src/main/java/core/components/GridBoard.java | 6 +- .../connect4/Connect4GameParameters.java | 5 +- .../games/connect4/Connect4GameState.java | 30 ++++- .../games/connect4/Connect4StateJSON.java | 115 ++++++++++++++++++ .../java/games/descent2e/DescentHelper.java | 2 +- src/main/java/players/mcts/OMATreeNode.java | 6 - src/main/java/utilities/Pathfinder.java | 4 +- 8 files changed, 171 insertions(+), 18 deletions(-) create mode 100644 src/main/java/games/connect4/Connect4StateJSON.java diff --git a/src/main/java/core/components/BoardNode.java b/src/main/java/core/components/BoardNode.java index dd2fa9981..07ca0901e 100644 --- a/src/main/java/core/components/BoardNode.java +++ b/src/main/java/core/components/BoardNode.java @@ -5,14 +5,14 @@ import org.json.simple.JSONArray; import org.json.simple.JSONObject; - import java.util.HashMap; +import java.util.Map; public class BoardNode extends Component { final static double defaultCost = 1.0; - private HashMap neighbours; // Neighbours of this board node, - private HashMap neighbourSideMapping; // Neighbours mapping to a side of this board node, component ID -> side idx + private Map neighbours; // Neighbours of this board node, + private Map neighbourSideMapping; // Neighbours mapping to a side of this board node, component ID -> side idx private int maxNeighbours; // Maximum number of neighbours for this board node public BoardNode(int maxNeighbours, String name) { @@ -29,6 +29,17 @@ public BoardNode(String name) { this.neighbourSideMapping = new HashMap<>(); } + /** + * Recreates a BoardNode with a specific componentID. Used when reloading a serialized game + * state, so that the restored node has the same identity (componentID) as the original. + */ + public BoardNode(String name, int componentID) { + super(CoreConstants.ComponentType.BOARD_NODE, name, componentID); + this.maxNeighbours = -1; + this.neighbours = new HashMap<>(); + this.neighbourSideMapping = new HashMap<>(); + } + // Copy constructor of properties (incl. other component ID) public BoardNode(BoardNode other) { super(CoreConstants.ComponentType.BOARD_NODE, other.componentName, other.componentID); @@ -131,7 +142,7 @@ public void copyComponentTo(Component copyTo) { /** * @return the neighbours of this node. */ - public HashMap getNeighbours() { + public Map getNeighbours() { return neighbours; } @@ -155,7 +166,7 @@ public double getNeighbourCost(BoardNode neighbour) /** * @return the neighbours mapping to sides of this node. */ - public HashMap getNeighbourSideMapping() { + public Map getNeighbourSideMapping() { return neighbourSideMapping; } diff --git a/src/main/java/core/components/GridBoard.java b/src/main/java/core/components/GridBoard.java index f4272c0bc..e54a03974 100644 --- a/src/main/java/core/components/GridBoard.java +++ b/src/main/java/core/components/GridBoard.java @@ -55,7 +55,11 @@ public GridBoard(BoardNode[][] grid) { this.grid = grid; } - protected GridBoard(BoardNode[][] grid, int ID) { + /** + * Rebuilds a GridBoard from an existing grid with a specific componentID. Used when reloading a + * serialized game state, so that the restored board keeps the same identity as the original. + */ + public GridBoard(BoardNode[][] grid, int ID) { super(CoreConstants.ComponentType.BOARD, ID); this.width = grid[0].length; this.height = grid.length; diff --git a/src/main/java/games/connect4/Connect4GameParameters.java b/src/main/java/games/connect4/Connect4GameParameters.java index a9f0b2fab..73e34b9f9 100644 --- a/src/main/java/games/connect4/Connect4GameParameters.java +++ b/src/main/java/games/connect4/Connect4GameParameters.java @@ -35,7 +35,10 @@ protected AbstractParameters _copy() { @Override public boolean _equals(Object o) { - return super.equals(o); + // width/height/winCount are tunable parameters, so they are already compared by + // TunableParameters.equals (via currentValues). Calling super.equals here would recurse + // infinitely, as TunableParameters.equals delegates back to _equals. + return o instanceof Connect4GameParameters; } @Override diff --git a/src/main/java/games/connect4/Connect4GameState.java b/src/main/java/games/connect4/Connect4GameState.java index 51b2ee27d..9a4070fb6 100644 --- a/src/main/java/games/connect4/Connect4GameState.java +++ b/src/main/java/games/connect4/Connect4GameState.java @@ -8,7 +8,10 @@ import core.components.Token; import core.interfaces.IGridGameState; import core.interfaces.IPrintable; +import core.interfaces.IToJSON; +import evaluation.optimisation.TunableParameters; import games.GameType; +import org.json.simple.JSONObject; import utilities.Pair; import java.util.ArrayList; @@ -16,7 +19,7 @@ import java.util.List; import java.util.Objects; -public class Connect4GameState extends AbstractGameState implements IPrintable, IGridGameState { +public class Connect4GameState extends AbstractGameState implements IPrintable, IGridGameState, IToJSON { GridBoard gridBoard; LinkedList> winnerCells; @@ -27,6 +30,22 @@ public Connect4GameState(AbstractParameters gameParameters, int nPlayers) { gridBoard = null; } + public Connect4GameState(JSONObject jsonObject) { + // We start from default parameters and the recorded number of players, then overwrite the + // parameters and board from the JSON. This mirrors the Backgammon (BGGameState) pattern; + // the parameters must be set before setup(), as the board dimensions are read from them. + this(new Connect4GameParameters(), + ((Number) (((JSONObject) jsonObject.get("abstractGameState")).get("nPlayers"))).intValue()); + JSONObject abstractGameStateJSON = (JSONObject) jsonObject.get("abstractGameState"); + JSONObject gameParamsJSON = (JSONObject) abstractGameStateJSON.get("gameParams"); + if (gameParamsJSON != null) { + this.gameParameters = TunableParameters.loadFromJSON(new Connect4GameParameters(), gameParamsJSON); + } + reset(); + new Connect4ForwardModel().setup(this); + Connect4StateJSON.loadFromJSON(this, jsonObject); + } + /** * This returns the player id of the token at the given position. Or -1 if this is empty. */ @@ -75,9 +94,11 @@ public double getGameScore(int playerId) { @Override protected boolean _equals(Object o) { + // _equals must only compare Connect4-specific state: AbstractGameState.equals (which is final) + // already compares the shared fields and then delegates here, so calling super.equals would + // recurse infinitely. if (this == o) return true; if (!(o instanceof Connect4GameState that)) return false; - if (!super.equals(o)) return false; return Objects.equals(gridBoard, that.gridBoard); } @@ -122,4 +143,9 @@ void registerWinningCells(LinkedList> winnerCells) { public LinkedList> getWinningCells() { return winnerCells; } + + @Override + public JSONObject toJSON() { + return Connect4StateJSON.toJSON(this); + } } diff --git a/src/main/java/games/connect4/Connect4StateJSON.java b/src/main/java/games/connect4/Connect4StateJSON.java new file mode 100644 index 000000000..3fd5d9476 --- /dev/null +++ b/src/main/java/games/connect4/Connect4StateJSON.java @@ -0,0 +1,115 @@ +package games.connect4; + +import core.components.BoardNode; +import core.components.GridBoard; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import utilities.Pair; + +import java.util.HashMap; +import java.util.LinkedList; +import java.util.Map; + +/** + * Serialization of {@link Connect4GameState} to and from JSON, following the same pattern as + * {@code games.backgammon.BGStateJSON}: the shared AbstractGameState fields (parameters, game phase, + * turn counters, ...) are handled by {@link core.AbstractGameState#abstractGameStateToJSON()} / + * {@link core.AbstractGameState#loadAbstractGameStateFromJSON}, and this class adds the Connect4 + * specific board state. + *

+ * Component identity is preserved on reload: occupied cells reference the shared static + * {@link Connect4Constants#playerMapping} markers (so their componentIDs and any subsequent + * getComponentById lookups line up), and empty cells / the board itself are rebuilt with their + * original componentIDs so that the restored state is {@code equals()} to the original. + */ +public class Connect4StateJSON { + + public static JSONObject toJSON(Connect4GameState state) { + JSONObject json = new JSONObject(); + json.put("class", "games.connect4.Connect4GameState"); + + // this includes parameters and game phase + json.put("abstractGameState", state.abstractGameStateToJSON()); + + GridBoard board = state.gridBoard; + json.put("gridBoardID", board.getComponentID()); + json.put("width", board.getWidth()); + json.put("height", board.getHeight()); + + // The board is stored row by row (y outer, x inner). Each cell records its name (which + // marker, or the empty cell) and its componentID (needed to restore empty-cell identity). + JSONArray rows = new JSONArray(); + for (int y = 0; y < board.getHeight(); y++) { + JSONArray row = new JSONArray(); + for (int x = 0; x < board.getWidth(); x++) { + BoardNode cell = board.getElement(x, y); + JSONObject cellJSON = new JSONObject(); + cellJSON.put("name", cell.getComponentName()); + cellJSON.put("id", cell.getComponentID()); + row.add(cellJSON); + } + rows.add(row); + } + json.put("cells", rows); + + JSONArray winnerCells = new JSONArray(); + for (Pair cell : state.winnerCells) { + JSONArray pair = new JSONArray(); + pair.add(cell.a); + pair.add(cell.b); + winnerCells.add(pair); + } + json.put("winnerCells", winnerCells); + + return json; + } + + public static void loadFromJSON(Connect4GameState state, JSONObject json) { + JSONObject abstractGS = (JSONObject) json.get("abstractGameState"); + state.loadAbstractGameStateFromJSON(abstractGS); + + int gridBoardID = ((Number) json.get("gridBoardID")).intValue(); + JSONArray rows = (JSONArray) json.get("cells"); + int height = rows.size(); + int width = ((JSONArray) rows.get(0)).size(); + + BoardNode[][] grid = new BoardNode[height][width]; + Map emptyNodes = new HashMap<>(); + for (int y = 0; y < height; y++) { + JSONArray row = (JSONArray) rows.get(y); + for (int x = 0; x < width; x++) { + JSONObject cellJSON = (JSONObject) row.get(x); + String name = (String) cellJSON.get("name"); + int id = ((Number) cellJSON.get("id")).intValue(); + grid[y][x] = resolveCell(name, id, emptyNodes); + } + } + state.gridBoard = new GridBoard(grid, gridBoardID); + + LinkedList> winnerCells = new LinkedList<>(); + JSONArray winnerArray = (JSONArray) json.get("winnerCells"); + if (winnerArray != null) { + for (Object o : winnerArray) { + JSONArray pair = (JSONArray) o; + int x = ((Number) pair.get(0)).intValue(); + int y = ((Number) pair.get(1)).intValue(); + winnerCells.add(new Pair<>(x, y)); + } + } + state.winnerCells = winnerCells; + } + + /** + * Occupied cells resolve to the shared static playerMapping markers (matched by name) so that + * componentIDs are identical to the pre-serialization state. Empty cells are rebuilt once per + * distinct componentID and reused across the board, mirroring how the forward model fills the + * board with a single shared empty node. + */ + private static BoardNode resolveCell(String name, int id, Map emptyNodes) { + for (BoardNode marker : Connect4Constants.playerMapping) { + if (marker.getComponentName().equals(name)) + return marker; + } + return emptyNodes.computeIfAbsent(id, i -> new BoardNode(name, i)); + } +} diff --git a/src/main/java/games/descent2e/DescentHelper.java b/src/main/java/games/descent2e/DescentHelper.java index e1eb030cb..047ae7bb3 100644 --- a/src/main/java/games/descent2e/DescentHelper.java +++ b/src/main/java/games/descent2e/DescentHelper.java @@ -330,7 +330,7 @@ private static HashMap>> getAllAdjacentNode nodesToBeExpanded.remove(expandingNode); // Go through all the neighbour nodes - HashMap neighbours = expandingNode.getNeighbours(); + Map neighbours = expandingNode.getNeighbours(); for (BoardNode neighbour : neighbours.keySet()){ Vector2D loc = ((PropertyVector2D) neighbour.getProperty(coordinateHash)).values; diff --git a/src/main/java/players/mcts/OMATreeNode.java b/src/main/java/players/mcts/OMATreeNode.java index aad42cab3..c98f3e91e 100644 --- a/src/main/java/players/mcts/OMATreeNode.java +++ b/src/main/java/players/mcts/OMATreeNode.java @@ -1,15 +1,9 @@ package players.mcts; -import com.sun.xml.bind.v2.runtime.unmarshaller.XsiNilLoader; import core.AbstractGameState; import core.actions.AbstractAction; -import utilities.Pair; import java.util.*; -import java.util.stream.Collectors; - -import static java.util.stream.Collectors.toList; -import static players.mcts.MCTSEnums.Information.Closed_Loop; /** * Opponent Move Abstraction adds a set of statistics at each node that ignore (abstract out) the decisions diff --git a/src/main/java/utilities/Pathfinder.java b/src/main/java/utilities/Pathfinder.java index 0483e186c..5ae85f0bd 100644 --- a/src/main/java/utilities/Pathfinder.java +++ b/src/main/java/utilities/Pathfinder.java @@ -64,7 +64,7 @@ private void initShortestPaths() */ private void initShortestPaths(BoardNode origin) { - HashMap nodeNeighbours = origin.getNeighbours(); + Map nodeNeighbours = origin.getNeighbours(); int originID = origin.getComponentID(); for(BoardNode node : nodeNeighbours.keySet()) { @@ -223,7 +223,7 @@ private boolean _a_star(AbstractGameState gState, Path path) } //For all connections from the current node... - HashMap conn = currentNode.getNeighbours(); + Map conn = currentNode.getNeighbours(); for(BoardNode connected : conn.keySet()) { //If it has not been evaluated yet. From 43453f66cbdb18812967798e48d74a75b070a14e Mon Sep 17 00:00:00 2001 From: hopshackle Date: Wed, 24 Jun 2026 15:55:17 +0100 Subject: [PATCH 28/41] Connect4 serializable --- src/test/java/games/connect4/JSONTest.java | 119 +++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/test/java/games/connect4/JSONTest.java diff --git a/src/test/java/games/connect4/JSONTest.java b/src/test/java/games/connect4/JSONTest.java new file mode 100644 index 000000000..6166c791a --- /dev/null +++ b/src/test/java/games/connect4/JSONTest.java @@ -0,0 +1,119 @@ +package games.connect4; + +import core.CoreConstants; +import core.actions.AbstractAction; +import core.actions.SetGridValueAction; +import games.GameType; +import org.json.simple.JSONObject; +import org.junit.Test; +import utilities.JSONUtils; + +import java.io.File; +import java.util.List; + +import static org.junit.Assert.*; + +public class JSONTest { + + private Connect4ForwardModel fm = new Connect4ForwardModel(); + + /** + * Plays the given columns in order, alternating players, returning the resulting state. + */ + private Connect4GameState playGame(Connect4GameParameters params, int... columns) { + Connect4GameState state = (Connect4GameState) GameType.Connect4.createGameState(params, 2); + fm.setup(state); + for (int col : columns) { + List actions = fm.computeAvailableActions(state); + AbstractAction chosen = actions.get(0); + for (AbstractAction a : actions) { + if (a instanceof SetGridValueAction sg && sg.getX() == col) { + chosen = a; + break; + } + } + fm.next(state, chosen); + } + return state; + } + + @Test + public void testJSON() { + Connect4GameParameters params = new Connect4GameParameters(); + Connect4GameState state = playGame(params, 0, 1, 2, 3, 0, 1); + + state.setFirstPlayer(1); + state.setTurnOwner(0); + state.setGameStatus(CoreConstants.GameResult.GAME_ONGOING); + + JSONObject json = state.toJSON(); + + // The shared AbstractGameState fields are nested under "abstractGameState" + assertTrue(json.containsKey("abstractGameState")); + JSONObject abstractGS = (JSONObject) json.get("abstractGameState"); + assertEquals(1L, ((Number) abstractGS.get("firstPlayer")).longValue()); + assertEquals(0L, ((Number) abstractGS.get("turnOwner")).longValue()); + + // Connect4-specific board fields + assertEquals(params.width, ((Number) json.get("width")).intValue()); + assertEquals(params.height, ((Number) json.get("height")).intValue()); + + // Load it back into a freshly set up state, as the GUI's load path does + Connect4GameState newState = (Connect4GameState) GameType.Connect4.createGameState(params, 2); + fm.setup(newState); + Connect4StateJSON.loadFromJSON(newState, json); + + assertEquals(state.getFirstPlayer(), newState.getFirstPlayer()); + assertEquals(state.getTurnOwner(), newState.getTurnOwner()); + assertEquals(state.getGameStatus(), newState.getGameStatus()); + assertEquals(state.getGameTick(), newState.getGameTick()); + assertEquals(state.getGridBoard(), newState.getGridBoard()); + } + + @Test + public void testJSONLoadFromFile() throws Exception { + Connect4GameParameters params = new Connect4GameParameters(); + Connect4GameState state = playGame(params, 3, 3, 4, 2, 5, 1); + state.setGameStatus(CoreConstants.GameResult.GAME_ONGOING); + + JSONObject json = state.toJSON(); + File file = File.createTempFile("connect4State", ".json"); + JSONUtils.writeJSON(json, file.getPath()); + + // Reload from the parsed-from-file JSON (exercises Long/Integer parsing quirks), via the + // reflective dispatcher the Frontend uses + JSONObject fromFile = JSONUtils.loadJSONFile(file.getPath()); + Connect4GameState loadedState = JSONUtils.loadClassFromJSON(fromFile); + + assertEquals(state, loadedState); + // identity of the board (componentID) and every cell must survive the round-trip + assertEquals(state.getGridBoard().getComponentID(), loadedState.getGridBoard().getComponentID()); + + file.delete(); + } + + @Test + public void parametersAreReadFromFile() throws Exception { + // Use non-default parameters to confirm they are serialized and restored rather than the defaults + Connect4GameParameters params = new Connect4GameParameters(); + params.setParameterValue("width", 8); + params.setParameterValue("height", 7); + params.setParameterValue("winCount", 5); + + Connect4GameState state = playGame(params, 0, 1, 0, 1); + + JSONObject json = state.toJSON(); + File file = File.createTempFile("connect4Params", ".json"); + JSONUtils.writeJSON(json, file.getPath()); + + Connect4GameState loadedState = JSONUtils.loadClassFromJSON(JSONUtils.loadJSONFile(file.getPath())); + Connect4GameParameters loadedParams = (Connect4GameParameters) loadedState.getGameParameters(); + + assertEquals(8, loadedParams.width); + assertEquals(7, loadedParams.height); + assertEquals(5, loadedParams.winCount); + assertEquals(state, loadedState); + + file.delete(); + } +} From 8ae392e0b56cf03c482621ae0317c100ff939a68 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Wed, 24 Jun 2026 18:25:01 +0100 Subject: [PATCH 29/41] Exploding Kittens and Deck serializable --- pom.xml | 7 + .../java/core/actions/AbstractAction.java | 6 +- src/main/java/core/components/Deck.java | 68 +++++++++- .../components/PartialObservableDeck.java | 45 +++++++ .../java/games/backgammon/BGStateJSON.java | 1 + .../games/connect4/Connect4StateJSON.java | 1 + .../ExplodingKittensGameState.java | 28 +++- .../ExplodingKittensStateJSON.java | 69 ++++++++++ .../actions/ChoiceOfCardToGive.java | 20 ++- .../actions/DefuseKitten.java | 19 ++- .../actions/NopeableAction.java | 26 +++- .../explodingkittens/actions/PlayEKCard.java | 18 ++- .../cards/ExplodingKittensCard.java | 19 ++- .../components/DeckSerializationTest.java | 120 ++++++++++++++++++ .../java/games/explodingkittens/JSONTest.java | 95 ++++++++++++++ 15 files changed, 532 insertions(+), 10 deletions(-) create mode 100644 src/main/java/games/explodingkittens/ExplodingKittensStateJSON.java create mode 100644 src/test/java/core/components/DeckSerializationTest.java create mode 100644 src/test/java/games/explodingkittens/JSONTest.java diff --git a/pom.xml b/pom.xml index 7c26fb208..fab7b378f 100644 --- a/pom.xml +++ b/pom.xml @@ -12,6 +12,13 @@ ${java.version} true www.tabletopgames.ai/wiki/maven + + ai.tabletopgames diff --git a/src/main/java/core/actions/AbstractAction.java b/src/main/java/core/actions/AbstractAction.java index 301f2dd2a..41985b52b 100644 --- a/src/main/java/core/actions/AbstractAction.java +++ b/src/main/java/core/actions/AbstractAction.java @@ -7,7 +7,7 @@ public abstract class AbstractAction implements IPrintable { - protected boolean saveGameBeforeAction = false; + private boolean saveGameBeforeAction = false; /** * Executes this action, applying its effect to the given game state. Can access any component IDs stored * through the AbstractGameState.getComponentById(int id) method. @@ -78,11 +78,11 @@ public String getTooltip(AbstractGameState gs) { * This is to indicate that the move is a 'critical' one in some sense. * This will only trigger action if the AbstractGameState implementation supports serialisation to a game state via the IToJson interface */ - public boolean saveGame() { + public final boolean saveGame() { return saveGameBeforeAction; } - public void setSaveGame(boolean saveGame) { + public final void setSaveGame(boolean saveGame) { this.saveGameBeforeAction = saveGame; } } diff --git a/src/main/java/core/components/Deck.java b/src/main/java/core/components/Deck.java index dc2bbfe6b..77c2b9a90 100644 --- a/src/main/java/core/components/Deck.java +++ b/src/main/java/core/components/Deck.java @@ -2,11 +2,13 @@ import core.CoreConstants; import core.interfaces.IComponentContainer; +import core.interfaces.IToJSON; import org.jetbrains.annotations.NotNull; import org.json.simple.JSONArray; import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; import org.json.simple.parser.ParseException; +import utilities.JSONUtils; import java.io.FileReader; import java.io.IOException; @@ -22,7 +24,7 @@ * * Components played on the player's area * * Discard pile */ -public class Deck extends Component implements IComponentContainer, Iterable { +public class Deck extends Component implements IComponentContainer, Iterable, IToJSON { protected int capacity; // Capacity of the deck (maximum number of elements) protected List components; // List of components in this deck @@ -115,6 +117,70 @@ public static Deck loadDeckOfCards(JSONObject deck) { return newDeck; } + /** + * Serializes the runtime state of this deck to JSON, so that it can be reconstructed via + * {@link #loadDeck}. Every component in the deck must itself implement {@link IToJSON} and + * expose a constructor taking a {@link JSONObject} (so it can be re-created on load) - this is + * the implicit requirement of putting a component into a serializable deck. Each component's JSON + * is tagged with its concrete class so it can be reconstructed reflectively. The deck's identity + * (componentID), name, ownerId, capacity, visibility mode and the order of its components are + * all preserved, so that the reloaded deck is {@code equals()} to the original. + * + * @throws IllegalStateException if any component in the deck does not implement {@link IToJSON}. + */ + @Override + @SuppressWarnings("unchecked") + public JSONObject toJSON() { + JSONObject json = new JSONObject(); + json.put("name", componentName); + json.put("id", componentID); + json.put("ownerId", ownerId); + json.put("capacity", capacity); + json.put("visibility", visibility.name()); + JSONArray cards = new JSONArray(); + for (T component : components) { + if (component instanceof IToJSON serializable) { + JSONObject cardJSON = serializable.toJSON(); + // tag with the concrete class so loadDeck can reconstruct it via its JSONObject constructor + cardJSON.putIfAbsent("class", component.getClass().getName()); + cards.add(cardJSON); + } else + throw new IllegalStateException("Cannot serialize deck '" + componentName + + "': component does not implement IToJSON : " + component.getClass().getName()); + } + json.put("cards", cards); + return json; + } + + /** + * Reconstructs a Deck from JSON produced by {@link #toJSON}. Each component is recreated by + * recursing into its JSON via {@link JSONUtils#loadClassFromJSON}, which finds the component's + * {@link JSONObject} constructor - so every component type in a serializable deck must provide one. + * + * @param json - the JSON produced by {@link #toJSON}. + */ + public static Deck loadDeck(JSONObject json) { + Deck deck = new Deck<>((String) json.get("name"), + ((Number) json.get("ownerId")).intValue(), + ((Number) json.get("id")).intValue(), + VisibilityMode.valueOf((String) json.get("visibility"))); + deck.capacity = ((Number) json.get("capacity")).intValue(); + populateComponents(deck, json); + return deck; + } + + /** + * Appends the serialized components (in their stored order) to the deck's component list, each + * reconstructed from its JSON via its {@link JSONObject} constructor. Adds directly to the + * underlying list rather than via {@link #add}, so that the original order is preserved exactly + * and no subclass add-side-effects (e.g. visibility handling) are triggered. + */ + protected static void populateComponents(Deck deck, JSONObject json) { + JSONArray cards = (JSONArray) json.get("cards"); + for (Object o : cards) + deck.components.add(JSONUtils.loadClassFromJSON((JSONObject) o)); + } + /** * Draws the first component of the deck * diff --git a/src/main/java/core/components/PartialObservableDeck.java b/src/main/java/core/components/PartialObservableDeck.java index cd79deb37..f384b4579 100644 --- a/src/main/java/core/components/PartialObservableDeck.java +++ b/src/main/java/core/components/PartialObservableDeck.java @@ -3,7 +3,10 @@ import core.AbstractGameState; import core.CoreConstants.VisibilityMode; import org.jetbrains.annotations.NotNull; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; import utilities.DeterminisationUtilities; +import utilities.JSONUtils; import utilities.Pair; import java.util.*; @@ -347,6 +350,48 @@ public boolean[] getDeckVisibility() { return deckVisibility; } + /** + * Serializes the runtime state of this deck, including the per-deck and per-component visibility + * (which {@link Deck#equals} does not compare, but which is essential to preserve hidden + * information across a save/load). As for {@link Deck#toJSON}, every component must implement + * {@link core.interfaces.IToJSON}. Reconstruct with {@link #loadDeck}. + */ + @Override + @SuppressWarnings("unchecked") + public JSONObject toJSON() { + JSONObject json = super.toJSON(); + json.put("deckVisibility", JSONUtils.booleanArrayToJSON(deckVisibility)); + JSONArray elementVis = new JSONArray(); + for (boolean[] vis : elementVisibility) + elementVis.add(JSONUtils.booleanArrayToJSON(vis)); + json.put("elementVisibility", elementVis); + return json; + } + + /** + * Reconstructs a PartialObservableDeck from JSON produced by {@link #toJSON}, restoring the + * components (each via its {@link JSONObject} constructor), the deck visibility and the + * per-component visibility exactly. + * + * @param json - the JSON produced by {@link #toJSON}. + */ + public static PartialObservableDeck loadDeck(JSONObject json) { + boolean[] deckVisibility = JSONUtils.booleanArrayFromJSON((JSONArray) json.get("deckVisibility")); + PartialObservableDeck deck = new PartialObservableDeck<>((String) json.get("name"), + ((Number) json.get("ownerId")).intValue(), deckVisibility, + ((Number) json.get("id")).intValue()); + // set the visibility mode directly (not via setVisibility, which would re-derive element + // visibility) as we restore the exact per-component visibility below + deck.visibility = VisibilityMode.valueOf((String) json.get("visibility")); + deck.capacity = ((Number) json.get("capacity")).intValue(); + populateComponents(deck, json); + List elementVisibility = new LinkedList<>(); + for (Object o : (JSONArray) json.get("elementVisibility")) + elementVisibility.add(JSONUtils.booleanArrayFromJSON((JSONArray) o)); + deck.elementVisibility = elementVisibility; + return deck; + } + @Override public PartialObservableDeck copy() { PartialObservableDeck dp = new PartialObservableDeck<>(componentName, ownerId, deckVisibility, componentID); diff --git a/src/main/java/games/backgammon/BGStateJSON.java b/src/main/java/games/backgammon/BGStateJSON.java index 11f949459..50587d97a 100644 --- a/src/main/java/games/backgammon/BGStateJSON.java +++ b/src/main/java/games/backgammon/BGStateJSON.java @@ -54,6 +54,7 @@ public static void loadFromJSON(BGGameState state, JSONObject json) { state.playerTrackMapping = JSONUtils.intMatrixFromJSON((JSONArray) json.get("playerTrackMapping")); } + @SuppressWarnings("unchecked") public static JSONObject toJSON(BGGameState state) { JSONObject json = new JSONObject(); json.put("class", "games.backgammon.BGGameState"); diff --git a/src/main/java/games/connect4/Connect4StateJSON.java b/src/main/java/games/connect4/Connect4StateJSON.java index 3fd5d9476..029de5faf 100644 --- a/src/main/java/games/connect4/Connect4StateJSON.java +++ b/src/main/java/games/connect4/Connect4StateJSON.java @@ -24,6 +24,7 @@ */ public class Connect4StateJSON { + @SuppressWarnings("unchecked") public static JSONObject toJSON(Connect4GameState state) { JSONObject json = new JSONObject(); json.put("class", "games.connect4.Connect4GameState"); diff --git a/src/main/java/games/explodingkittens/ExplodingKittensGameState.java b/src/main/java/games/explodingkittens/ExplodingKittensGameState.java index d010f28c4..a1bc87128 100644 --- a/src/main/java/games/explodingkittens/ExplodingKittensGameState.java +++ b/src/main/java/games/explodingkittens/ExplodingKittensGameState.java @@ -6,15 +6,18 @@ import core.components.Component; import core.components.Deck; import core.components.PartialObservableDeck; +import core.interfaces.IToJSON; +import evaluation.optimisation.TunableParameters; import games.GameType; import games.explodingkittens.cards.ExplodingKittensCard; +import org.json.simple.JSONObject; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Objects; -public class ExplodingKittensGameState extends AbstractGameState { +public class ExplodingKittensGameState extends AbstractGameState implements IToJSON { // Cards in each player's hand, index corresponds to player ID List> playerHandCards; @@ -33,6 +36,29 @@ public ExplodingKittensGameState(AbstractParameters gameParameters, int nPlayers super(gameParameters, nPlayers); } + /** + * Reconstructs a game state from JSON produced by {@link #toJSON}. As for {@code BGGameState}, we + * first build a default state (so that all transient fields are initialised), reload the tunable + * parameters, run a fresh setup, and then overwrite the game-specific state from the JSON. + */ + public ExplodingKittensGameState(JSONObject json) { + this(new ExplodingKittensParameters(), + ((Number) ((JSONObject) json.get("abstractGameState")).get("nPlayers")).intValue()); + JSONObject abstractGameStateJSON = (JSONObject) json.get("abstractGameState"); + JSONObject gameParamsJSON = (JSONObject) abstractGameStateJSON.get("gameParams"); + if (gameParamsJSON != null) { + this.gameParameters = TunableParameters.loadFromJSON(new ExplodingKittensParameters(), gameParamsJSON); + } + reset(); + new ExplodingKittensForwardModel().setup(this); + ExplodingKittensStateJSON.loadFromJSON(this, json); + } + + @Override + public JSONObject toJSON() { + return ExplodingKittensStateJSON.toJSON(this); + } + @Override protected GameType _getGameType() { return GameType.ExplodingKittens; diff --git a/src/main/java/games/explodingkittens/ExplodingKittensStateJSON.java b/src/main/java/games/explodingkittens/ExplodingKittensStateJSON.java new file mode 100644 index 000000000..b03c47050 --- /dev/null +++ b/src/main/java/games/explodingkittens/ExplodingKittensStateJSON.java @@ -0,0 +1,69 @@ +package games.explodingkittens; + +import core.components.Deck; +import core.components.PartialObservableDeck; +import games.explodingkittens.cards.ExplodingKittensCard; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import utilities.JSONUtils; + +import java.util.ArrayList; + +/** + * Serialization of {@link ExplodingKittensGameState} to and from JSON, following the same pattern as + * {@code games.backgammon.BGStateJSON} and {@code games.connect4.Connect4StateJSON}: the shared + * AbstractGameState fields (parameters, game phase, turn counters and - crucially for this game - the + * stack of in-progress {@link core.interfaces.IExtendedSequence}s) are handled by + * {@link core.AbstractGameState#abstractGameStateToJSON()} / + * {@link core.AbstractGameState#loadAbstractGameStateFromJSON}, and this class adds the Exploding + * Kittens specific state. + *

+ * The card decks are themselves serializable ({@link Deck} / {@link PartialObservableDeck} implement + * {@link core.interfaces.IToJSON}), so each is round-tripped via its own {@code toJSON} / {@code loadDeck}, + * preserving componentIDs, card order and (for the partially observable decks) per-player visibility. + */ +public class ExplodingKittensStateJSON { + + @SuppressWarnings("unchecked") + public static JSONObject toJSON(ExplodingKittensGameState state) { + JSONObject json = new JSONObject(); + json.put("class", "games.explodingkittens.ExplodingKittensGameState"); + + // this includes parameters, game phase and the actionsInProgress stack (the extended sequences) + json.put("abstractGameState", state.abstractGameStateToJSON()); + + json.put("drawPile", state.drawPile.toJSON()); + json.put("discardPile", state.discardPile.toJSON()); + json.put("inPlay", state.inPlay.toJSON()); + + JSONArray hands = new JSONArray(); + for (PartialObservableDeck hand : state.playerHandCards) + hands.add(hand.toJSON()); + json.put("playerHandCards", hands); + + json.put("currentPlayerTurnsLeft", state.currentPlayerTurnsLeft); + json.put("nextAttackLevel", state.nextAttackLevel); + json.put("skip", state.skip); + json.put("orderOfPlayerDeath", JSONUtils.intArrayToJSON(state.orderOfPlayerDeath)); + + return json; + } + + public static void loadFromJSON(ExplodingKittensGameState state, JSONObject json) { + state.loadAbstractGameStateFromJSON((JSONObject) json.get("abstractGameState")); + + state.drawPile = PartialObservableDeck.loadDeck((JSONObject) json.get("drawPile")); + state.discardPile = Deck.loadDeck((JSONObject) json.get("discardPile")); + state.inPlay = Deck.loadDeck((JSONObject) json.get("inPlay")); + + JSONArray hands = (JSONArray) json.get("playerHandCards"); + state.playerHandCards = new ArrayList<>(); + for (Object o : hands) + state.playerHandCards.add(PartialObservableDeck.loadDeck((JSONObject) o)); + + state.currentPlayerTurnsLeft = ((Number) json.get("currentPlayerTurnsLeft")).intValue(); + state.nextAttackLevel = ((Number) json.get("nextAttackLevel")).intValue(); + state.skip = (boolean) json.get("skip"); + state.orderOfPlayerDeath = JSONUtils.intArrayFromJSON((JSONArray) json.get("orderOfPlayerDeath")); + } +} diff --git a/src/main/java/games/explodingkittens/actions/ChoiceOfCardToGive.java b/src/main/java/games/explodingkittens/actions/ChoiceOfCardToGive.java index 35f7c9f3a..008a3c7b8 100644 --- a/src/main/java/games/explodingkittens/actions/ChoiceOfCardToGive.java +++ b/src/main/java/games/explodingkittens/actions/ChoiceOfCardToGive.java @@ -3,12 +3,14 @@ import core.AbstractGameState; import core.actions.AbstractAction; import core.interfaces.IExtendedSequence; +import core.interfaces.IToJSON; import games.explodingkittens.ExplodingKittensGameState; +import org.json.simple.JSONObject; import java.util.List; import java.util.stream.Collectors; -public class ChoiceOfCardToGive implements IExtendedSequence { +public class ChoiceOfCardToGive implements IExtendedSequence, IToJSON { public final int giver; public final int recipient; @@ -19,6 +21,22 @@ public ChoiceOfCardToGive(int giver, int recipient) { this.recipient = recipient; } + public ChoiceOfCardToGive(JSONObject json) { + this(((Number) json.get("giver")).intValue(), ((Number) json.get("recipient")).intValue()); + this.executed = (boolean) json.get("executed"); + } + + @Override + @SuppressWarnings("unchecked") + public JSONObject toJSON() { + JSONObject json = new JSONObject(); + json.put("class", getClass().getName()); + json.put("giver", giver); + json.put("recipient", recipient); + json.put("executed", executed); + return json; + } + @Override public List _computeAvailableActions(AbstractGameState gs) { ExplodingKittensGameState state = (ExplodingKittensGameState) gs; diff --git a/src/main/java/games/explodingkittens/actions/DefuseKitten.java b/src/main/java/games/explodingkittens/actions/DefuseKitten.java index a03d3ece5..fec730e06 100644 --- a/src/main/java/games/explodingkittens/actions/DefuseKitten.java +++ b/src/main/java/games/explodingkittens/actions/DefuseKitten.java @@ -3,14 +3,16 @@ import core.AbstractGameState; import core.actions.AbstractAction; import core.interfaces.IExtendedSequence; +import core.interfaces.IToJSON; import games.explodingkittens.ExplodingKittensGameState; +import org.json.simple.JSONObject; import java.util.List; import java.util.stream.IntStream; import static java.util.stream.Collectors.toList; -public class DefuseKitten implements IExtendedSequence { +public class DefuseKitten implements IExtendedSequence, IToJSON { boolean executed; final int player; @@ -20,6 +22,21 @@ public DefuseKitten(int player) { this.player = player; } + public DefuseKitten(JSONObject json) { + this(((Number) json.get("player")).intValue()); + this.executed = (boolean) json.get("executed"); + } + + @Override + @SuppressWarnings("unchecked") + public JSONObject toJSON() { + JSONObject json = new JSONObject(); + json.put("class", getClass().getName()); + json.put("player", player); + json.put("executed", executed); + return json; + } + @Override public List _computeAvailableActions(AbstractGameState state) { ExplodingKittensGameState ekgs = (ExplodingKittensGameState) state; diff --git a/src/main/java/games/explodingkittens/actions/NopeableAction.java b/src/main/java/games/explodingkittens/actions/NopeableAction.java index 0b3540769..a4c69ffc2 100644 --- a/src/main/java/games/explodingkittens/actions/NopeableAction.java +++ b/src/main/java/games/explodingkittens/actions/NopeableAction.java @@ -4,8 +4,11 @@ import core.actions.AbstractAction; import core.components.Deck; import core.interfaces.IExtendedSequence; +import core.interfaces.IToJSON; import games.explodingkittens.ExplodingKittensGameState; import games.explodingkittens.cards.ExplodingKittensCard; +import org.json.simple.JSONObject; +import utilities.JSONUtils; import java.util.List; import java.util.Objects; @@ -13,7 +16,7 @@ import static games.explodingkittens.cards.ExplodingKittensCard.CardType.NOPE; -public class NopeableAction implements IExtendedSequence { +public class NopeableAction implements IExtendedSequence, IToJSON { int currentInterrupter = -1; int lastCardPlayedBy; @@ -35,6 +38,27 @@ private NopeableAction(int lastCardPlayedBy, PlayEKCard originalAction, int curr this.nopes = nopes; } + /** Reconstructs the in-progress sequence from JSON produced by {@link #toJSON}, restoring its + * local state directly (without consulting the game state, as the live constructor does). */ + public NopeableAction(JSONObject json) { + this.lastCardPlayedBy = ((Number) json.get("lastCardPlayedBy")).intValue(); + this.currentInterrupter = ((Number) json.get("currentInterrupter")).intValue(); + this.nopes = ((Number) json.get("nopes")).intValue(); + this.originalAction = JSONUtils.loadClassFromJSON((JSONObject) json.get("originalAction")); + } + + @Override + @SuppressWarnings("unchecked") + public JSONObject toJSON() { + JSONObject json = new JSONObject(); + json.put("class", getClass().getName()); + json.put("lastCardPlayedBy", lastCardPlayedBy); + json.put("currentInterrupter", currentInterrupter); + json.put("nopes", nopes); + json.put("originalAction", originalAction.toJSON()); + return json; + } + @Override public List _computeAvailableActions(AbstractGameState gs) { ExplodingKittensGameState state = (ExplodingKittensGameState) gs; diff --git a/src/main/java/games/explodingkittens/actions/PlayEKCard.java b/src/main/java/games/explodingkittens/actions/PlayEKCard.java index 323fa08be..cea64624b 100644 --- a/src/main/java/games/explodingkittens/actions/PlayEKCard.java +++ b/src/main/java/games/explodingkittens/actions/PlayEKCard.java @@ -2,12 +2,14 @@ import core.AbstractGameState; import core.actions.AbstractAction; +import core.interfaces.IToJSON; import games.explodingkittens.ExplodingKittensGameState; import games.explodingkittens.cards.ExplodingKittensCard.CardType; +import org.json.simple.JSONObject; import java.util.Objects; -public class PlayEKCard extends AbstractAction { +public class PlayEKCard extends AbstractAction implements IToJSON { public final CardType cardType; public final int target; @@ -22,6 +24,20 @@ public PlayEKCard(CardType cardType, int target) { this.target = target; } + public PlayEKCard(JSONObject json) { + this(CardType.valueOf((String) json.get("cardType")), ((Number) json.get("target")).intValue()); + } + + @Override + @SuppressWarnings("unchecked") + public JSONObject toJSON() { + JSONObject json = new JSONObject(); + json.put("class", getClass().getName()); + json.put("cardType", cardType.name()); + json.put("target", target); + return json; + } + @Override public boolean execute(AbstractGameState gs) { ExplodingKittensGameState state = (ExplodingKittensGameState) gs; diff --git a/src/main/java/games/explodingkittens/cards/ExplodingKittensCard.java b/src/main/java/games/explodingkittens/cards/ExplodingKittensCard.java index 4ed0200dc..53f464967 100644 --- a/src/main/java/games/explodingkittens/cards/ExplodingKittensCard.java +++ b/src/main/java/games/explodingkittens/cards/ExplodingKittensCard.java @@ -1,13 +1,15 @@ package games.explodingkittens.cards; import core.components.Card; +import core.interfaces.IToJSON; import games.explodingkittens.ExplodingKittensGameState; import games.explodingkittens.actions.ChoiceOfCardToGive; +import org.json.simple.JSONObject; import java.util.function.BiConsumer; -public class ExplodingKittensCard extends Card { +public class ExplodingKittensCard extends Card implements IToJSON { public static int SEE_THE_FUTURE_CARDS = 3; public static int ADDITIONAL_TURNS = 2; @@ -97,6 +99,21 @@ public ExplodingKittensCard(CardType cardType, int ID) { this.cardType = cardType; } + /** Reconstructs a card (with its original componentID) from JSON produced by {@link #toJSON}. + * Required so that a serialized {@link core.components.Deck} of these can recurse to recreate them. */ + public ExplodingKittensCard(JSONObject json) { + this(CardType.valueOf((String) json.get("cardType")), ((Number) json.get("id")).intValue()); + } + + @Override + @SuppressWarnings("unchecked") + public JSONObject toJSON() { + JSONObject json = new JSONObject(); + json.put("cardType", cardType.name()); + json.put("id", componentID); + return json; + } + @Override public Card copy() { return this; // immutable diff --git a/src/test/java/core/components/DeckSerializationTest.java b/src/test/java/core/components/DeckSerializationTest.java new file mode 100644 index 000000000..f1dae4f19 --- /dev/null +++ b/src/test/java/core/components/DeckSerializationTest.java @@ -0,0 +1,120 @@ +package core.components; + +import core.CoreConstants; +import core.CoreConstants.VisibilityMode; +import core.interfaces.IToJSON; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Round-trip tests for {@link Deck} / {@link PartialObservableDeck} serialization. Both implement + * {@link IToJSON}, which implicitly requires every component they hold to be serializable: it must + * implement {@link IToJSON} and provide a {@link JSONObject} constructor, since loading + * recurses into each component via {@link utilities.JSONUtils#loadClassFromJSON}. The {@link TestCard} + * below is the minimal component that satisfies that contract. + */ +public class DeckSerializationTest { + + /** A minimal serializable component: implements IToJSON and has a JSONObject constructor that + * restores its componentID, so deck round-trips preserve identity. */ + public static class TestCard extends Component implements IToJSON { + TestCard(String name) { + super(CoreConstants.ComponentType.CARD, name); + } + + public TestCard(JSONObject json) { + super(CoreConstants.ComponentType.CARD, (String) json.get("name"), ((Number) json.get("id")).intValue()); + } + + @Override + public Component copy() { + return new TestCard(toJSON()); + } + + @Override + @SuppressWarnings("unchecked") + public JSONObject toJSON() { + JSONObject json = new JSONObject(); + json.put("name", componentName); + json.put("id", componentID); + return json; + } + } + + /** Re-parses through text, as a real save-to-file/load-from-file round-trip would (json-simple + * yields Long for integers, so this also guards the {@code (Number)} casts). */ + private static JSONObject reparse(JSONObject json) throws Exception { + return (JSONObject) new JSONParser().parse(json.toJSONString()); + } + + @Test + public void deckRoundTrips() throws Exception { + Deck deck = new Deck<>("My Deck", 2, VisibilityMode.VISIBLE_TO_ALL); + deck.setCapacity(10); + deck.add(new TestCard("Ace")); + deck.add(new TestCard("King")); + deck.add(new TestCard("Queen")); + + Deck loaded = Deck.loadDeck(reparse(deck.toJSON())); + + // Deck.equals covers componentID, capacity and the ordered component list (by componentID) + assertEquals(deck, loaded); + assertEquals(deck.getComponentID(), loaded.getComponentID()); + assertEquals(deck.getOwnerId(), loaded.getOwnerId()); + assertEquals(deck.getVisibilityMode(), loaded.getVisibilityMode()); + assertEquals(deck.getSize(), loaded.getSize()); + for (int i = 0; i < deck.getSize(); i++) { + assertEquals(deck.get(i).getComponentID(), loaded.get(i).getComponentID()); + assertEquals(deck.get(i).getComponentName(), loaded.get(i).getComponentName()); + } + } + + @Test + public void emptyDeckRoundTrips() throws Exception { + Deck deck = new Deck<>("Empty", VisibilityMode.HIDDEN_TO_ALL); + Deck loaded = Deck.loadDeck(reparse(deck.toJSON())); + assertEquals(deck, loaded); + assertEquals(0, loaded.getSize()); + } + + @Test + public void partialObservableDeckRoundTripsIncludingVisibility() throws Exception { + int nPlayers = 3; + PartialObservableDeck deck = new PartialObservableDeck<>("Hand", 1, nPlayers, VisibilityMode.MIXED_VISIBILITY); + // add cards with distinct per-player visibility patterns + deck.add(new TestCard("c0"), new boolean[]{true, false, false}); + deck.add(new TestCard("c1"), new boolean[]{false, true, true}); + deck.add(new TestCard("c2"), new boolean[]{true, true, true}); + + PartialObservableDeck loaded = PartialObservableDeck.loadDeck(reparse(deck.toJSON())); + + // Deck.equals ignores visibility, so this only confirms identity/order/contents... + assertEquals(deck, loaded); + assertEquals(deck.getComponentID(), loaded.getComponentID()); + assertEquals(deck.getVisibilityMode(), loaded.getVisibilityMode()); + + // ...the visibility itself must be asserted explicitly, as it is the whole point of a + // PartialObservableDeck and would otherwise be silently lost. + assertArrayEquals(deck.getDeckVisibility(), loaded.getDeckVisibility()); + assertEquals(deck.getSize(), loaded.getSize()); + for (int i = 0; i < deck.getSize(); i++) { + assertArrayEquals("element " + i + " visibility", + deck.getVisibilityOfComponent(i), loaded.getVisibilityOfComponent(i)); + for (int p = 0; p < nPlayers; p++) { + assertEquals("card " + i + " visible to player " + p, + deck.isComponentVisible(i, p), loaded.isComponentVisible(i, p)); + } + } + } + + @Test + public void serializingDeckWithNonSerializableComponentThrows() { + // Card does not implement IToJSON, so it cannot live in a serializable deck. + Deck deck = new Deck<>("Bad", VisibilityMode.VISIBLE_TO_ALL); + deck.add(new Card("plain")); + assertThrows(IllegalStateException.class, deck::toJSON); + } +} diff --git a/src/test/java/games/explodingkittens/JSONTest.java b/src/test/java/games/explodingkittens/JSONTest.java new file mode 100644 index 000000000..0680d6497 --- /dev/null +++ b/src/test/java/games/explodingkittens/JSONTest.java @@ -0,0 +1,95 @@ +package games.explodingkittens; + +import core.CoreConstants; +import games.explodingkittens.actions.NopeableAction; +import games.explodingkittens.actions.PlayEKCard; +import games.explodingkittens.cards.ExplodingKittensCard; +import org.json.simple.JSONObject; +import org.junit.Test; +import utilities.JSONUtils; + +import java.io.File; + +import static games.explodingkittens.cards.ExplodingKittensCard.CardType.NOPE; +import static games.explodingkittens.cards.ExplodingKittensCard.CardType.SKIP; +import static org.junit.Assert.*; + +/** + * Round-trip serialization tests for {@link ExplodingKittensGameState}, mirroring + * {@code games.backgammon.JSONTest} and {@code games.connect4.JSONTest}. Exploding Kittens is the + * first game serialized that uses Extended Action Sequences, so the second and third tests + * specifically exercise an {@link core.interfaces.IExtendedSequence} on the actionsInProgress stack. + */ +public class JSONTest { + + private ExplodingKittensGameState freshState(int nPlayers) { + ExplodingKittensParameters params = new ExplodingKittensParameters(); + ExplodingKittensGameState state = new ExplodingKittensGameState(params, nPlayers); + ExplodingKittensForwardModel fm = new ExplodingKittensForwardModel(); + fm.setup(state); + return state; + } + + @Test + public void testJSON() { + ExplodingKittensGameState state = freshState(4); + state.setFirstPlayer(1); + state.setTurnOwner(2); + state.setGameStatus(CoreConstants.GameResult.GAME_ONGOING); + + JSONObject json = state.toJSON(); + + assertTrue(json.containsKey("abstractGameState")); + JSONObject abstractGS = (JSONObject) json.get("abstractGameState"); + assertEquals(1L, ((Number) abstractGS.get("firstPlayer")).longValue()); + assertEquals(2L, ((Number) abstractGS.get("turnOwner")).longValue()); + + ExplodingKittensGameState loaded = JSONUtils.loadClassFromJSON(json); + + // Full equality: decks (with componentIDs, order and visibility), counters and the + // (here empty) actionsInProgress stack must all round-trip. + assertEquals(state, loaded); + assertEquals(state.getDrawPile(), loaded.getDrawPile()); + assertEquals(state.getDiscardPile(), loaded.getDiscardPile()); + for (int p = 0; p < state.getNPlayers(); p++) + assertEquals(state.getPlayerHand(p), loaded.getPlayerHand(p)); + } + + @Test + public void testJSONWithActionInProgress() { + ExplodingKittensGameState state = freshState(4); + state.setTurnOwner(0); + + // Ensure player 1 holds a NOPE so the NopeableAction finds an interrupter (and so stays + // open on the stack rather than completing immediately). + state.getPlayerHand(1).add(new ExplodingKittensCard(NOPE)); + NopeableAction nopeable = new NopeableAction(0, new PlayEKCard(SKIP), state); + state.setActionInProgress(nopeable); + assertTrue(state.isActionInProgress()); + + JSONObject json = state.toJSON(); + ExplodingKittensGameState loaded = JSONUtils.loadClassFromJSON(json); + + assertEquals(state, loaded); + assertTrue(loaded.isActionInProgress()); + assertEquals(state.currentActionInProgress(), loaded.currentActionInProgress()); + // currentPlayer is delegated to the sequence, so this confirms the interrupter was restored + assertEquals(state.getCurrentPlayer(), loaded.getCurrentPlayer()); + } + + @Test + public void testJSONLoadFromFile() { + ExplodingKittensGameState state = freshState(3); + state.setTurnOwner(0); + state.getPlayerHand(1).add(new ExplodingKittensCard(NOPE)); + state.setActionInProgress(new NopeableAction(0, new PlayEKCard(SKIP), state)); + + JSONObject json = state.toJSON(); + JSONUtils.writeJSON(json, "ekTestFile.json"); + + ExplodingKittensGameState loaded = JSONUtils.loadClassFromFile("ekTestFile.json"); + + assertEquals(state, loaded); + assertTrue(new File("ekTestFile.json").delete()); + } +} From c7c3ff488f5b715034bd7cc32327b85248999a7d Mon Sep 17 00:00:00 2001 From: hopshackle Date: Wed, 24 Jun 2026 21:16:38 +0100 Subject: [PATCH 30/41] Claude file --- CLAUDE.md | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..fe341b150 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,121 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Start here + +A more detailed, up-to-date summary of the codebase structure is maintained at +**https://deepwiki.com/GAIGResearch/TabletopGames**. Consult it first (e.g. via WebFetch) before +doing any detailed analysis of the source — it is faster than reading the code directly and gives a +fuller architectural picture than the overview below. + +## What this is + +TAG (Tabletop Games Framework) is a Java benchmark for board/card game AI research. It provides a +common API so that ~40 games and a range of AI agents can be run against each other, tuned, and +analysed through a shared infrastructure. This fork (`Advisers` branch) adds **advisers** (players +that suggest moves to other players) and an **LLM** module that generates/uses heuristics via +langchain4j. + +## Build, test, run + +Maven project, **Java 21**. + +```bash +mvn package # builds target/TAG.jar (jar-with-dependencies, main class core.TAG) +``` + +Tests are **skipped by default** (`maven.test.skip=true` in pom.xml). To run them you must override: + +```bash +mvn test -Dmaven.test.skip=false # all tests (JUnit 4 + Mockito) +mvn test -Dmaven.test.skip=false -Dtest=CustomDiceTest # single test class +mvn test -Dmaven.test.skip=false -Dtest=CustomDiceTest#methodName # single method +``` + +Running the framework — `core.TAG` dispatches on its first argument to a sub-module: + +```bash +java -jar target/TAG.jar [args...] +# EntryPoints: RunGames, ParameterSearch, FrontEnd, FrontEndSimple, +# ExpertIteration, OneStepDeviations, SkillLadder +java -jar target/TAG.jar RunGames --help # each entry point has its own --help +``` + +From an IDE, run `core.TAG`, or run `gui.Frontend` directly for the GUI. Most entry points are +configured via JSON config files (see `--help` / `RunArg`) or command-line `key=value` args parsed +by `evaluation/RunArg.java`. A `Dockerfile` builds and packages the same jar with the `data/` +directory. + +## Core architecture + +A game is the combination of four cooperating pieces, all in `core`: + +- **`AbstractGameState`** — all mutable game data. Implement `_copy(playerId)` (redeterminises hidden + info for that player's perspective), `_getAllComponents()`, `getGameScore()`, `_getHeuristicScore()`, + `_equals()`. State is the single source of truth; the forward model never holds game data. +- **`AbstractForwardModel`** — the rules engine. Implement `_setup()`, `_next(state, action)`, + `_computeAvailableActions(state)`, `endPlayerTurn(state)`. Most games extend + **`StandardForwardModel`** (use `StandardForwardModelWithTurnOrder` only for legacy turn-order games). +- **`AbstractParameters`** — tunable game parameters (implements `ITunableParameters` so games can be + optimised/searched). +- **`AbstractGUIManager`** subclass — Swing rendering (optional but expected). + +`core.Game` is the orchestrator: it owns the state, forward model, and the list of `AbstractPlayer`s, +drives the turn loop, broadcasts `Event`s to `IGameListener`s, and tracks per-tick statistics. + +**Actions** (`core.actions`) implement `AbstractAction`; multi-step decisions use +`IExtendedSequence` to keep an action "in progress" across several player decisions. + +**Registering a game**: every game lives in `games.` (e.g. `games.catan`) and is wired into the +central enum **`games.GameType`**, which maps the game to its state/forward-model/parameters/GUI +classes plus metadata (min/max players, categories). This enum is the canonical list of games — add +new games here. + +## Players (agents) + +`core.AbstractPlayer` defines an agent via `_getAction(state, possibleActions)` and `copy()`. Notable +implementations under `players/`: + +- `simple` — `RandomPlayer`, `OSLAPlayer` (one-step look-ahead), `FirstActionPlayer`. +- `mcts` (full MCTS, heavily configurable), `basicMCTS`, `rhea` (rolling-horizon EA), `rmhc`. +- `heuristics` — `IStateHeuristic` / `IActionHeuristic` implementations (linear, logistic, decision-tree, + learned-value, etc.) used to bias the search agents. +- `decorators` (implement `IPlayerDecorator`) — wrap a player to **filter the action list** before it + decides (e.g. forbidding action classes, epsilon-randomness). +- `observers` — **the Advisers feature** (`Advisers` branch). `GameAdviser` is an `IGameListener` + composed of an `AbstractPlayer` (decides what is "best") plus an `IAdviceFilter` (decides *which* + players are advised and *when* to intervene), writing advice to a file. + +`players.PlayerFactory.createPlayer(jsonFileOrName)` constructs agents from JSON descriptors — this is +how tournaments and the GUI instantiate configurable players (see `json/players/`). + +## Evaluation & analytics + +`evaluation/` is the experiment-running side: + +- `RunGames` — run tournaments of agents over one or more games (round-robin, one-vs-all, etc.). +- `tournaments/` — `RoundRobinTournament`, `SkillLadder`/`SkillGrid` (vary budget), result analyses. +- `optimisation/` — `ParameterSearch` (NTBEA-based tuning of tunable parameters), `OneStepDeviations`. +- `metrics/`, `listeners/`, `loggers/`, `summarisers/` — `IGameListener` + `Event` pipeline that + records game data to files/stats for analysis. Listener configs live in `metrics/*.json`. +- `features/`, `heuristics/` — state/action feature extraction feeding learned heuristics and + `ExpertIteration`. + +## LLM module + +`llm/` integrates langchain4j (OpenAI, Anthropic, Gemini, Mistral backends; see pom.xml). `JavaCoder` +asks an LLM to write Java heuristic code; `GamePromptGenerator` builds game-state prompts; +`StringHeuristic` compiles LLM-produced heuristic strings (uses `javaparser`). Requires provider API +keys in the environment. + +## Config & data locations + +- `json/` — player definitions, search spaces, listener and learner configs consumed at runtime. +- `metrics/` — metric/listener JSON configs; `metrics/out/` is the default results directory. +- `data/` — game assets (images, card/board definitions) loaded by some games; shipped into the + Docker image. + +## Python + +`core.PyTAG` + `players/python` expose a Python-facing API (PyTAG) for RL/gym-style use. From 18d4dcadf87f429a835594c91bb3b1ad3fcd09a4 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Thu, 25 Jun 2026 09:35:18 +0100 Subject: [PATCH 31/41] small fix for restarts after iterations done --- src/main/java/evaluation/ExpertIteration.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/evaluation/ExpertIteration.java b/src/main/java/evaluation/ExpertIteration.java index bfd89dd50..e34197d7e 100644 --- a/src/main/java/evaluation/ExpertIteration.java +++ b/src/main/java/evaluation/ExpertIteration.java @@ -166,8 +166,8 @@ public void run() { // Check to see if we are re-starting a previously aborted run Pair completedIterations = checkCompletedIterations(); int restartAtIteration = completedIterations.a; - boolean restartWithTuning = completedIterations.b > 0 && !Objects.equals(completedIterations.a, completedIterations.b); - iter = restartAtIteration; + boolean restartWithTuning = completedIterations.b > 0 && !Objects.equals(completedIterations.a, completedIterations.b) + && restartAtIteration < (int) config.get(RunArg.expertIterations); iter = restartAtIteration; if (completedIterations.a > 0 && completedIterations.b > 0) { System.out.printf("Restarting from iteration %d (with tuning: %b)%n", restartAtIteration, restartWithTuning); } From f5fb156519d1cba8e1212b4f84f249634184cf0c Mon Sep 17 00:00:00 2001 From: hopshackle Date: Thu, 25 Jun 2026 13:59:19 +0100 Subject: [PATCH 32/41] Fixed Backgammon Action metrics --- .../backgammon/metrics/BGActionMetrics.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/java/games/backgammon/metrics/BGActionMetrics.java b/src/main/java/games/backgammon/metrics/BGActionMetrics.java index 7a659dd99..f9c9310e7 100644 --- a/src/main/java/games/backgammon/metrics/BGActionMetrics.java +++ b/src/main/java/games/backgammon/metrics/BGActionMetrics.java @@ -3,6 +3,7 @@ import core.AbstractPlayer; import core.Game; import core.actions.AbstractAction; +import core.actions.DoNothing; import core.interfaces.IGameEvent; import evaluation.listeners.MetricsGameListener; import evaluation.metrics.AbstractMetric; @@ -47,14 +48,15 @@ public boolean _run(MetricsGameListener listener, Event e, Map r case LoadDice ignored -> "LoadDice"; case MovePiece mp when (mp.from == -1) -> "BearOn"; case MovePiece mp when (mp.to == -1) -> "BearOff"; - default -> "Move"; + case MovePiece ignored -> "Move"; + case DoNothing ignored -> "DoNothing"; + default -> "Unknown"; }; - if (type.equals("Move")) { - MovePiece move = (MovePiece) action; + if (action instanceof MovePiece move) { // get number of pieces on from/to points - int fromCount = state.getPiecesOnPoint(e.playerID, move.from); - int toCountOwn = state.getPiecesOnPoint(e.playerID, move.to); - int toCountOpp = state.getPiecesOnPoint(1 - e.playerID, move.to); + int fromCount = move.from > -1 ? state.getPiecesOnPoint(e.playerID, move.from) : -1; + int toCountOwn = move.to > -1 ? state.getPiecesOnPoint(e.playerID, move.to) : -1; + int toCountOpp = move.to > -1 ? state.getPiecesOnPoint(1 - e.playerID, move.to) : 0; if (toCountOpp == 1) { type = "Blot"; } @@ -74,7 +76,7 @@ public Set getDefaultEventTypes() { public Map> getColumns(int nPlayersPerGame, Set playerNames) { Map> columns = new HashMap<>(); columns.put("Type", String.class); - columns.put("From", String.class); + columns.put("From", Integer.class); columns.put("To", Integer.class); return columns; } From 74cba1e3e0dae7fe401258f30a97bcfce77f032b Mon Sep 17 00:00:00 2001 From: hopshackle Date: Thu, 25 Jun 2026 15:29:20 +0100 Subject: [PATCH 33/41] Smartened up XII GUI --- .../java/games/XIIScripta/XIIBoardView.java | 267 ++++++++++++++++-- 1 file changed, 243 insertions(+), 24 deletions(-) diff --git a/src/main/java/games/XIIScripta/XIIBoardView.java b/src/main/java/games/XIIScripta/XIIBoardView.java index 550001ebd..4b1b38edd 100644 --- a/src/main/java/games/XIIScripta/XIIBoardView.java +++ b/src/main/java/games/XIIScripta/XIIBoardView.java @@ -15,6 +15,13 @@ public class XIIBoardView extends BGBoardView { protected final int squareSize = 50; protected final int verticalGap = squareSize; // Add vertical gap between rows + // Stone / Roman-board palette + private static final Color STONE_BG = new Color(196, 190, 176); // weathered stone tablet + private static final Color STONE_SQUARE = new Color(221, 216, 204); // lighter inset square + private static final Color STONE_BORDER = new Color(120, 114, 100); // carved grout line + private static final Color GROUP_DIVIDER = new Color(88, 82, 70); // heavy line between groups of six + private static final Color TRAY_STONE = new Color(184, 177, 162); // off-board trays + public XIIBoardView(BGForwardModel model) { super(model); boardWidth = 900; @@ -31,32 +38,31 @@ public void mouseClicked(MouseEvent evt) { int x = evt.getX(); int y = evt.getY(); int col, space = -1; + int vcol = (x - margin) / squareSize; // visual column index from the left margin - // Bar zone (left of middle row) - if (x < margin + squareSize && y > margin + squareSize + verticalGap && y < margin + 2 * squareSize + verticalGap) { + // Bar zone (column 0, left of middle row) + if (vcol == 0 && y > margin + squareSize + verticalGap && y < margin + 2 * squareSize + verticalGap) { space = 0; } // Bearing off zone (right of bottom row) else { boolean onBoard = y < margin + 3 * (squareSize + verticalGap); - if (x > margin + 13 * squareSize && y > margin + 2 * (squareSize + verticalGap) && onBoard) { + if (x >= margin + 14 * squareSize && y > margin + 2 * (squareSize + verticalGap) && onBoard) { space = 37; } - // Board squares - else if (x >= margin + squareSize && x < margin + 13 * squareSize) { + // Board squares (visual columns 1-6 and 8-13; column 7 is the rosette gap) + else if (x >= margin + squareSize && x < margin + 14 * squareSize && vcol != 7) { + col = vcol <= 6 ? vcol : vcol - 1; // logical box column 1..12 // Top row (spaces 13-24, right to left) if (y > margin && y < margin + squareSize) { - col = 12 - ((x - margin) / squareSize); - space = 13 + col; + space = 25 - col; } // Middle row (spaces 1-12, left to right) else if (y > margin + squareSize + verticalGap && y < margin + 2 * squareSize + verticalGap) { - col = (x - margin) / squareSize; space = col; } // Bottom row (spaces 25-36, left to right) else if (y > margin + 2 * (squareSize + verticalGap) && onBoard) { - col = (x - margin) / squareSize; space = 24 + col; } } @@ -116,25 +122,39 @@ public synchronized void update(BGGameState state) { protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2d = (Graphics2D) g; + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - // Board background - g2d.setColor(new Color(222, 184, 135)); + // Board background (weathered stone tablet) + g2d.setColor(STONE_BG); g2d.fillRect(0, 0, boardWidth, boardHeight); - // Draw discs on squares + // Draw all the squares first for (int space = 1; space <= 36; space++) { - int player = piecesPerPoint[0][space] > 0 ? 0 : (piecesPerPoint[1][space] > 0 ? 1 : -1); int[] pos = getSpacePosition(space); drawSquare(g2d, pos[0], pos[1], space); + } + + // Frame each row's two groups of six and mark the divide with a rosette + drawGroupDividers(g2d); + + // Direction-of-travel arrows (under the discs) + drawDirectionArrows(g2d); + + // Draw the discs, centred on each square + for (int space = 1; space <= 36; space++) { + int player = piecesPerPoint[0][space] > 0 ? 0 : (piecesPerPoint[1][space] > 0 ? 1 : -1); if (player == -1) continue; - int numDiscs = piecesPerPoint[player][space]; - drawDiscs(g2d, pos[0], pos[1], numDiscs, player, false); + int[] pos = getSpacePosition(space); + drawDiscs(g2d, pos[0], pos[1], piecesPerPoint[player][space], player, false); } + // Draw the bar (entry) and bear-off trays + drawBarAndBearOff(g2d); + // Draw dice in the center drawDice(g2d, boardWidth / 2, boardHeight * 3 / 4); - // Number the spaces + // Number the spaces (drawn over the discs so they stay legible) g2d.setColor(Color.BLACK); for (int space = 1; space <= 36; space++) { int[] pos = getSpacePosition(space); @@ -179,25 +199,224 @@ private void drawSquare(Graphics2D g2d, int x, int y, int space) { } if (space == firstClick) highlight = true; } - g2d.setColor(highlightRed ? Color.RED : (highlight ? Color.YELLOW : Color.LIGHT_GRAY)); + g2d.setColor(highlightRed ? new Color(196, 92, 78) : (highlight ? new Color(214, 196, 120) : STONE_SQUARE)); g2d.fillRect(x, y, squareSize, squareSize); - g2d.setColor(Color.BLACK); + g2d.setColor(STONE_BORDER); + g2d.setStroke(new BasicStroke(1)); g2d.drawRect(x, y, squareSize, squareSize); } private int[] getSpacePosition(int space) { // Returns [x, y] for the top-left of the square for a given space, with vertical gaps + int midY = margin + squareSize + verticalGap; + int topY = margin; + int botY = margin + 2 * (squareSize + verticalGap); if (space == 0) // bar - return new int[]{margin, margin + squareSize + verticalGap}; - if (space == 37) // bearing off - return new int[]{margin + 13 * squareSize, margin + 2 * (squareSize + verticalGap)}; + return new int[]{margin, midY}; + if (space == 37) // bearing off (right of the rosette-widened bottom row) + return new int[]{columnX(13), botY}; if (space >= 1 && space <= 12) - return new int[]{margin + space * squareSize, margin + squareSize + verticalGap}; + return new int[]{columnX(space), midY}; if (space >= 13 && space <= 24) - return new int[]{margin + (25 - space) * squareSize, margin}; + return new int[]{columnX(25 - space), topY}; if (space >= 25 && space <= 36) - return new int[]{margin + (space - 24) * squareSize, margin + 2 * (squareSize + verticalGap)}; + return new int[]{columnX(space - 24), botY}; return new int[]{0, 0}; } + // x of the left edge of a box, given its logical column (1..12) within a row. The rosette + // occupies its own column between the two groups of six, so columns 7-12 are pushed one + // place to the right to leave a clean 6 - rosette - 6 layout. + private int columnX(int col) { + int visualCol = col <= 6 ? col : col + 1; + return margin + visualCol * squareSize; + } + + // Centre of a square, given its space index + private int[] getSpaceCentre(int space) { + int[] pos = getSpacePosition(space); + return new int[]{pos[0] + squareSize / 2, pos[1] + squareSize / 2}; + } + + /** + * Draw a stack of pieces as overlapping "saucers", centred on the square. The + * {@code x}/{@code yStart} passed in are the top-left of the square. + */ + @Override + protected void drawDiscs(Graphics2D g2d, int x, int yStart, int numDiscs, int player, boolean fromTop) { + int cx = x + squareSize / 2; // horizontal centre of the square + int cy = yStart + (squareSize - 12) / 2; // biased up a little to leave room for the number + drawSaucerPile(g2d, cx, cy, squareSize - 16, numDiscs, player); + } + + /** + * Draw {@code numDiscs} pieces as overlapping "saucers" (flattened ellipses), centred on + * ({@code cx}, {@code cy}) and tightly stacked within {@code maxExtent} pixels vertically. + * Stacks too tall to fit are capped and labelled with the total count. + */ + private void drawSaucerPile(Graphics2D g2d, int cx, int cy, int maxExtent, int numDiscs, int player) { + if (numDiscs <= 0) return; + + int discWidth = squareSize - 16; // wide, saucer-like + int discHeight = 14; // ...and flat + int idealStep = 7; // gap between stacked saucers + + int step = idealStep; + int drawn = numDiscs; + if (numDiscs > 1 && discHeight + (numDiscs - 1) * step > maxExtent) { + step = Math.max(1, (maxExtent - discHeight) / (numDiscs - 1)); + if (step < 4) { // too compressed: cap and show a count instead + step = 4; + drawn = Math.max(1, 1 + (maxExtent - discHeight) / step); + } + } + boolean showCount = drawn < numDiscs; + + int stackHeight = discHeight + (drawn - 1) * step; + int topY = cy - stackHeight / 2; + + Color faceColor = player == 0 ? Color.WHITE : new Color(45, 45, 45); + Color rimColor = player == 0 ? new Color(120, 120, 120) : Color.BLACK; + + // Draw bottom saucer first so higher saucers overlap the ones below + for (int i = 0; i < drawn; i++) { + int dx = cx - discWidth / 2; + int dy = topY + (drawn - 1 - i) * step; + g2d.setColor(faceColor); + g2d.fillOval(dx, dy, discWidth, discHeight); + g2d.setStroke(new BasicStroke(1)); + g2d.setColor(rimColor); + g2d.drawOval(dx, dy, discWidth, discHeight); + // inner ring to give the saucer some depth + g2d.drawOval(dx + discWidth / 6, dy + discHeight / 4, discWidth * 2 / 3, discHeight / 2); + } + + if (showCount) { + String count = String.valueOf(numDiscs); + Font old = g2d.getFont(); + g2d.setFont(old.deriveFont(Font.BOLD, 13f)); + int tw = g2d.getFontMetrics().stringWidth(count); + int ty = topY + (discHeight + g2d.getFontMetrics().getAscent()) / 2 - 1; + g2d.setColor(player == 0 ? Color.BLACK : Color.WHITE); + g2d.drawString(count, cx - tw / 2, ty); + g2d.setFont(old); + } + } + + /** + * Draw the bar (entry) tray at the far left of the middle row and the bear-off tray to the + * right of the bottom row. Each tray shows both players' piles (player 0 above, player 1 + * below) since both can occupy them at the same time. + */ + private void drawBarAndBearOff(Graphics2D g2d) { + int[] bar = getSpacePosition(0); + int[] off = getSpacePosition(37); + drawTray(g2d, bar[0], bar[1], "BAR", piecesPerPoint[0][0], piecesPerPoint[1][0]); + drawTray(g2d, off[0], off[1], "OFF", piecesPerPoint[0][37], piecesPerPoint[1][37]); + } + + private void drawTray(Graphics2D g2d, int x, int y, String label, int p0, int p1) { + // Tray background, slightly darker than a normal square so it reads as "off-board" + g2d.setColor(TRAY_STONE); + g2d.fillRect(x, y, squareSize, squareSize); + g2d.setColor(Color.BLACK); + g2d.setStroke(new BasicStroke(1)); + g2d.drawRect(x, y, squareSize, squareSize); + + int cx = x + squareSize / 2; + int half = squareSize / 2 - 6; + drawSaucerPile(g2d, cx, y + squareSize / 4, half, p0, 0); // player 0 (white), upper half + drawSaucerPile(g2d, cx, y + 3 * squareSize / 4, half, p1, 1); // player 1 (black), lower half + + // Label just above the tray + Font old = g2d.getFont(); + g2d.setFont(old.deriveFont(Font.BOLD, 11f)); + g2d.setColor(new Color(90, 70, 40)); + int tw = g2d.getFontMetrics().stringWidth(label); + g2d.drawString(label, cx - tw / 2, y - 4); + g2d.setFont(old); + } + + // Draws the two connector arrows that show how movement wraps between the rows. + private void drawDirectionArrows(Graphics2D g2d) { + g2d.setColor(new Color(50, 90, 200, 210)); + + int[] topRow = getSpacePosition(13); + int[] midRow = getSpacePosition(1); + + int x12 = getSpaceCentre(12)[0]; + int topCentreY = getSpaceCentre(13)[1]; + int botCentreY = getSpaceCentre(25)[1]; + + // Connector up the right side (space 12 -> 13) + drawArrow(g2d, x12, midRow[1] - 2, x12, topRow[1] + squareSize + 2); + + // Connector down the left side (space 24 -> 25), routed through the left margin so it + // clears the bar tray that sits at the left of the middle row + int leftX = margin / 2; + int square24LeftX = getSpacePosition(24)[0]; + int square25LeftX = getSpacePosition(25)[0]; + drawArrowPath(g2d, + new int[]{square24LeftX, leftX, leftX, square25LeftX}, + new int[]{topCentreY, topCentreY, botCentreY, botCentreY}); + } + + /** + * Frame each row's two groups of six squares and place a Roman-style rosette on the divide, + * so the line of twelve clearly reads as two sixes (as on the carved boards). + */ + private void drawGroupDividers(Graphics2D g2d) { + int leftStart = margin + squareSize; // x of the first square in a row + int rightStart = margin + 8 * squareSize; // x of the seventh square (past the rosette gap) + int rosetteCx = margin + 7 * squareSize + squareSize / 2; // centre of the rosette column + int[] rowTops = {getSpacePosition(13)[1], getSpacePosition(1)[1], getSpacePosition(25)[1]}; + + for (int yTop : rowTops) { + g2d.setColor(GROUP_DIVIDER); + g2d.setStroke(new BasicStroke(3)); + g2d.drawRect(leftStart, yTop, 6 * squareSize, squareSize); // left six + g2d.drawRect(rightStart, yTop, 6 * squareSize, squareSize); // right six + drawRosette(g2d, rosetteCx, yTop + squareSize / 2, squareSize / 2 - 4); + } + } + + // A simple four-pointed Roman rosette, as carved between the groups of six. + private void drawRosette(Graphics2D g2d, int cx, int cy, int r) { + g2d.setColor(new Color(236, 232, 221)); + g2d.fillOval(cx - r, cy - r, 2 * r, 2 * r); + g2d.setColor(GROUP_DIVIDER); + g2d.setStroke(new BasicStroke(2)); + g2d.drawOval(cx - r, cy - r, 2 * r, 2 * r); + + int o = r - 3; // distance to the star's points + int i = Math.max(2, r / 3); // distance to the star's waist + int[] xs = {cx, cx + i, cx + o, cx + i, cx, cx - i, cx - o, cx - i}; + int[] ys = {cy - o, cy - i, cy, cy + i, cy + o, cy + i, cy, cy - i}; + g2d.setColor(new Color(128, 118, 98)); + g2d.fillPolygon(xs, ys, 8); + + g2d.setColor(new Color(236, 232, 221)); + g2d.fillOval(cx - 2, cy - 2, 4, 4); + } + + // Straight arrow from (x1,y1) to (x2,y2) + private void drawArrow(Graphics2D g2d, int x1, int y1, int x2, int y2) { + drawArrowPath(g2d, new int[]{x1, x2}, new int[]{y1, y2}); + } + + // Poly-line arrow with a head on the final segment + private void drawArrowPath(Graphics2D g2d, int[] xs, int[] ys) { + g2d.setStroke(new BasicStroke(4, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + for (int i = 0; i < xs.length - 1; i++) + g2d.drawLine(xs[i], ys[i], xs[i + 1], ys[i + 1]); + int n = xs.length; + double ang = Math.atan2(ys[n - 1] - ys[n - 2], xs[n - 1] - xs[n - 2]); + int head = 12; + int xa = (int) (xs[n - 1] - head * Math.cos(ang - Math.PI / 7)); + int ya = (int) (ys[n - 1] - head * Math.sin(ang - Math.PI / 7)); + int xb = (int) (xs[n - 1] - head * Math.cos(ang + Math.PI / 7)); + int yb = (int) (ys[n - 1] - head * Math.sin(ang + Math.PI / 7)); + g2d.fillPolygon(new int[]{xs[n - 1], xa, xb}, new int[]{ys[n - 1], ya, yb}, 3); + } + } From 6e0f2458d1cc613e1e950180160aa1791f502c23 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Thu, 25 Jun 2026 15:58:02 +0100 Subject: [PATCH 34/41] XII not to crash in Frontend --- src/main/java/games/backgammon/BGGameState.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/games/backgammon/BGGameState.java b/src/main/java/games/backgammon/BGGameState.java index aa4b83b60..d135def7a 100644 --- a/src/main/java/games/backgammon/BGGameState.java +++ b/src/main/java/games/backgammon/BGGameState.java @@ -249,6 +249,8 @@ public int[] getDiceValues() { public int[] getAvailableDiceValues() { // only return values for dice not yet used + if (availableDiceValues == null) + return new int[0]; int[] values = new int[availableDiceValues.length]; int count = 0; for (int i = 0; i < diceUsed.length; i++) { From 9c1817be388985c11dc6269bb07d9c12b8e9ca2f Mon Sep 17 00:00:00 2001 From: hopshackle Date: Thu, 25 Jun 2026 16:39:14 +0100 Subject: [PATCH 35/41] Test details to Claude.md --- CLAUDE.md | 107 ++++++++++++++++++ .../java/games/XIIScripta/XIIBoardView.java | 24 +++- 2 files changed, 125 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fe341b150..499e55827 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,113 @@ keys in the environment. - `data/` — game assets (images, card/board definitions) loaded by some games; shipped into the Docker image. +## Unit testing + +Tests live under `src/test/java/` mirroring the main source tree (e.g. game tests in +`src/test/java/games//`, core tests in `src/test/java/core/`). The framework is **JUnit 4** +with **Mockito 5**; there is no JUnit 5 in the project. + +**Any test written during development should be committed to the repository** unless it is purely +throwaway scaffolding (e.g. a one-off render harness or a `main` used for manual inspection or to check a rendered image). If a +test helped verify a bug fix or a new feature, it belongs permanently in the test suite. + +### Naming + +- Class: `Test` or `Test` (both styles exist; prefer suffix form for new work). +- Methods: descriptive camelCase, no mandatory `test` prefix — `availableActionsExcludeBlockedMoves()` + is clearer than `testAvailableActionsExcludeBlockedMoves()`. + +### Structure + +```java +public class XIIScriptaForwardModelTest { + + BGGameState state; + BGForwardModel fm; + + @Before + public void setup() { + Game game = GameType.XIIScripta.createGameInstance(2); + game.reset(List.of(new RandomPlayer(), new RandomPlayer())); + state = (BGGameState) game.getGameState(); + fm = (BGForwardModel) game.getForwardModel(); + } + + @Test + public void bearOffOnlyWhenAllPiecesHome() { ... } +} +``` + +- Use `@Before` (not `@BeforeEach`) for shared setup. +- Use standard JUnit 4 assertions (`assertEquals`, `assertTrue`, etc.) — no external assertion + libraries are used in this project. +- No shared base test class; each test class is self-contained. + +### Setting up game state + +Two patterns are in use — pick the one that fits: + +1. **Factory + reset** (preferred for integration-level tests): + ```java + Game game = GameType.CantStop.createGameInstance(3, 34, params); + game.reset(players); + BGGameState state = (BGGameState) game.getGameState(); + ``` + +2. **Direct instantiation** (preferred for tightly scoped unit tests): + ```java + DominionGameState state = new DominionGameState(new DominionFGParameters(), 2); + new DominionForwardModel().setup(state); + ``` + +### Setting up game state from a saved JSON file + +For games that support JSON serialisation, load a saved state rather than simulating to reach a +scenario — simulation can land on awkward mid-tick states (e.g. null dice rolls). Edit the JSON +fields to vary conditions rather than forcing values via reflection: + +```java +Game game = GameType.XIIScripta.createGameInstance(2); +game.reset(List.of(new RandomPlayer(), new RandomPlayer())); +BGGameState state = (BGGameState) game.getGameState(); +BGStateJSON.loadFromJSON(state, JSONUtils.loadJSONFile("P0_Tick340.json")); +// Edit e.g. "piecesBorneOff" : [4, 7] in the JSON to test bear-off logic +``` + +For GUI/board-view verification, paint the view to a `BufferedImage` and write a PNG: + +```java +MyBoardView view = new MyBoardView((MyForwardModel) game.getForwardModel()); +view.update(state); +view.setSize(view.getPreferredSize()); +BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); +view.paint(img.getGraphics()); +ImageIO.write(img, "png", new File("render.png")); +``` + +Remove any throwaway render harness files and output PNGs before committing. + +### Running tests + +**Never run the full suite during development** — it includes slow integration tests. Always target +a specific class or method. + +```bash +# 1. Build the classpath once (output to cp.txt, committed to .gitignore) +mvn dependency:build-classpath -Dmdep.outputFile=cp.txt + +# 2. Compile tests +mvn test-compile -Dmaven.test.skip=false + +# 3. Run a single test class +java -cp "target/classes;target/test-classes;$(cat cp.txt)" \ + org.junit.runner.JUnitCore games.cantstop.TestCantStop +``` + +`java` may not be on PATH; use the full JDK path if needed +(e.g. `C:/Users//.jdks/ms-21.0.10/bin/java`). Alternatively, run directly from the IDE +by right-clicking the test class or method. + ## Python `core.PyTAG` + `players/python` expose a Python-facing API (PyTAG) for RL/gym-style use. diff --git a/src/main/java/games/XIIScripta/XIIBoardView.java b/src/main/java/games/XIIScripta/XIIBoardView.java index 4b1b38edd..b6f966cff 100644 --- a/src/main/java/games/XIIScripta/XIIBoardView.java +++ b/src/main/java/games/XIIScripta/XIIBoardView.java @@ -311,15 +311,27 @@ private void drawSaucerPile(Graphics2D g2d, int cx, int cy, int maxExtent, int n private void drawBarAndBearOff(Graphics2D g2d) { int[] bar = getSpacePosition(0); int[] off = getSpacePosition(37); - drawTray(g2d, bar[0], bar[1], "BAR", piecesPerPoint[0][0], piecesPerPoint[1][0]); - drawTray(g2d, off[0], off[1], "OFF", piecesPerPoint[0][37], piecesPerPoint[1][37]); + drawTray(g2d, bar[0], bar[1], "BAR", piecesPerPoint[0][0], piecesPerPoint[1][0], null); + + // Highlight the OFF tray when bearing-off moves are available, matching regular square logic + Color offHighlight = null; + if (firstClick == -1) { + for (MovePiece action : validActions) { + if (action.to == -1) { offHighlight = new Color(214, 196, 120); break; } + } + } else { + int fromGs = guiToGameStateSpace(firstClick); + for (MovePiece action : validActions) { + if (action.from == fromGs && action.to == -1) { offHighlight = new Color(196, 92, 78); break; } + } + } + drawTray(g2d, off[0], off[1], "OFF", piecesPerPoint[0][37], piecesPerPoint[1][37], offHighlight); } - private void drawTray(Graphics2D g2d, int x, int y, String label, int p0, int p1) { - // Tray background, slightly darker than a normal square so it reads as "off-board" - g2d.setColor(TRAY_STONE); + private void drawTray(Graphics2D g2d, int x, int y, String label, int p0, int p1, Color highlight) { + g2d.setColor(highlight != null ? highlight : TRAY_STONE); g2d.fillRect(x, y, squareSize, squareSize); - g2d.setColor(Color.BLACK); + g2d.setColor(STONE_BORDER); g2d.setStroke(new BasicStroke(1)); g2d.drawRect(x, y, squareSize, squareSize); From e40f10057d8ce5c77cbe3d88ed04235e54cf78b6 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Fri, 26 Jun 2026 17:31:59 +0100 Subject: [PATCH 36/41] Fixed bug from JSON change; GameAdviser now copes more cleanly with ROBUST selection policies --- .../evaluation/optimisation/ITPSearchSpace.java | 15 ++------------- .../optimisation/TunableParameters.java | 5 ++--- src/main/java/players/mcts/MCTSPlayer.java | 4 ++++ .../players/observers/AbstractMCTSAdviser.java | 6 ++++++ .../java/evaluation/TunableParametersTest.java | 3 ++- .../java/games/explodingkittens/JSONTest.java | 8 +++++--- 6 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/main/java/evaluation/optimisation/ITPSearchSpace.java b/src/main/java/evaluation/optimisation/ITPSearchSpace.java index 23a2814c1..1400db077 100644 --- a/src/main/java/evaluation/optimisation/ITPSearchSpace.java +++ b/src/main/java/evaluation/optimisation/ITPSearchSpace.java @@ -223,17 +223,10 @@ public int[] settingsFromJSON(JSONObject json) { settings[i] = index; } - // Then we check all the parameters in the JSON file are valid for the ITunableParameters - // If any have values that are not the same as the searchspace values (or do not + // If any have values that are not the same as the searchSpace values (or do not // match the default value for that parameter), then we throw an error // This is recursive as needed over any nested ITunableParameters in the JSON - JSONObject oldJSON = new JSONObject(); - if (itp instanceof TunableParameters tp) { - oldJSON = tp.getRawJSON(); - tp.setRawJSON(json); - // first we set the rawJSON on TunabpleParameters to pick up defaults correctly - } for (Object key : json.keySet()) { String keyName = (String) key; if (keyName.equals("class") || keyName.equals("args") || keyName.equals("budget") || searchDimensions.contains(keyName)) { @@ -243,20 +236,16 @@ public int[] settingsFromJSON(JSONObject json) { // we do not check recursively here (possible future enhancement) if (value instanceof JSONObject) continue; - // slightly awkward...TunableParameters has a rawJSON set of data that should be used to provide local overrides to the + // TunableParameters has a rawJSON set of data that should be used to provide local overrides to the // global defaults specific to the main parameter definition Object defaultValue = itp instanceof TunableParameters tp ? tp.getDefaultOverride(keyName) : itp.getDefaultParameterValue(keyName); if (!JSONUtils.areValuesEqual(value, defaultValue)) { throw new AssertionError("Value " + value + " for parameter " + keyName + " does not match default value " + defaultValue); } } - if (itp instanceof TunableParameters tp) { - tp.setRawJSON(oldJSON); // possibly redundant; but cleaner - } return settings; } - public JSONObject constructAgentJSON(int[] settings) { // we first need to update itp with the specified parameters, and then instantiate setTo(settings); diff --git a/src/main/java/evaluation/optimisation/TunableParameters.java b/src/main/java/evaluation/optimisation/TunableParameters.java index b5fc700a0..89c34d136 100644 --- a/src/main/java/evaluation/optimisation/TunableParameters.java +++ b/src/main/java/evaluation/optimisation/TunableParameters.java @@ -52,7 +52,7 @@ public static void loadFromJSONFile(TunableParameters params, String filename JSONObject rawData = (JSONObject) jsonParser.parse(reader); loadFromJSON(params, rawData); } catch (Exception e) { - throw new AssertionError(e.getClass().toString() + " : " + e.getMessage() + " : problem loading TunableParameters from file " + filename); + throw new AssertionError(e.getClass() + " : " + e.getMessage() + " : problem loading TunableParameters from file " + filename); } } @@ -65,7 +65,6 @@ public static TunableParameters loadFromJSON(TunableParameters params, JSO // Static parameter, load from json and exclude from tuning // THIS IS UNCHECKED - // TODO Add validation once I figure for (String pName : params.staticParameters) { params.currentValues.put(pName, rawData.getOrDefault(pName, params.getDefaultParameterValue(pName))); } @@ -407,7 +406,7 @@ public Map getDefaultParameterValues() { } @Override - public ITunableParameters instanceFromJSON(JSONObject jsonObject) { + public ITunableParameters instanceFromJSON(JSONObject jsonObject) { return JSONUtils.loadClassFromJSON(jsonObject); } diff --git a/src/main/java/players/mcts/MCTSPlayer.java b/src/main/java/players/mcts/MCTSPlayer.java index 65eed88b0..c85ac12d7 100644 --- a/src/main/java/players/mcts/MCTSPlayer.java +++ b/src/main/java/players/mcts/MCTSPlayer.java @@ -321,6 +321,10 @@ public void overrideAction(AbstractAction originalAction, AbstractAction overrid public double getValue(AbstractAction action) { return root.getValue(action); } + // Proportion of visits - this does not take account of nValidVisits (which would be especially important away from root node) + public double getVisitProportion(AbstractAction action) { + return root.actionVisits(action) / (double) root.getVisits(); + } @Override public void finalizePlayer(AbstractGameState state) { diff --git a/src/main/java/players/observers/AbstractMCTSAdviser.java b/src/main/java/players/observers/AbstractMCTSAdviser.java index 20e557b43..16e7c6428 100644 --- a/src/main/java/players/observers/AbstractMCTSAdviser.java +++ b/src/main/java/players/observers/AbstractMCTSAdviser.java @@ -3,6 +3,7 @@ import core.AbstractGameState; import core.AbstractPlayer; import core.actions.AbstractAction; +import players.mcts.MCTSEnums; import players.mcts.MCTSPlayer; public abstract class AbstractMCTSAdviser implements IAdviceFilter { @@ -19,6 +20,11 @@ public boolean provideAdvice(AbstractGameState state, AbstractAction proposedAct if (adviser.player instanceof MCTSPlayer mctsPlayer) { double valueOfProposedAction = mctsPlayer.getValue(proposedAction); double valueOfOurAction = mctsPlayer.getValue(advice); + if (mctsPlayer.getParameters().selectionPolicy == MCTSEnums.SelectionPolicy.ROBUST) { + // use visit count proportion instead + valueOfProposedAction = mctsPlayer.getVisitProportion(proposedAction); + valueOfOurAction = mctsPlayer.getVisitProportion(advice); + } return valueOfOurAction - valueOfProposedAction > adviceThreshold; } else { throw new AssertionError("AbstractMCTSAdviser only supports MCTS players"); diff --git a/src/test/java/evaluation/TunableParametersTest.java b/src/test/java/evaluation/TunableParametersTest.java index 180c8dc27..7698335c6 100644 --- a/src/test/java/evaluation/TunableParametersTest.java +++ b/src/test/java/evaluation/TunableParametersTest.java @@ -259,7 +259,8 @@ public void settingsFromJSONFailsToMatchSearchSpace() { @Test public void settingsFromJSONFailsToMatchDefault() { // If the searchSpace defines a fixed value for a parameter, then we should use this - ITPSearchSpace itp = new ITPSearchSpace<>(params, "src/test/java/evaluation/MCTSSearch_Heuristic.json"); + ITPSearchSpace itp = new ITPSearchSpace<>(params, + "src/test/java/evaluation/MCTSSearch_Heuristic.json"); try { int[] settings = itp.settingsFromJSON("src/test/java/evaluation/MCTSSearch_HeuristicSampleIncorrectII.json"); JSONObject json = itp.constructAgentJSON(settings); diff --git a/src/test/java/games/explodingkittens/JSONTest.java b/src/test/java/games/explodingkittens/JSONTest.java index 0680d6497..4b4293edb 100644 --- a/src/test/java/games/explodingkittens/JSONTest.java +++ b/src/test/java/games/explodingkittens/JSONTest.java @@ -85,11 +85,13 @@ public void testJSONLoadFromFile() { state.setActionInProgress(new NopeableAction(0, new PlayEKCard(SKIP), state)); JSONObject json = state.toJSON(); - JSONUtils.writeJSON(json, "ekTestFile.json"); + JSONUtils.writeJSON(json, "src/ekTestFile.json"); - ExplodingKittensGameState loaded = JSONUtils.loadClassFromFile("ekTestFile.json"); + ExplodingKittensGameState loaded = JSONUtils.loadClassFromFile("src/ekTestFile.json"); assertEquals(state, loaded); - assertTrue(new File("ekTestFile.json").delete()); + // we need to ensure the file root is picked up correctly to delete this + File file = new File("src/ekTestFile.json"); + file.delete(); } } From 9009199b47428a677ed2cf9c40d9c42474471c05 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Sun, 28 Jun 2026 18:07:12 +0100 Subject: [PATCH 37/41] Added ability to render a saved state to png --- src/main/java/core/TAG.java | 7 +- src/main/java/gui/StateRenderer.java | 178 +++++++++++++++++++++++ src/test/java/gui/StateRendererTest.java | 103 +++++++++++++ 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 src/main/java/gui/StateRenderer.java create mode 100644 src/test/java/gui/StateRendererTest.java diff --git a/src/main/java/core/TAG.java b/src/main/java/core/TAG.java index 3d8b42cc3..f848f1970 100644 --- a/src/main/java/core/TAG.java +++ b/src/main/java/core/TAG.java @@ -7,6 +7,7 @@ import evaluation.optimisation.ParameterSearch; import gui.Frontend; import gui.FrontendSimple; +import gui.StateRenderer; import java.util.Arrays; @@ -23,7 +24,8 @@ public enum Entry { FrontEndSimple, ExpertIteration, OneStepDeviations, - SkillLadder; + SkillLadder, + StateRenderer; public static Entry find(String name) { for (Entry e : Entry.values()) { @@ -70,6 +72,9 @@ public static void main(String[] args) { case SkillLadder: SkillLadder.main(remainingArgs); break; + case StateRenderer: + StateRenderer.main(remainingArgs); + break; default: throw new IllegalStateException("Unexpected value: " + entry); } diff --git a/src/main/java/gui/StateRenderer.java b/src/main/java/gui/StateRenderer.java new file mode 100644 index 000000000..9ae120439 --- /dev/null +++ b/src/main/java/gui/StateRenderer.java @@ -0,0 +1,178 @@ +package gui; + +import core.AbstractGameState; +import core.AbstractPlayer; +import core.Game; +import games.GameType; +import players.human.ActionController; +import players.simple.RandomPlayer; +import utilities.JSONUtils; +import utilities.Utils; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Utility that loads a saved game-state JSON file (as produced by the JSON serialisation used by the + * Advisers feature) and renders it to a PNG image, going through the game's real GUI components. + *

+ * It loads the state (see {@code RoundRobinTournament} for the same pattern), builds the game's + * {@link AbstractGUIManager} via {@link GameType#createGUIManager}, updates the view to reflect the + * loaded state, and then paints the relevant Swing component to an offscreen {@link BufferedImage}. + * No window is ever shown and no screenshot is taken — painting is done via {@code printAll}, so the + * output is deterministic and does not require focus or an unoccluded display. + *

+ * Usage: + *

+ *   java -cp ... gui.StateRenderer input=G13/P1_Tick1230.json
+ *   java -cp ... gui.StateRenderer input=G13 output=renders fullPanel=true
+ * 
+ * Arguments (key=value): + *
    + *
  • {@code input} - path to a saved-state JSON file, or a directory of {@code *.json} files (batch mode).
  • + *
  • {@code output} - output PNG path (single file), or output directory (batch mode). Defaults to the + * input path with its extension replaced by {@code .png}.
  • + *
  • {@code game} - {@link GameType} name (default {@code XIIScripta}).
  • + *
  • {@code fullPanel} - {@code true} to render the whole game panel (board + info panel); default {@code false} + * renders the board view only.
  • + *
+ */ +public class StateRenderer { + + public static void main(String[] args) { + String input = Utils.getArg(args, "input", ""); + String output = Utils.getArg(args, "output", ""); + String game = Utils.getArg(args, "game", "XIIScripta"); + boolean fullPanel = Utils.getArg(args, "fullPanel", false); + + if (input.isEmpty()) { + System.out.println("Usage: gui.StateRenderer input= [output=] [game=XIIScripta] [fullPanel=false]"); + return; + } + + GameType gameType = GameType.valueOf(game); + File inputFile = new File(input); + if (!inputFile.exists()) + throw new AssertionError("Input does not exist: " + input); + + if (inputFile.isDirectory()) { + File[] jsonFiles = inputFile.listFiles((dir, name) -> name.toLowerCase().endsWith(".json")); + if (jsonFiles == null || jsonFiles.length == 0) { + System.out.println("No .json files found in directory " + input); + return; + } + File outDir = output.isEmpty() ? inputFile : new File(output); + if (!outDir.exists() && !outDir.mkdirs()) + throw new AssertionError("Could not create output directory: " + outDir); + + int success = 0; + for (File json : jsonFiles) { + File png = new File(outDir, stripExtension(json.getName()) + ".png"); + try { + render(gameType, json, png, fullPanel); + success++; + } catch (Exception e) { + System.err.println("Failed to render " + json.getName() + " : " + e.getMessage()); + } + } + System.out.printf("Rendered %d of %d state files to %s%n", success, jsonFiles.length, outDir); + } else { + File png = output.isEmpty() + ? new File(inputFile.getParentFile(), stripExtension(inputFile.getName()) + ".png") + : new File(output); + render(gameType, inputFile, png, fullPanel); + System.out.println("Rendered " + inputFile + " to " + png); + } + } + + /** + * Loads the state in {@code stateFile} and writes a PNG of it to {@code outputFile}. + * + * @param gameType the game the state belongs to (determines which GUI manager / view is used). + * @param stateFile the saved-state JSON file. + * @param outputFile the PNG to write. + * @param fullPanel if true render the whole game panel; otherwise the board view only. + */ + public static void render(GameType gameType, File stateFile, File outputFile, boolean fullPanel) { + BufferedImage img = renderToImage(gameType, stateFile, fullPanel); + try { + ImageIO.write(img, "png", outputFile); + } catch (Exception e) { + throw new AssertionError("Failed to write PNG " + outputFile + " : " + e); + } + } + + /** + * Loads the state in {@code stateFile} and returns a rendered image of it. + * + * @param fullPanel if true, render the whole game panel (board + info panel); otherwise render only + * the board view (the component placed at {@link BorderLayout#CENTER} by the GUI manager). + */ + public static BufferedImage renderToImage(GameType gameType, File stateFile, boolean fullPanel) { + // 1. Load the saved state (same approach as RoundRobinTournament). + AbstractGameState state = JSONUtils.loadClassFromFile(stateFile.getAbsolutePath()); + + // 2. Wrap it in a Game that owns the matching forward model, then install the loaded state. + List players = new ArrayList<>(); + for (int i = 0; i < state.getNPlayers(); i++) + players.add(new RandomPlayer()); + Game gameInstance = gameType.createGameInstance(state.getNPlayers()); + gameInstance.reset(players); + gameInstance.reset(state); + + // 3. Build the real GUI components for this game. + GamePanel gamePanel = new GamePanel(); + ActionController ac = new ActionController(); + AbstractGUIManager gui = gameType.createGUIManager(gamePanel, gameInstance, ac); + + // 4. Populate the view with the loaded state (showActions = false: this is a static snapshot). + int currentPlayer = state.getCurrentPlayer(); + gui.update(players.get(currentPlayer), state, false); + + // 5. Choose what to capture: the board view only (default) or the whole panel. + Component target = gamePanel; + if (!fullPanel) { + Component centre = boardView(gamePanel); + if (centre != null) target = centre; + } + + // 6. Lay the chosen component out offscreen at its own preferred size (no window is shown) so it + // has valid bounds. Sizing the target directly avoids the BorderLayout CENTER component being + // stretched to fill the parent panel's leftover space. + target.setSize(target.getPreferredSize()); + layoutRecursively(target); + + int w = Math.max(1, target.getWidth()); + int h = Math.max(1, target.getHeight()); + BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + target.printAll(g); + g.dispose(); + return img; + } + + // The board view is placed at BorderLayout.CENTER by the backgammon-family GUI managers. + private static Component boardView(GamePanel gamePanel) { + if (gamePanel.getLayout() instanceof BorderLayout layout) + return layout.getLayoutComponent(BorderLayout.CENTER); + return null; + } + + // Recursively force layout of a component tree without realising a window. + private static void layoutRecursively(Component c) { + c.doLayout(); + if (c instanceof Container container) { + for (Component child : container.getComponents()) + layoutRecursively(child); + } + } + + private static String stripExtension(String name) { + int dot = name.lastIndexOf('.'); + return dot == -1 ? name : name.substring(0, dot); + } +} diff --git a/src/test/java/gui/StateRendererTest.java b/src/test/java/gui/StateRendererTest.java new file mode 100644 index 000000000..b7f960cda --- /dev/null +++ b/src/test/java/gui/StateRendererTest.java @@ -0,0 +1,103 @@ +package gui; + +import games.GameType; +import games.explodingkittens.ExplodingKittensForwardModel; +import games.explodingkittens.ExplodingKittensGameState; +import games.explodingkittens.ExplodingKittensParameters; +import org.json.simple.JSONObject; +import org.junit.Test; +import utilities.JSONUtils; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.*; + +public class StateRendererTest { + + static final File G13_FILE = new File("G13/P1_Tick1230.json"); + + @Test + public void rendersBoardOnlyPng() throws Exception { + assumeStateFileExists(); + BufferedImage img = StateRenderer.renderToImage(GameType.XIIScripta, G13_FILE, false); + // XIIBoardView is 900x500. + assertEquals(900, img.getWidth()); + assertEquals(500, img.getHeight()); + assertTrue("Rendered board should not be a single flat colour", hasMultipleColours(img)); + } + + @Test + public void rendersFullPanelPng() throws Exception { + assumeStateFileExists(); + BufferedImage img = StateRenderer.renderToImage(GameType.XIIScripta, G13_FILE, true); + // Full panel is at least as large as the board view, and taller (info + action panels). + assertTrue(img.getWidth() >= 900); + assertTrue(img.getHeight() > 500); + assertTrue(hasMultipleColours(img)); + } + + @Test + public void writesPngFileToDisk() throws Exception { + assumeStateFileExists(); + Path tmpDir = Files.createTempDirectory("staterender"); + File out = new File(tmpDir.toFile(), "state.png"); + StateRenderer.render(GameType.XIIScripta, G13_FILE, out, false); + assertTrue(out.exists()); + assertTrue("PNG should be non-trivial in size", out.length() > 1000); + BufferedImage reloaded = ImageIO.read(out); + assertNotNull("Output should be a decodable PNG", reloaded); + assertEquals(900, reloaded.getWidth()); + } + + /** + * Renders a second, structurally different game (Exploding Kittens) to confirm StateRenderer is + * game-agnostic. EK uses a completely different GUI layout (player hands on the four edges, a + * BoxLayout of deck/discard piles in the centre) and loads card-image assets from {@code data/}, + * so a successful render exercises a different code path from the backgammon-family board view. + * The state is generated and serialised here rather than relying on a checked-in JSON file. + */ + @Test + public void rendersExplodingKittensFromGeneratedState() throws Exception { + org.junit.Assume.assumeTrue("EK card assets not present", new File("data/explodingkittens").isDirectory()); + + ExplodingKittensGameState state = + new ExplodingKittensGameState(new ExplodingKittensParameters(), 4); + new ExplodingKittensForwardModel().setup(state); + + Path tmpDir = Files.createTempDirectory("staterender-ek"); + File stateFile = new File(tmpDir.toFile(), "ek_state.json"); + JSONObject json = state.toJSON(); + JSONUtils.writeJSON(json, stateFile.getAbsolutePath()); + + // Board-only (the central deck/discard area) and the full panel (info + board + actions). + BufferedImage board = StateRenderer.renderToImage(GameType.ExplodingKittens, stateFile, false); + assertTrue("EK board render should have positive dimensions", board.getWidth() > 0 && board.getHeight() > 0); + assertTrue("EK board should not be a single flat colour", hasMultipleColours(board)); + + BufferedImage full = StateRenderer.renderToImage(GameType.ExplodingKittens, stateFile, true); + assertTrue("Full panel should be at least as wide as the board", full.getWidth() >= board.getWidth()); + assertTrue("Full panel should be taller than the board (info + action panels)", full.getHeight() > board.getHeight()); + assertTrue(hasMultipleColours(full)); + + File out = new File(tmpDir.toFile(), "ek_state.png"); + StateRenderer.render(GameType.ExplodingKittens, stateFile, out, true); + assertTrue("EK PNG should be written and non-trivial", out.exists() && out.length() > 1000); + assertNotNull("EK output should be a decodable PNG", ImageIO.read(out)); + } + + private void assumeStateFileExists() { + org.junit.Assume.assumeTrue("Test state file " + G13_FILE + " not present", G13_FILE.exists()); + } + + private static boolean hasMultipleColours(BufferedImage img) { + int first = img.getRGB(0, 0); + for (int x = 0; x < img.getWidth(); x += 13) + for (int y = 0; y < img.getHeight(); y += 13) + if (img.getRGB(x, y) != first) return true; + return false; + } +} From bc5d0c7770c9025c8973543c6a33e7f7fa564d97 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Mon, 29 Jun 2026 11:59:50 +0100 Subject: [PATCH 38/41] Tweaked javadoc --- src/main/java/gui/StateRenderer.java | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/main/java/gui/StateRenderer.java b/src/main/java/gui/StateRenderer.java index 9ae120439..92889ab2b 100644 --- a/src/main/java/gui/StateRenderer.java +++ b/src/main/java/gui/StateRenderer.java @@ -26,11 +26,6 @@ * No window is ever shown and no screenshot is taken — painting is done via {@code printAll}, so the * output is deterministic and does not require focus or an unoccluded display. *

- * Usage: - *

- *   java -cp ... gui.StateRenderer input=G13/P1_Tick1230.json
- *   java -cp ... gui.StateRenderer input=G13 output=renders fullPanel=true
- * 
* Arguments (key=value): *
    *
  • {@code input} - path to a saved-state JSON file, or a directory of {@code *.json} files (batch mode).
  • @@ -50,7 +45,7 @@ public static void main(String[] args) { boolean fullPanel = Utils.getArg(args, "fullPanel", false); if (input.isEmpty()) { - System.out.println("Usage: gui.StateRenderer input= [output=] [game=XIIScripta] [fullPanel=false]"); + System.out.println("Usage: input= [output=] [game=XIIScripta] [fullPanel=false]"); return; } @@ -113,10 +108,10 @@ public static void render(GameType gameType, File stateFile, File outputFile, bo * the board view (the component placed at {@link BorderLayout#CENTER} by the GUI manager). */ public static BufferedImage renderToImage(GameType gameType, File stateFile, boolean fullPanel) { - // 1. Load the saved state (same approach as RoundRobinTournament). + // 1. Load the saved state AbstractGameState state = JSONUtils.loadClassFromFile(stateFile.getAbsolutePath()); - // 2. Wrap it in a Game that owns the matching forward model, then install the loaded state. + // 2. Wrap it in a Game for the matching forward model, then install the loaded state. List players = new ArrayList<>(); for (int i = 0; i < state.getNPlayers(); i++) players.add(new RandomPlayer()); @@ -129,9 +124,9 @@ public static BufferedImage renderToImage(GameType gameType, File stateFile, boo ActionController ac = new ActionController(); AbstractGUIManager gui = gameType.createGUIManager(gamePanel, gameInstance, ac); - // 4. Populate the view with the loaded state (showActions = false: this is a static snapshot). + // 4. Populate the view with the loaded state int currentPlayer = state.getCurrentPlayer(); - gui.update(players.get(currentPlayer), state, false); + gui.update(players.get(currentPlayer), state, true); // 5. Choose what to capture: the board view only (default) or the whole panel. Component target = gamePanel; From df31d251b258cada8ade82562e10c98c64775562 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Mon, 29 Jun 2026 18:30:53 +0100 Subject: [PATCH 39/41] Fixed columns in GameAdviser report, and added generation of annotated pngs --- .../java/games/XIIScripta/XIIBoardView.java | 3 +- .../gui/AnnotatedScreenshotGenerator.java | 249 ++++++++++++++++++ .../java/players/observers/GameAdviser.java | 6 +- 3 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 src/main/java/gui/AnnotatedScreenshotGenerator.java diff --git a/src/main/java/games/XIIScripta/XIIBoardView.java b/src/main/java/games/XIIScripta/XIIBoardView.java index b6f966cff..f31d5cd1d 100644 --- a/src/main/java/games/XIIScripta/XIIBoardView.java +++ b/src/main/java/games/XIIScripta/XIIBoardView.java @@ -67,7 +67,6 @@ else if (y > margin + 2 * (squareSize + verticalGap) && onBoard) { } } } - // System.out.printf("Clicked at (%d, %d), mapped to point %d%n", x, y, space); if (evt.getButton() == MouseEvent.BUTTON1) { if (firstClick == -1) @@ -158,7 +157,7 @@ protected void paintComponent(Graphics g) { g2d.setColor(Color.BLACK); for (int space = 1; space <= 36; space++) { int[] pos = getSpacePosition(space); - String text = String.valueOf(space); + String text = String.valueOf(37-space); int tx = pos[0] + squareSize / 2 - g.getFontMetrics().stringWidth(text) / 2; int ty = pos[1] + squareSize - 5; g2d.drawString(text, tx, ty); diff --git a/src/main/java/gui/AnnotatedScreenshotGenerator.java b/src/main/java/gui/AnnotatedScreenshotGenerator.java new file mode 100644 index 000000000..102adcd59 --- /dev/null +++ b/src/main/java/gui/AnnotatedScreenshotGenerator.java @@ -0,0 +1,249 @@ +package gui; + +import games.GameType; +import utilities.Utils; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.*; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Produces a set of annotated screenshots for a directory of saved game states from an Advisers run. + *

    + * The input directory (e.g. {@code .../Player_A_10}) is expected to contain: + *

      + *
    • {@code SavedStates//G/P_Tick.json} - the saved states to render;
    • + *
    • {@code Adviser.txt} - one tab-separated row per saved state, linked by (PlayerID, GameID, Tick);
    • + *
    • {@code ACTION_CHOSEN.csv} - comma-separated action log covering all ticks (not just saved states).
    • + *
    + * For each saved state this writes, alongside the JSON file, a {@code P_Tick.png} render + * (via {@link StateRenderer#renderToImage}) annotated with the agent's and adviser's chosen actions taken + * from the Adviser file. For each game it also writes, into that game's {@code G} directory, a copy + * of the Adviser file and a copy of the ACTION_CHOSEN file filtered to that game's rows. + *

    + * Arguments (key=value): + *

      + *
    • {@code input} - the root data directory (required).
    • + *
    • {@code game} - {@link GameType} name (default {@code XIIScripta}).
    • + *
    • {@code fullPanel} - {@code true} to render the whole game panel; default {@code false} renders the board only.
    • + *
    • {@code annotate} - {@code true} (default) to draw the agent/adviser actions onto the PNG.
    • + *
    + */ +public class AnnotatedScreenshotGenerator { + + // P_Tick.json + private static final Pattern STATE_FILE = Pattern.compile("P(\\d+)_Tick(\\d+)\\.json", Pattern.CASE_INSENSITIVE); + // G directory + private static final Pattern GAME_DIR = Pattern.compile("G(\\d+)"); + + // Column indices in Adviser.txt (tab-separated) + private static final int ADV_PLAYER_ID = 0, ADV_AGENT_ACTION = 2, ADV_AGENT_VALUE = 3, + ADV_ADVISER_ACTION = 4, ADV_ADVISER_VALUE = 5, ADV_GAME_ID = 6, ADV_TICK = 9; + // Column index of GameID in ACTION_CHOSEN.csv + private static final int AC_GAME_ID = 7; + + public static void main(String[] args) { + String input = Utils.getArg(args, "input", ""); + String game = Utils.getArg(args, "game", "XIIScripta"); + boolean fullPanel = Utils.getArg(args, "fullPanel", false); + boolean annotate = Utils.getArg(args, "annotate", true); + + if (input.isEmpty()) { + System.out.println("Usage: input= [game=XIIScripta] [fullPanel=false] [annotate=true]"); + return; + } + + GameType gameType = GameType.valueOf(game); + File root = new File(input); + if (!root.isDirectory()) + throw new AssertionError("Input directory does not exist: " + input); + + File savedStatesRoot = new File(new File(root, "SavedStates"), game); + if (!savedStatesRoot.isDirectory()) + throw new AssertionError("No SavedStates/" + game + " directory under " + input); + + // 1. Parse the Adviser file, keyed by (PlayerID, GameID, Tick). + File adviserFile = findAdviserFile(root); + Map adviserRows = new HashMap<>(); + String adviserHeader = null; + List adviserAll = new ArrayList<>(); + if (adviserFile != null) { + List lines = readLines(adviserFile); + if (!lines.isEmpty()) { + adviserHeader = lines.get(0); + for (int i = 1; i < lines.size(); i++) { + if (lines.get(i).isBlank()) continue; + String[] f = lines.get(i).split("\t", -1); + adviserAll.add(f); + adviserRows.put(key(f[ADV_PLAYER_ID], f[ADV_GAME_ID], f[ADV_TICK]), f); + } + } + } else { + System.out.println("Warning: no *Adviser.txt found in " + input + " - PNGs will not be annotated."); + } + + // 2. Walk the saved states, render (+annotate) each one, and collect the games seen. + File[] gameDirs = savedStatesRoot.listFiles(File::isDirectory); + if (gameDirs == null) gameDirs = new File[0]; + Map gameDirById = new HashMap<>(); + int rendered = 0, total = 0; + for (File gameDir : gameDirs) { + Matcher gm = GAME_DIR.matcher(gameDir.getName()); + if (!gm.matches()) continue; + String gameId = gm.group(1); + gameDirById.put(gameId, gameDir); + + File[] jsons = gameDir.listFiles((d, n) -> STATE_FILE.matcher(n).matches()); + if (jsons == null) continue; + for (File json : jsons) { + total++; + Matcher sm = STATE_FILE.matcher(json.getName()); + if (!sm.matches()) continue; + String playerId = sm.group(1); + String tick = sm.group(2); + try { + BufferedImage img = StateRenderer.renderToImage(gameType, json, fullPanel); + String[] adv = adviserRows.get(key(playerId, gameId, tick)); + if (annotate && adv != null) img = annotate(img, adv); + File png = new File(gameDir, stripExtension(json.getName()) + ".png"); + ImageIO.write(img, "png", png); + rendered++; + } catch (Exception e) { + System.err.println("Failed to render " + json.getName() + " : " + e.getMessage()); + } + } + } + System.out.printf("Rendered %d of %d saved states.%n", rendered, total); + + // 3a. Per-game copy of the Adviser file, filtered to that game's rows. + if (adviserFile != null && adviserHeader != null) { + for (Map.Entry e : gameDirById.entrySet()) { + String gameId = e.getKey(); + List out = new ArrayList<>(); + out.add(adviserHeader); + for (String[] f : adviserAll) + if (f[ADV_GAME_ID].equals(gameId)) out.add(String.join("\t", f)); + writeLines(new File(e.getValue(), adviserFile.getName()), out); + } + } + + // 3b. Per-game copy of ACTION_CHOSEN.csv, filtered to that game's rows in a single streaming pass. + File actionChosen = new File(root, "ACTION_CHOSEN.csv"); + if (actionChosen.isFile()) { + filterActionChosen(actionChosen, gameDirById); + } else { + System.out.println("Warning: no ACTION_CHOSEN.csv found in " + input); + } + } + + /** Streams ACTION_CHOSEN.csv once, writing each relevant row to the matching game directory's copy. */ + private static void filterActionChosen(File actionChosen, Map gameDirById) { + Map writers = new HashMap<>(); + try (BufferedReader reader = Files.newBufferedReader(actionChosen.toPath(), StandardCharsets.UTF_8)) { + String header = reader.readLine(); + if (header == null) return; + for (Map.Entry e : gameDirById.entrySet()) { + BufferedWriter w = Files.newBufferedWriter(new File(e.getValue(), actionChosen.getName()).toPath(), + StandardCharsets.UTF_8); + w.write(header); + w.newLine(); + writers.put(e.getKey(), w); + } + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) continue; + String[] f = line.split(",", -1); + if (f.length <= AC_GAME_ID) continue; + BufferedWriter w = writers.get(f[AC_GAME_ID]); + if (w != null) { + w.write(line); + w.newLine(); + } + } + } catch (IOException ex) { + throw new AssertionError("Failed reading " + actionChosen + " : " + ex.getMessage()); + } finally { + for (BufferedWriter w : writers.values()) { + try { w.close(); } catch (IOException ignored) { } + } + } + } + + /** Adds a header strip above the rendered board carrying the agent's and adviser's chosen actions. */ + private static BufferedImage annotate(BufferedImage board, String[] adv) { + String agentLine = "Agent: " + adv[ADV_AGENT_ACTION] + " (" + adv[ADV_AGENT_VALUE] + ")"; + String adviserLine = "Adviser: " + adv[ADV_ADVISER_ACTION] + " (" + adv[ADV_ADVISER_VALUE] + ")"; + + Font font = new Font(Font.SANS_SERIF, Font.BOLD, 16); + int pad = 8, lineGap = 4; + // Measure with a scratch graphics context. + BufferedImage scratch = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + Graphics2D sg = scratch.createGraphics(); + sg.setFont(font); + FontMetrics fm = sg.getFontMetrics(); + int lineH = fm.getHeight(); + int textW = Math.max(fm.stringWidth(agentLine), fm.stringWidth(adviserLine)); + sg.dispose(); + + int stripH = pad * 2 + lineH * 2 + lineGap; + int w = Math.max(board.getWidth(), textW + pad * 2); + int h = board.getHeight() + stripH; + + BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + Graphics2D g = out.createGraphics(); + g.setColor(Color.WHITE); + g.fillRect(0, 0, w, h); + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g.setFont(font); + + int baseline = pad + fm.getAscent(); + g.setColor(new Color(0x10, 0x10, 0x10)); + g.drawString(agentLine, pad, baseline); + g.setColor(new Color(0x00, 0x66, 0x00)); + g.drawString(adviserLine, pad, baseline + lineH + lineGap); + + // Separator line, then the board below the strip. + g.setColor(Color.LIGHT_GRAY); + g.drawLine(0, stripH - 1, w, stripH - 1); + g.drawImage(board, 0, stripH, null); + g.dispose(); + return out; + } + + private static File findAdviserFile(File root) { + File[] matches = root.listFiles((d, n) -> n.endsWith("Adviser.txt")); + return (matches != null && matches.length > 0) ? matches[0] : null; + } + + private static String key(String playerId, String gameId, String tick) { + return playerId + "|" + gameId + "|" + tick; + } + + private static List readLines(File f) { + try { + return Files.readAllLines(f.toPath(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new AssertionError("Failed reading " + f + " : " + e.getMessage()); + } + } + + private static void writeLines(File f, List lines) { + try { + Files.write(f.toPath(), lines, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new AssertionError("Failed writing " + f + " : " + e.getMessage()); + } + } + + private static String stripExtension(String name) { + int dot = name.lastIndexOf('.'); + return dot == -1 ? name : name.substring(0, dot); + } +} diff --git a/src/main/java/players/observers/GameAdviser.java b/src/main/java/players/observers/GameAdviser.java index 8fe581ea6..9bc6a565d 100644 --- a/src/main/java/players/observers/GameAdviser.java +++ b/src/main/java/players/observers/GameAdviser.java @@ -115,19 +115,19 @@ public Game getGame() { return game; } - protected void logIntervention(Event event, AbstractAction action, AbstractPlayer actingPlayer) { + protected void logIntervention(Event event, AbstractAction advisedAction, AbstractPlayer actingPlayer) { if (writer == null){ setupWriter(); if (writer == null) return; } double agentValue = player instanceof MCTSPlayer mcts ? mcts.getValue(event.action) : 0.0; - double adviserValue = player instanceof MCTSPlayer mcts ? mcts.getValue(action) : 0.0; + double adviserValue = player instanceof MCTSPlayer mcts ? mcts.getValue(advisedAction) : 0.0; try { writer.write(String.format("%s\t%s\t%s\t%.3g\t%s\t%.3g\t%d\t%d\t%d\t%d\n", event.playerID, actingPlayer.toString(), - action.getString(event.state), agentValue, event.action.getString(event.state), adviserValue, + event.action.getString(event.state), agentValue, advisedAction.getString(event.state), adviserValue, event.state.getGameID(), event.state.getTurnCounter(), event.state.getRoundCounter(), event.state.getGameTick())); } catch (IOException e) { throw new RuntimeException(e); From ff85400f9d8727115826de1172a4a4503d78674a Mon Sep 17 00:00:00 2001 From: hopshackle Date: Tue, 30 Jun 2026 11:45:40 +0100 Subject: [PATCH 40/41] Added extra data to GameAdviser Reporting --- json/listeners/PlayerAdviserListener.json | 11 +++++++++++ json/listeners/UnderdogAdviserListener.json | 11 +++++++++++ src/main/java/players/mcts/MCTSPlayer.java | 6 ++++++ src/main/java/players/observers/GameAdviser.java | 14 ++++++++++---- 4 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 json/listeners/PlayerAdviserListener.json create mode 100644 json/listeners/UnderdogAdviserListener.json diff --git a/json/listeners/PlayerAdviserListener.json b/json/listeners/PlayerAdviserListener.json new file mode 100644 index 000000000..31d3eb482 --- /dev/null +++ b/json/listeners/PlayerAdviserListener.json @@ -0,0 +1,11 @@ +{ + "class" : "players.observers.GameAdviser", + "args" : [ + "AdviserJSON/3D_34_Adviser.json", + { + "class" : "players.observers.PlayerNameAdviser", + "args" : ["Player_A", 0.05] + }, + "Player_A_Adviser.txt" + ] +} \ No newline at end of file diff --git a/json/listeners/UnderdogAdviserListener.json b/json/listeners/UnderdogAdviserListener.json new file mode 100644 index 000000000..18ede961a --- /dev/null +++ b/json/listeners/UnderdogAdviserListener.json @@ -0,0 +1,11 @@ +{ + "class" : "players.observers.GameAdviser", + "args" : [ + "AdviserJSON/3D_34_Adviser.json", + { + "class" : "players.observers.UnderdogAdviser", + "args" : ["AdviserJSON/3D_34_StateHeuristic.json", 0.00, 0.05] + }, + "Underdog_Adviser.txt" + ] +} \ No newline at end of file diff --git a/src/main/java/players/mcts/MCTSPlayer.java b/src/main/java/players/mcts/MCTSPlayer.java index c85ac12d7..7c6397d2e 100644 --- a/src/main/java/players/mcts/MCTSPlayer.java +++ b/src/main/java/players/mcts/MCTSPlayer.java @@ -325,6 +325,12 @@ public double getValue(AbstractAction action) { public double getVisitProportion(AbstractAction action) { return root.actionVisits(action) / (double) root.getVisits(); } + public int getVisits(AbstractAction action) { + return root.actionVisits(action); + } + public int getRootVisits() { + return root.getVisits(); + } @Override public void finalizePlayer(AbstractGameState state) { diff --git a/src/main/java/players/observers/GameAdviser.java b/src/main/java/players/observers/GameAdviser.java index 9bc6a565d..15adbb91b 100644 --- a/src/main/java/players/observers/GameAdviser.java +++ b/src/main/java/players/observers/GameAdviser.java @@ -53,7 +53,8 @@ private void setupWriter() { writer = new FileWriter(this.fileName, true); File file = new File(this.fileName); if (file.length() == 0) { - writer.write("PlayerID\tAgentName\tAgentAction\tAgentValue\tAdviserAction\tAdviserValue\tGameID\tTurn\tRound\tTick\n"); + writer.write("PlayerID\tAgentName\tAgentAction\tAgentValue\tAgentVisits" + + "\tAdviserAction\tAdviserValue\tAdviserVisits\tTotalVisits\tGameID\tTurn\tRound\tTick\n"); } } catch (IOException e) { throw new RuntimeException(e); @@ -116,18 +117,23 @@ public Game getGame() { } protected void logIntervention(Event event, AbstractAction advisedAction, AbstractPlayer actingPlayer) { - if (writer == null){ + if (writer == null) { setupWriter(); if (writer == null) return; } double agentValue = player instanceof MCTSPlayer mcts ? mcts.getValue(event.action) : 0.0; + int agentVisits = player instanceof MCTSPlayer mcts ? mcts.getVisits(event.action) : 0; double adviserValue = player instanceof MCTSPlayer mcts ? mcts.getValue(advisedAction) : 0.0; + int adviserVisits = player instanceof MCTSPlayer mcts ? mcts.getVisits(advisedAction) : 0; + int totalVisits = player instanceof MCTSPlayer mcts ? mcts.getRootVisits() : 0; try { - writer.write(String.format("%s\t%s\t%s\t%.3g\t%s\t%.3g\t%d\t%d\t%d\t%d\n", + writer.write(String.format("%s\t%s\t%s\t%.3g\t%d\t%s\t%.3g\t%d\t%d\t%d\t%d\t%d\t%d\n", event.playerID, actingPlayer.toString(), - event.action.getString(event.state), agentValue, advisedAction.getString(event.state), adviserValue, + event.action.getString(event.state), agentValue, agentVisits, + advisedAction.getString(event.state), adviserValue, adviserVisits, + totalVisits, event.state.getGameID(), event.state.getTurnCounter(), event.state.getRoundCounter(), event.state.getGameTick())); } catch (IOException e) { throw new RuntimeException(e); From b0519f14e53d71cd5750ea4cede0ef99562de289 Mon Sep 17 00:00:00 2001 From: hopshackle Date: Tue, 30 Jun 2026 19:29:54 +0100 Subject: [PATCH 41/41] MCTSDecisionReporter --- json/listeners/MCTSDecisionRecorder.json | 4 + .../games/gofish/gui/GoFishPlayerView.java | 2 +- .../loveletter/gui/LoveLetterGUIManager.java | 2 +- .../loveletter/gui/LoveLetterPlayerView.java | 5 +- .../games/seasaltpaper/gui/SSPGUIManager.java | 2 +- .../games/seasaltpaper/gui/SSPPlayerView.java | 8 +- .../java/games/sushigo/gui/SGPlayerView.java | 5 +- .../gui/AnnotatedScreenshotGenerator.java | 40 +- src/main/java/gui/StateRenderer.java | 62 +++ src/main/java/gui/views/CardView.java | 1 + src/main/java/players/mcts/MCTSPlayer.java | 3 + .../java/players/mcts/SingleTreeNode.java | 22 ++ .../observers/MCTSDecisionRecorder.java | 361 ++++++++++++++++++ .../players/observers/GameAdviserTest.java | 8 +- .../observers/MCTSDecisionRecorderTest.java | 254 ++++++++++++ 15 files changed, 729 insertions(+), 50 deletions(-) create mode 100644 json/listeners/MCTSDecisionRecorder.json create mode 100644 src/main/java/players/observers/MCTSDecisionRecorder.java create mode 100644 src/test/java/players/observers/MCTSDecisionRecorderTest.java diff --git a/json/listeners/MCTSDecisionRecorder.json b/json/listeners/MCTSDecisionRecorder.json new file mode 100644 index 000000000..28e0a48c0 --- /dev/null +++ b/json/listeners/MCTSDecisionRecorder.json @@ -0,0 +1,4 @@ +{ + "class" : "players.observers.MCTSDecisionRecorder", + "args" : [ 30, true ] +} diff --git a/src/main/java/games/gofish/gui/GoFishPlayerView.java b/src/main/java/games/gofish/gui/GoFishPlayerView.java index 06fc8370d..592e83dd9 100644 --- a/src/main/java/games/gofish/gui/GoFishPlayerView.java +++ b/src/main/java/games/gofish/gui/GoFishPlayerView.java @@ -25,7 +25,7 @@ public GoFishPlayerView(Deck hand, int playerId, Set humanI // Player hand this.playerHandView = new GoFishDeckView( - playerId, + humanIds.stream().toList().getFirst(), hand, true, null, diff --git a/src/main/java/games/loveletter/gui/LoveLetterGUIManager.java b/src/main/java/games/loveletter/gui/LoveLetterGUIManager.java index 60e19dc6e..1312d85a1 100644 --- a/src/main/java/games/loveletter/gui/LoveLetterGUIManager.java +++ b/src/main/java/games/loveletter/gui/LoveLetterGUIManager.java @@ -319,7 +319,7 @@ protected void _update(AbstractPlayer player, AbstractGameState gameState) { // Update decks and visibility llgs = (LoveLetterGameState)gameState.copy(); for (int i = 0; i < gameState.getNPlayers(); i++) { - boolean front = i == gameState.getCurrentPlayer() && gameState.getCoreGameParameters().alwaysDisplayCurrentPlayer + boolean front = (i == gameState.getCurrentPlayer() && gameState.getCoreGameParameters().alwaysDisplayCurrentPlayer) || humanPlayerIds.contains(i) || gameState.getCoreGameParameters().alwaysDisplayFullObservable; playerHands[i].update(llgs, front); diff --git a/src/main/java/games/loveletter/gui/LoveLetterPlayerView.java b/src/main/java/games/loveletter/gui/LoveLetterPlayerView.java index 3ccc98cc4..c7a19bdfa 100644 --- a/src/main/java/games/loveletter/gui/LoveLetterPlayerView.java +++ b/src/main/java/games/loveletter/gui/LoveLetterPlayerView.java @@ -33,9 +33,10 @@ public class LoveLetterPlayerView extends JPanel { public LoveLetterPlayerView(Deck hand, Deck discard, int playerId, Set humanId, String dataPath) { JLabel label1 = new JLabel("Player hand:"); JLabel label2 = new JLabel("Discards:"); - handCards = new LoveLetterDeckView(playerId, hand, false, dataPath, + int human = humanId.stream().findFirst().orElse(-1); + handCards = new LoveLetterDeckView(human, hand, false, dataPath, new Rectangle(0, 0, playerAreaWidth / 2 - border * 2, llCardHeight)); - discardCards = new LoveLetterDeckView(playerId, discard, true, dataPath, + discardCards = new LoveLetterDeckView(human, discard, true, dataPath, new Rectangle(0, 0, playerAreaWidth / 2 - border * 2, llCardHeight)); JPanel wrap = new JPanel(); wrap.setLayout(new BoxLayout(wrap, BoxLayout.Y_AXIS)); diff --git a/src/main/java/games/seasaltpaper/gui/SSPGUIManager.java b/src/main/java/games/seasaltpaper/gui/SSPGUIManager.java index fefa6aba2..5ba7efb0e 100644 --- a/src/main/java/games/seasaltpaper/gui/SSPGUIManager.java +++ b/src/main/java/games/seasaltpaper/gui/SSPGUIManager.java @@ -45,7 +45,7 @@ public SSPGUIManager(GamePanel parent, Game game, ActionController ac, Set playerHand = sspgs.getPlayerHands().get(i); Deck playerDiscard = sspgs.getPlayerDiscards().get(i); - SSPPlayerView playerView = new SSPPlayerView(sspgs, playerHand, playerDiscard, i, dataPath); + SSPPlayerView playerView = new SSPPlayerView(sspgs, playerHand, playerDiscard, i, dataPath, human); playerViews[i] = playerView; playerArea.addTab("Player " + i, playerView); diff --git a/src/main/java/games/seasaltpaper/gui/SSPPlayerView.java b/src/main/java/games/seasaltpaper/gui/SSPPlayerView.java index 3d12b5e2c..60af1e547 100644 --- a/src/main/java/games/seasaltpaper/gui/SSPPlayerView.java +++ b/src/main/java/games/seasaltpaper/gui/SSPPlayerView.java @@ -6,6 +6,7 @@ import javax.swing.*; import java.awt.*; +import java.util.Set; import static games.seasaltpaper.gui.SSPGUIManager.playerAreaHeight; import static games.seasaltpaper.gui.SSPGUIManager.playerAreaWidth; @@ -25,15 +26,16 @@ public class SSPPlayerView extends JPanel { SeaSaltPaperGameState gs; - public SSPPlayerView(SeaSaltPaperGameState gameState, Deck playerHand, Deck playerDiscard, int playerId, String dataPath) + public SSPPlayerView(SeaSaltPaperGameState gameState, Deck playerHand, Deck playerDiscard, int playerId, String dataPath, Set human) { this.gs = gameState; this.width = playerAreaWidth + border*20; this.height = playerAreaHeight*2 + borderBottom + border + borderBottom; this.playerId = playerId; - this.playerHandView = new SSPDeckView(playerId, playerHand, true, dataPath, new Rectangle(border, border, playerAreaWidth, playerAreaHeight)); // todo only visible if player is human or always fully observable - this.playerDiscardView = new SSPDeckView(playerId, playerDiscard, true, dataPath, new Rectangle(border, border, playerAreaWidth, playerAreaHeight)); + int humanId = human.stream().toList().getFirst(); + this.playerHandView = new SSPDeckView(humanId, playerHand, true, dataPath, new Rectangle(border, border, playerAreaWidth, playerAreaHeight)); // todo only visible if player is human or always fully observable + this.playerDiscardView = new SSPDeckView(humanId, playerDiscard, true, dataPath, new Rectangle(border, border, playerAreaWidth, playerAreaHeight)); this.pointsText = new JLabel(0 + " points"); this.pointsText.setOpaque(false); diff --git a/src/main/java/games/sushigo/gui/SGPlayerView.java b/src/main/java/games/sushigo/gui/SGPlayerView.java index 16134ae9b..5166ca4a8 100644 --- a/src/main/java/games/sushigo/gui/SGPlayerView.java +++ b/src/main/java/games/sushigo/gui/SGPlayerView.java @@ -33,8 +33,9 @@ public SGPlayerView(Deck deck, Deck playDeck, int playerId, Set< // this.width = playerAreaWidth + border*20; // this.height = playerAreaHeight + border + borderBottom; this.playerId = playerId; - this.playerHandView = new SGDeckView(playerId, deck, true, dataPath, new Rectangle(border, border, playerAreaWidth-50, playerAreaHeight)); - this.playedCardsView = new SGDeckView(playerId, playDeck, true, dataPath, new Rectangle(border, border, playerAreaWidth-50, playerAreaHeight)); + int human = humanId.stream().toList().getFirst(); + this.playerHandView = new SGDeckView(human, deck, true, dataPath, new Rectangle(border, border, playerAreaWidth-50, playerAreaHeight)); + this.playedCardsView = new SGDeckView(human, playDeck, true, dataPath, new Rectangle(border, border, playerAreaWidth-50, playerAreaHeight)); this.pointsText = new JLabel(0 + " points"); this.setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); diff --git a/src/main/java/gui/AnnotatedScreenshotGenerator.java b/src/main/java/gui/AnnotatedScreenshotGenerator.java index 102adcd59..767d25f67 100644 --- a/src/main/java/gui/AnnotatedScreenshotGenerator.java +++ b/src/main/java/gui/AnnotatedScreenshotGenerator.java @@ -176,45 +176,13 @@ private static void filterActionChosen(File actionChosen, Map game } } - /** Adds a header strip above the rendered board carrying the agent's and adviser's chosen actions. */ + /** Adds the agent's and adviser's chosen actions in a strip below the rendered board. */ private static BufferedImage annotate(BufferedImage board, String[] adv) { String agentLine = "Agent: " + adv[ADV_AGENT_ACTION] + " (" + adv[ADV_AGENT_VALUE] + ")"; String adviserLine = "Adviser: " + adv[ADV_ADVISER_ACTION] + " (" + adv[ADV_ADVISER_VALUE] + ")"; - - Font font = new Font(Font.SANS_SERIF, Font.BOLD, 16); - int pad = 8, lineGap = 4; - // Measure with a scratch graphics context. - BufferedImage scratch = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); - Graphics2D sg = scratch.createGraphics(); - sg.setFont(font); - FontMetrics fm = sg.getFontMetrics(); - int lineH = fm.getHeight(); - int textW = Math.max(fm.stringWidth(agentLine), fm.stringWidth(adviserLine)); - sg.dispose(); - - int stripH = pad * 2 + lineH * 2 + lineGap; - int w = Math.max(board.getWidth(), textW + pad * 2); - int h = board.getHeight() + stripH; - - BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); - Graphics2D g = out.createGraphics(); - g.setColor(Color.WHITE); - g.fillRect(0, 0, w, h); - g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); - g.setFont(font); - - int baseline = pad + fm.getAscent(); - g.setColor(new Color(0x10, 0x10, 0x10)); - g.drawString(agentLine, pad, baseline); - g.setColor(new Color(0x00, 0x66, 0x00)); - g.drawString(adviserLine, pad, baseline + lineH + lineGap); - - // Separator line, then the board below the strip. - g.setColor(Color.LIGHT_GRAY); - g.drawLine(0, stripH - 1, w, stripH - 1); - g.drawImage(board, 0, stripH, null); - g.dispose(); - return out; + return StateRenderer.withTextBelow(board, + List.of(agentLine, adviserLine), + List.of(new Color(0x10, 0x10, 0x10), new Color(0x00, 0x66, 0x00))); } private static File findAdviserFile(File root) { diff --git a/src/main/java/gui/StateRenderer.java b/src/main/java/gui/StateRenderer.java index 92889ab2b..0139b171b 100644 --- a/src/main/java/gui/StateRenderer.java +++ b/src/main/java/gui/StateRenderer.java @@ -110,7 +110,18 @@ public static void render(GameType gameType, File stateFile, File outputFile, bo public static BufferedImage renderToImage(GameType gameType, File stateFile, boolean fullPanel) { // 1. Load the saved state AbstractGameState state = JSONUtils.loadClassFromFile(stateFile.getAbsolutePath()); + return renderToImage(gameType, state, fullPanel); + } + /** + * Renders a live (in-memory) {@link AbstractGameState} to an image, going through the game's real + * GUI components. This is the painting half of {@link #renderToImage(GameType, File, boolean)} and + * is used by observers that want to render a state they already hold (rather than load from file). + * + * @param fullPanel if true, render the whole game panel (board + info panel); otherwise render only + * the board view (the component placed at {@link BorderLayout#CENTER} by the GUI manager). + */ + public static BufferedImage renderToImage(GameType gameType, AbstractGameState state, boolean fullPanel) { // 2. Wrap it in a Game for the matching forward model, then install the loaded state. List players = new ArrayList<>(); for (int i = 0; i < state.getNPlayers(); i++) @@ -145,11 +156,62 @@ public static BufferedImage renderToImage(GameType gameType, File stateFile, boo int h = Math.max(1, target.getHeight()); BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); Graphics2D g = img.createGraphics(); + // A TYPE_INT_RGB image initialises to black; fill it white first so any region of the panel that + // is not covered by an opaque component (e.g. empty action/history areas in full-panel mode) shows + // as white rather than a black band. + g.setColor(Color.WHITE); + g.fillRect(0, 0, w, h); target.printAll(g); g.dispose(); return img; } + /** + * Returns a new image consisting of {@code image} with the given lines of text drawn in a white strip + * below it. Used to annotate rendered states with extra information (e.g. the chosen action, or + * the top actions an agent considered). If {@code colors} is non-null it supplies a colour per line + * (shorter lists fall back to a dark default). + */ + public static BufferedImage withTextBelow(BufferedImage image, List lines, List colors) { + Font font = new Font(Font.SANS_SERIF, Font.BOLD, 16); + int pad = 8, lineGap = 4; + Color defaultColor = new Color(0x10, 0x10, 0x10); + + // Measure with a scratch graphics context. + BufferedImage scratch = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + Graphics2D sg = scratch.createGraphics(); + sg.setFont(font); + FontMetrics fm = sg.getFontMetrics(); + int lineH = fm.getHeight(); + int textW = lines.stream().mapToInt(fm::stringWidth).max().orElse(0); + sg.dispose(); + + int stripH = pad * 2 + lineH * lines.size() + lineGap * Math.max(0, lines.size() - 1); + int w = Math.max(image.getWidth(), textW + pad * 2); + int h = image.getHeight() + stripH; + + BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + Graphics2D g = out.createGraphics(); + g.setColor(Color.WHITE); + g.fillRect(0, 0, w, h); + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g.setFont(font); + + // The image first, then a separator, then the text below it. + g.drawImage(image, 0, 0, null); + g.setColor(Color.LIGHT_GRAY); + g.drawLine(0, image.getHeight(), w, image.getHeight()); + + int baseline = image.getHeight() + pad + fm.getAscent(); + for (int i = 0; i < lines.size(); i++) { + Color c = (colors != null && i < colors.size() && colors.get(i) != null) ? colors.get(i) : defaultColor; + g.setColor(c); + g.drawString(lines.get(i), pad, baseline + i * (lineH + lineGap)); + } + g.dispose(); + return out; + } + // The board view is placed at BorderLayout.CENTER by the backgammon-family GUI managers. private static Component boardView(GamePanel gamePanel) { if (gamePanel.getLayout() instanceof BorderLayout layout) diff --git a/src/main/java/gui/views/CardView.java b/src/main/java/gui/views/CardView.java index f03dda136..de46df792 100644 --- a/src/main/java/gui/views/CardView.java +++ b/src/main/java/gui/views/CardView.java @@ -26,6 +26,7 @@ public static void drawCard(Graphics2D g, Card card, Rectangle r) { } public static void drawCard(Graphics2D g, Rectangle r, Card card, Image front, Image back, boolean visible) { + // System.out.println("Drawing " + card + " visibility: " + visible); drawCard(g, r.x, r.y, r.width, r.height, card, front, back, visible); } diff --git a/src/main/java/players/mcts/MCTSPlayer.java b/src/main/java/players/mcts/MCTSPlayer.java index 7c6397d2e..c38cfc4d4 100644 --- a/src/main/java/players/mcts/MCTSPlayer.java +++ b/src/main/java/players/mcts/MCTSPlayer.java @@ -331,6 +331,9 @@ public int getVisits(AbstractAction action) { public int getRootVisits() { return root.getVisits(); } + public SingleTreeNode getRoot() { + return root; + } @Override public void finalizePlayer(AbstractGameState state) { diff --git a/src/main/java/players/mcts/SingleTreeNode.java b/src/main/java/players/mcts/SingleTreeNode.java index 5776c8eb3..232cb3f6e 100644 --- a/src/main/java/players/mcts/SingleTreeNode.java +++ b/src/main/java/players/mcts/SingleTreeNode.java @@ -428,6 +428,28 @@ public int actionVisits(AbstractAction action) { return stats == null ? 0 : stats.nVisits; } + public int actionValidVisits(AbstractAction action) { + ActionStats stats = actionValues.get(action); + return stats == null ? 0 : stats.validVisits; + } + + /** + * The action-heuristic value estimate for an action (as computed for move ordering / progressive bias), + * or 0.0 if none has been recorded. + */ + public double actionHeuristicValue(AbstractAction action) { + return actionValueEstimates.getOrDefault(action, 0.0); + } + + /** + * The actions that are currently valid at this node (those available from the open-loop state of the + * most recent iteration). Note this can differ from {@link #getChildren()} keys, which may include + * actions retained from a reused tree that are no longer valid. + */ + public List getActionsFromOpenLoopState() { + return actionsFromOpenLoopState; + } + private int validVisitsFor(AbstractAction action) { if (params.information == Closed_Loop) return nVisits; diff --git a/src/main/java/players/observers/MCTSDecisionRecorder.java b/src/main/java/players/observers/MCTSDecisionRecorder.java new file mode 100644 index 000000000..3c07bf5f2 --- /dev/null +++ b/src/main/java/players/observers/MCTSDecisionRecorder.java @@ -0,0 +1,361 @@ +package players.observers; + +import core.AbstractGameState; +import core.AbstractPlayer; +import core.Game; +import core.actions.AbstractAction; +import evaluation.listeners.IGameListener; +import evaluation.metrics.Event; +import games.GameType; +import gui.StateRenderer; +import players.mcts.MCTSPlayer; +import players.mcts.MultiTreeNode; +import players.mcts.SingleTreeNode; +import utilities.Utils; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * A passive {@link IGameListener} that records, for every real decision in a game, a complete picture + * of what the (MCTS) agent was thinking. It does not change the action taken (unlike + * {@link GameAdviser}); it merely observes. + *

    + * For each {@code ACTION_CHOSEN} event (skipping forced single-action decisions) it produces, in the + * same {@code //G/} directory the {@link Game} uses for saved states: + *

      + *
    • {@code P_Tick.json} - the full game state, written by the {@code Game} itself + * (we simply flag the chosen action with {@link AbstractAction#setSaveGame(boolean)});
    • + *
    • {@code P_Tick.png} - a render of the live state (only if the game has a GUI), + * annotated below with the top 5 actions the agent considered;
    • + *
    • {@code P_Tick_actions.tsv} - a tab-delimited table of every action available at + * the MCTS root with its visit count, valid-visit count, whether it is currently valid, mean + * value and action-heuristic value (untried actions included with a visit count of zero);
    • + *
    • {@code P_Tick.dot} - a graphviz digraph of the part of the search tree whose + * nodes have more than {@code visitThreshold} visits, plus, if graphviz {@code dot} is installed, + * a rendered {@code P_Tick_tree.png}.
    • + *
    + * The last two are only produced when the acting player is an {@link MCTSPlayer}. + */ +public class MCTSDecisionRecorder implements IGameListener { + + protected Game game; + protected final int visitThreshold; + protected final boolean fullPanel; + // null = not yet checked; cached after the first probe so we do not spawn a process per decision. + private Boolean dotAvailable; + + public MCTSDecisionRecorder() { + this(30, true); + } + + public MCTSDecisionRecorder(int visitThreshold) { + this(visitThreshold, true); + } + + /** + * @param visitThreshold only tree nodes with strictly more than this many visits appear in the graphviz output. + * @param fullPanel if true the PNG renders the whole game panel (board + info/history panel); otherwise the + * board view only. Defaults to true here because we render the live state, which still + * carries the history/info-panel data (unlike a state loaded back from JSON). + */ + public MCTSDecisionRecorder(int visitThreshold, boolean fullPanel) { + this.visitThreshold = visitThreshold; + this.fullPanel = fullPanel; + } + + @Override + public void onEvent(Event event) { + if (event.type != Event.GameEvent.ACTION_CHOSEN) + return; + if (event.action == null || event.actions == null || event.actions.size() <= 1) + return; // skip forced (single-action) decisions - there is no real choice to record + + AbstractGameState state = event.state; + int playerID = event.playerID; + + // Ask the Game to write the JSON state for us (it does this in the game loop, after this event) + event.action.setSaveGame(true); + + GameType gameType = game.getGameType(); + String directory = String.format("%s%s%s%sG%d", + game.getSavedStatesDirectory(), File.separator, gameType.name(), File.separator, state.getGameID()); + Utils.createDirectory(directory); + String base = String.format("%s%sP%d_Tick%d", directory, File.separator, playerID, state.getGameTick()); + + // Resolve the MCTS root(s) up front. The acting player's root drives the PNG annotation and the + // actions TSV. The graphviz output covers every tree: for MultiTree MCTS there is a separate tree + // per player, which we render in turn order (active player first, then the rest) so that the + // rendered PNG lays the trees out one after another down the page. + SingleTreeNode actingRoot = null; + List graphRoots = new ArrayList<>(); + AbstractPlayer player = game.getPlayers().get(playerID); + if (player instanceof MCTSPlayer mcts) { + SingleTreeNode root = mcts.getRoot(); + if (root instanceof MultiTreeNode mt) { + int n = state.getNPlayers(); + for (int i = 0; i < n; i++) { + SingleTreeNode playerRoot = mt.getRoot((playerID + i) % n); + if (playerRoot != null) + graphRoots.add(playerRoot); + } + actingRoot = mt.getRoot(playerID); + } else if (root != null) { + actingRoot = root; + graphRoots.add(root); + } + } + + // 1. PNG of the state (only if this game has a GUI), annotated below with the top actions considered. + if (gameType.getGuiManagerClass() != null) { + try { + BufferedImage img = StateRenderer.renderToImage(gameType, state.copy(), fullPanel); + if (actingRoot != null) + img = StateRenderer.withTextBelow(img, topActionLines(actingRoot, playerID, 5), null); + ImageIO.write(img, "png", new File(base + ".png")); + } catch (Exception e) { + System.err.println("MCTSDecisionRecorder: failed to render PNG for " + base + " : " + e.getMessage()); + } + } + + // 2. MCTS statistics (only if the acting player is an MCTS player with a usable root) + if (actingRoot != null) + writeRootActions(base + "_actions.tsv", actingRoot, playerID); + if (!graphRoots.isEmpty()) + writeGraphviz(base + ".dot", base + "_tree.png", graphRoots); + } + + /** + * Writes a tab-delimited table of every action available at the root node. Columns are: visit count, + * valid-visit count, whether the action is currently valid (part of the open-loop action set), the + * action description, its mean value for the deciding player, and its action-heuristic estimate. + * Actions never tried are included with a visit count of zero. Rows are ordered by visits descending. + */ + protected void writeRootActions(String fileName, SingleTreeNode root, int playerID) { + AbstractGameState rootState = root.getState(); + List openLoop = root.getActionsFromOpenLoopState(); + List actions = root.getChildren().keySet().stream() + .sorted(Comparator.comparingInt(root::actionVisits).reversed()) + .toList(); + try (FileWriter writer = new FileWriter(fileName)) { + writer.write("Visits\tnValidVisits\tInOpenLoop\tAction\tValue\tHeuristicValue\n"); + for (AbstractAction action : actions) { + int visits = root.actionVisits(action); + int validVisits = root.actionValidVisits(action); + String inOpenLoop = openLoop.contains(action) ? "Y" : "N"; + double value = visits > 0 ? root.actionTotValue(action, playerID) / visits : 0.0; + double heuristic = root.actionHeuristicValue(action); + String desc = rootState != null ? action.getString(rootState) : action.toString(); + writer.write(String.format("%d\t%d\t%s\t%s\t%.4f\t%.4f\n", + visits, validVisits, inOpenLoop, desc, value, heuristic)); + } + } catch (IOException e) { + System.err.println("MCTSDecisionRecorder: failed to write actions file " + fileName + " : " + e.getMessage()); + } + } + + /** Builds the lines used to annotate the state PNG: the top {@code n} actions by visits, with value. */ + protected List topActionLines(SingleTreeNode root, int playerID, int n) { + AbstractGameState rootState = root.getState(); + List top = root.getChildren().keySet().stream() + .sorted(Comparator.comparingInt(root::actionVisits).reversed()) + .limit(n) + .toList(); + List lines = new ArrayList<>(); + lines.add(String.format("Top %d actions (visits, value):", top.size())); + int rank = 1; + for (AbstractAction action : top) { + int visits = root.actionVisits(action); + double value = visits > 0 ? root.actionTotValue(action, playerID) / visits : 0.0; + String desc = rootState != null ? action.getString(rootState) : action.toString(); + lines.add(String.format("%d. %s (visits=%d, value=%.3f)", rank++, desc, visits, value)); + } + return lines; + } + + /** + * Writes a graphviz (DOT) digraph containing only the tree nodes with more than {@code visitThreshold} + * visits. Nodes are labelled with the deciding player, visit count and mean node value; edges are + * labelled with the action that leads from parent to child, and the children of each node are emitted + * in descending order of visits. An edge is drawn for every action taken more than {@code visitThreshold} + * times; if its child node is not itself in the graph (e.g. the action leads beyond the tree's max depth, + * so the child accrued no node visits of its own) the edge instead leads to an empty (dashed) box + * labelled with the action's visit count. If graphviz {@code dot} is on the PATH, a PNG render is also produced. + *

    + * More than one {@code root} may be supplied (one per player) when MultiTree MCTS is in use: each tree + * is emitted as a contiguous block, in the order given, so the rendered graph stacks the per-player + * trees down the page. At each root any action that is not currently valid (i.e. not part of that + * root's open-loop action set) is dropped, along with the whole subtree beneath it; this filtering is + * applied only at the roots, not at deeper nodes. + */ + protected void writeGraphviz(String dotFileName, String pngFileName, List roots) { + // First work out the subtrees to exclude: those reached from a root via an action that is not in + // that root's current open-loop (valid) action set. + Set excluded = Collections.newSetFromMap(new IdentityHashMap<>()); + for (SingleTreeNode root : roots) { + List openLoop = root.getActionsFromOpenLoopState(); + if (openLoop == null) continue; + for (Map.Entry entry : root.getChildren().entrySet()) { + if (entry.getValue() == null || openLoop.contains(entry.getKey())) continue; + for (SingleTreeNode child : entry.getValue()) + if (child != null) excluded.addAll(child.allNodesInTree()); + } + } + + // Gather the included nodes, grouped by tree (root) so each player's tree forms a contiguous block. + List nodes = new ArrayList<>(); + for (SingleTreeNode root : roots) + for (SingleTreeNode node : root.allNodesInTree()) + if (node.getVisits() > visitThreshold && !excluded.contains(node)) + nodes.add(node); + + IdentityHashMap ids = new IdentityHashMap<>(); + for (SingleTreeNode node : nodes) + ids.put(node, ids.size()); + + // Map each in-tree node to the value of the action that leads to it, evaluated on its parent (and + // from the parent's deciding-player perspective). The root has no incoming action, so it falls back + // to its own mean node value. + IdentityHashMap incomingValue = new IdentityHashMap<>(); + for (SingleTreeNode node : nodes) { + int parentPlayer = node.getActor(); + for (Map.Entry entry : node.getChildren().entrySet()) { + if (entry.getValue() == null) continue; + int actionVisits = node.actionVisits(entry.getKey()); + double value = actionVisits > 0 ? node.actionTotValue(entry.getKey(), parentPlayer) / actionVisits : 0.0; + for (SingleTreeNode child : entry.getValue()) { + if (child == null || !ids.containsKey(child)) continue; + incomingValue.put(child, value); + } + } + } + + try (FileWriter writer = new FileWriter(dotFileName)) { + writer.write("digraph MCTS {\n"); + writer.write(" node [shape=box];\n"); + for (SingleTreeNode node : nodes) { + int player = node.getActor(); + double value = incomingValue.containsKey(node) ? incomingValue.get(node) : node.nodeValue(player); + String label = String.format("P%d\\nvisits=%d\\nvalue=%.3f", player, node.getVisits(), value); + writer.write(String.format(" n%d [label=\"%s\"];\n", ids.get(node), label)); + } + // An edge target is either a real in-tree node (n) or, for an action that was tried but + // never expanded into a node, a synthetic empty box (the optional emptyDecl declares it). + record DotEdge(String emptyDecl, String targetId, String label, int sortVisits) {} + Set rootSet = Collections.newSetFromMap(new IdentityHashMap<>()); + rootSet.addAll(roots); + int emptyCounter = 0; + for (SingleTreeNode node : nodes) { + // Open-loop MCTS discards the state at non-root nodes, so describe actions relative to the + // node's state when present, otherwise fall back to the action's own description. + AbstractGameState nodeState = node.getState(); + // At a root we only show currently-valid (open-loop) actions; deeper nodes are not filtered. + List openLoop = rootSet.contains(node) ? node.getActionsFromOpenLoopState() : null; + List edges = new ArrayList<>(); + for (Map.Entry entry : node.getChildren().entrySet()) { + AbstractAction action = entry.getKey(); + if (openLoop != null && !openLoop.contains(action)) continue; + int actionVisits = node.actionVisits(action); + if (actionVisits <= visitThreshold) continue; // only actions taken more than the threshold + String label = escape(nodeState != null ? action.getString(nodeState) : action.toString()); + + List inGraph = new ArrayList<>(); + if (entry.getValue() != null) + for (SingleTreeNode child : entry.getValue()) + if (child != null && ids.containsKey(child)) inGraph.add(child); + + if (!inGraph.isEmpty()) { + for (SingleTreeNode child : inGraph) + edges.add(new DotEdge(null, "n" + ids.get(child), label, child.getVisits())); + } else { + // The action was taken more than the threshold, but its resulting node is not in the + // graph - typically because it sits beyond the tree's max depth and so never accrued + // node visits of its own. Show it as an edge to an empty box labelled with the + // action's visit count and value (the value of the action, evaluated on this node + // from its deciding player's perspective - matching how real nodes are labelled). + double actionValue = node.actionTotValue(action, node.getActor()) / actionVisits; + String emptyId = "empty" + (emptyCounter++); + String decl = String.format(" %s [label=\"visits=%d\\nvalue=%.3f\", style=dashed];\n", + emptyId, actionVisits, actionValue); + edges.add(new DotEdge(decl, emptyId, label, actionVisits)); + } + } + edges.sort(Comparator.comparingInt(DotEdge::sortVisits).reversed()); + for (DotEdge e : edges) { + if (e.emptyDecl() != null) writer.write(e.emptyDecl()); + writer.write(String.format(" n%d -> %s [label=\"%s\"];\n", ids.get(node), e.targetId(), e.label())); + } + } + writer.write("}\n"); + } catch (IOException e) { + System.err.println("MCTSDecisionRecorder: failed to write graphviz file " + dotFileName + " : " + e.getMessage()); + return; + } + renderDotToPng(dotFileName, pngFileName); + } + + /** Renders a DOT file to PNG using the graphviz {@code dot} executable, if it is installed. */ + protected void renderDotToPng(String dotFileName, String pngFileName) { + if (!isDotAvailable()) + return; + try { + Process p = new ProcessBuilder("dot", "-Tpng", dotFileName, "-o", pngFileName) + .redirectErrorStream(true).start(); + if (!p.waitFor(60, TimeUnit.SECONDS)) { + p.destroyForcibly(); + System.err.println("MCTSDecisionRecorder: dot timed out rendering " + dotFileName); + } + } catch (IOException e) { + System.err.println("MCTSDecisionRecorder: failed to run dot for " + dotFileName + " : " + e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** Probes once (and caches) whether the graphviz {@code dot} command is available on the PATH. */ + private boolean isDotAvailable() { + if (dotAvailable == null) { + try { + Process p = new ProcessBuilder("dot", "-V").redirectErrorStream(true).start(); + p.waitFor(10, TimeUnit.SECONDS); + dotAvailable = true; + } catch (IOException e) { + dotAvailable = false; // dot not installed / not on PATH + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + dotAvailable = false; + } + } + return dotAvailable; + } + + private static String escape(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " "); + } + + @Override + public void report() { + // Nothing to do - files are written per decision. + } + + @Override + public void setGame(Game game) { + this.game = game; + } + + @Override + public Game getGame() { + return game; + } +} diff --git a/src/test/java/players/observers/GameAdviserTest.java b/src/test/java/players/observers/GameAdviserTest.java index 8450c2b58..c47b2c846 100644 --- a/src/test/java/players/observers/GameAdviserTest.java +++ b/src/test/java/players/observers/GameAdviserTest.java @@ -64,12 +64,12 @@ public boolean provideAdvice(core.AbstractGameState state, AbstractAction propos try (BufferedReader reader = new BufferedReader(new FileReader(file))) { String header = reader.readLine(); assertNotNull("Header should not be null", header); - assertEquals("PlayerID\tAgentName\tAgentAction\tAgentValue\tAdviserAction\tAdviserValue\tGameID\tTurn\tRound\tTick", header); - + assertEquals("PlayerID\tAgentName\tAgentAction\tAgentValue\tAgentVisits\tAdviserAction\tAdviserValue\tAdviserVisits\tTotalVisits\tGameID\tTurn\tRound\tTick", header); + String logLine = reader.readLine(); assertNotNull("Log line should not be null", logLine); String[] parts = logLine.split("\t"); - assertEquals("Should have 10 parts", 10, parts.length); + assertEquals("Should have 13 parts", 13, parts.length); } finally { file.delete(); } @@ -144,7 +144,7 @@ public boolean provideAdvice(AbstractGameState state, AbstractAction proposedAct String logLine = reader.readLine(); assertNotNull("Log line should not be null", logLine); String[] parts = logLine.split("\t"); - assertEquals("Should have 10 parts", 10, parts.length); + assertEquals("Should have 13 parts", 13, parts.length); // Check that values are not "0.0" if possible, but TicTacToe might have 0.0 values. // At least it didn't crash. } finally { diff --git a/src/test/java/players/observers/MCTSDecisionRecorderTest.java b/src/test/java/players/observers/MCTSDecisionRecorderTest.java new file mode 100644 index 000000000..912725850 --- /dev/null +++ b/src/test/java/players/observers/MCTSDecisionRecorderTest.java @@ -0,0 +1,254 @@ +package players.observers; + +import core.AbstractGameState; +import core.AbstractPlayer; +import core.Game; +import core.actions.AbstractAction; +import evaluation.metrics.Event; +import games.GameType; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import players.PlayerConstants; +import players.mcts.MCTSEnums; +import players.mcts.MCTSParams; +import players.mcts.MCTSPlayer; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.Assert.*; + +public class MCTSDecisionRecorderTest { + + Path tempDir; + + @Before + public void setup() throws IOException { + tempDir = Files.createTempDirectory("mctsRecorderTest"); + } + + @After + public void tearDown() throws IOException { + if (tempDir != null && Files.exists(tempDir)) { + try (Stream walk = Files.walk(tempDir)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); + } + } + } + + private MCTSPlayer mctsPlayer(int iterations) { + MCTSParams params = new MCTSParams(); + params.setParameterValue("budgetType", PlayerConstants.BUDGET_ITERATIONS); + params.setParameterValue("budget", iterations); + return new MCTSPlayer(params); + } + + private MCTSPlayer multiTreeMctsPlayer(int iterations) { + MCTSParams params = new MCTSParams(); + params.setParameterValue("budgetType", PlayerConstants.BUDGET_ITERATIONS); + params.setParameterValue("budget", iterations); + params.setParameterValue("opponentTreePolicy", MCTSEnums.OpponentTreePolicy.MultiTree); + return new MCTSPlayer(params); + } + + /** + * Drives a single decision manually and checks the root-action TSV lists every available action, + * including ones never tried (which must appear with a visit count of zero). A tiny budget (fewer + * iterations than Connect4's seven opening columns) guarantees some untried actions. + */ + @Test + public void recordsRootActionsIncludingUntried() throws IOException { + MCTSPlayer mcts = mctsPlayer(3); + List players = new ArrayList<>(); + players.add(mcts); + players.add(mcts.copy()); + + Game game = GameType.Connect4.createGameInstance(2); + game.reset(players); + game.setSavedStatesDirectory(tempDir.toString()); + + MCTSDecisionRecorder recorder = new MCTSDecisionRecorder(30, false); + game.addListener(recorder); // sets the game on the recorder + + AbstractGameState state = game.getGameState(); + mcts.setForwardModel(game.getForwardModel()); + List actions = game.getForwardModel().computeAvailableActions(state); + assertTrue("Connect4 should open with multiple actions", actions.size() > 1); + AbstractAction chosen = mcts.getAction(state, actions); + + Event event = Event.createEvent(Event.GameEvent.ACTION_CHOSEN, state, chosen, actions, state.getCurrentPlayer()); + recorder.onEvent(event); + + Path actionsFile = findFile(tempDir, "_actions.tsv"); + assertNotNull("An _actions.tsv file should have been written", actionsFile); + + List lines = Files.readAllLines(actionsFile); + assertEquals("Visits\tnValidVisits\tInOpenLoop\tAction\tValue\tHeuristicValue", lines.get(0)); + List rows = lines.subList(1, lines.size()); + assertEquals("Every available action should be listed", actions.size(), rows.size()); + boolean anyUntried = rows.stream().anyMatch(r -> r.startsWith("0\t")); + assertTrue("With a 3-iteration budget some actions must be untried (0 visits)", anyUntried); + for (String row : rows) { + String[] cols = row.split("\t"); + assertEquals("Each row should have six columns", 6, cols.length); + Integer.parseInt(cols[0]); // Visits + Integer.parseInt(cols[1]); // nValidVisits + assertTrue("InOpenLoop should be Y or N", cols[2].equals("Y") || cols[2].equals("N")); + } + } + + /** + * Runs a full short game with MCTS players and the recorder attached, then checks that the four + * artefact types are produced (JSON via the Game, plus PNG / actions TSV / graphviz DOT from the + * recorder), and that the DOT is well-formed. + */ + @Test + public void endToEndProducesAllArtefacts() throws IOException { + int threshold = 5; + List players = new ArrayList<>(); + players.add(mctsPlayer(100)); + players.add(mctsPlayer(100)); + + Game game = GameType.Connect4.createGameInstance(2); + game.reset(players); + game.setSavedStatesDirectory(tempDir.toString()); + game.addListener(new MCTSDecisionRecorder(threshold, false)); + + game.run(); + + assertNotNull("A JSON state file should exist", findFile(tempDir, ".json")); + assertNotNull("A PNG should exist (Connect4 has a GUI)", findFile(tempDir, ".png")); + assertNotNull("An actions TSV should exist", findFile(tempDir, "_actions.tsv")); + Path dot = findFile(tempDir, ".dot"); + assertNotNull("A graphviz DOT should exist", dot); + + List dotLines = Files.readAllLines(dot); + assertTrue("DOT should declare a digraph", dotLines.get(0).startsWith("digraph")); + assertEquals("}", dotLines.get(dotLines.size() - 1).trim()); + // every declared node line carries a visit count above the threshold + for (String line : dotLines) { + String trimmed = line.trim(); + if (trimmed.contains("visits=")) { + int visits = Integer.parseInt(trimmed.substring(trimmed.indexOf("visits=") + 7).split("\\\\n")[0]); + assertTrue("Nodes in the DOT must exceed the visit threshold", visits > threshold); + } + } + } + + /** + * With MultiTree MCTS a separate tree is kept per player. The DOT should therefore contain a tree for + * each player that acted during search, with the active player's tree emitted first. + */ + @Test + public void multiTreeProducesOneTreePerPlayer() throws IOException { + int threshold = 5; + MCTSPlayer mcts = multiTreeMctsPlayer(400); + List players = new ArrayList<>(); + players.add(mcts); + players.add(mcts.copy()); + + Game game = GameType.Connect4.createGameInstance(2); + game.reset(players); + game.setSavedStatesDirectory(tempDir.toString()); + + MCTSDecisionRecorder recorder = new MCTSDecisionRecorder(threshold, false); + game.addListener(recorder); + + AbstractGameState state = game.getGameState(); + mcts.setForwardModel(game.getForwardModel()); + List actions = game.getForwardModel().computeAvailableActions(state); + AbstractAction chosen = mcts.getAction(state, actions); + + int actingPlayer = state.getCurrentPlayer(); + Event event = Event.createEvent(Event.GameEvent.ACTION_CHOSEN, state, chosen, actions, actingPlayer); + recorder.onEvent(event); + + Path dot = findFile(tempDir, ".dot"); + assertNotNull("A graphviz DOT should exist", dot); + String dotText = String.join("\n", Files.readAllLines(dot)); + + // Both players' trees should be present (P0 and P1 appear as node labels)... + assertTrue("The acting player's tree should be present", dotText.contains("P" + actingPlayer + "\\n")); + int other = 1 - actingPlayer; + assertTrue("The opponent's tree should be present", dotText.contains("P" + other + "\\n")); + + // ...and the active player's tree should come first in the file. + List dotLines = Files.readAllLines(dot); + int firstActing = firstNodeIndexForPlayer(dotLines, actingPlayer); + int firstOther = firstNodeIndexForPlayer(dotLines, other); + assertTrue("Active player's tree should be emitted before the opponent's", firstActing < firstOther); + } + + /** + * Actions taken more than the threshold whose child node lies beyond the tree's max depth (and so + * accrued no node visits of its own) should still appear as an edge - one leading to an empty, + * dashed placeholder box labelled with the action's visit count. + */ + @Test + public void maxDepthActionsLeadToEmptyBoxes() throws IOException { + int threshold = 5; + MCTSParams params = new MCTSParams(); + params.setParameterValue("budgetType", PlayerConstants.BUDGET_ITERATIONS); + params.setParameterValue("budget", 1000); + params.setParameterValue("maxTreeDepth", 2); + MCTSPlayer mcts = new MCTSPlayer(params); + + List players = new ArrayList<>(); + players.add(mcts); + players.add(mcts.copy()); + + Game game = GameType.Connect4.createGameInstance(2); + game.reset(players); + game.setSavedStatesDirectory(tempDir.toString()); + + MCTSDecisionRecorder recorder = new MCTSDecisionRecorder(threshold, false); + game.addListener(recorder); + + AbstractGameState state = game.getGameState(); + mcts.setForwardModel(game.getForwardModel()); + List actions = game.getForwardModel().computeAvailableActions(state); + AbstractAction chosen = mcts.getAction(state, actions); + + Event event = Event.createEvent(Event.GameEvent.ACTION_CHOSEN, state, chosen, actions, state.getCurrentPlayer()); + recorder.onEvent(event); + + Path dot = findFile(tempDir, ".dot"); + assertNotNull("A graphviz DOT should exist", dot); + List dotLines = Files.readAllLines(dot); + + long emptyDecls = dotLines.stream().filter(l -> l.contains("style=dashed")).count(); + assertTrue("Max-depth actions should produce empty placeholder boxes", emptyDecls > 0); + long emptyEdges = dotLines.stream().filter(l -> l.contains("-> empty")).count(); + assertEquals("Each empty box should be the target of exactly one edge", emptyDecls, emptyEdges); + // empty boxes carry the action's visit count (must exceed the threshold) and its value + for (String line : dotLines) { + if (line.contains("style=dashed")) { + String after = line.substring(line.indexOf("visits=") + 7); + int visits = Integer.parseInt(after.split("[^0-9]")[0]); + assertTrue("Empty-box edges represent actions taken more than the threshold", visits > threshold); + assertTrue("Empty boxes should also show the action value", line.contains("value=")); + } + } + } + + private static int firstNodeIndexForPlayer(List dotLines, int player) { + String marker = "label=\"P" + player + "\\n"; + for (int i = 0; i < dotLines.size(); i++) + if (dotLines.get(i).contains(marker)) return i; + return Integer.MAX_VALUE; + } + + private static Path findFile(Path root, String suffix) throws IOException { + try (Stream walk = Files.walk(root)) { + return walk.filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().endsWith(suffix)) + .findFirst().orElse(null); + } + } +}