diff --git a/AGENTS.md b/AGENTS.md index 4ac959d..033a853 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,8 @@ Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app Specs that exist: `keycloak-auth` (login, guests, tokens), `game-history` (saved games, live games, replay), `game-clock` (5 minutes per player, ending games on time), `web-redesign` (the web app's look: themes, board, -pages) and `quarkus-backend` (the backend's move from Node to Java and Quarkus). +pages), `quarkus-backend` (the backend's move from Node to Java and Quarkus) and `game-bot` (playing against a bot +at four levels). ## Running locally @@ -44,13 +45,15 @@ unless `useLocalServer` is set in `apps/mobile/src/const/config.ts`. |---|---|---| | Engine | `cd packages/mancala.js && npx jest` | After changing the rules, change the Java port too and record the parity games again (`apps/backend/README.md`) | | Core | `cd packages/core && npx jest` | | -| Backend | `cd apps/backend && ./mvnw test` | Needs a JDK 25 and Docker: Postgres and Valkey run with Quarkus Dev Services | +| Backend | `cd apps/backend && ./mvnw test` | Needs a JDK 25 and Docker: Postgres and Valkey run with Quarkus Dev Services. The bot's calibration tournament is tagged `calibration` and skipped; see `apps/backend/README.md` | | Keycloak extension | `cd apps/keycloak/extension && mvn test` | Needs Docker; starts Keycloak with the real realm file | | Web app | `cd apps/frontend && yarn build` | There is no `tsconfig.json`, so nothing type-checks it; use a temporary one when needed (with `"types": ["styled-jsx", "react", "react-dom"]`). 1 type error (`react-dom/client`) is known | | Mobile | `cd apps/mobile && npx tsc --noEmit -p .` and `npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output /tmp/x.bundle` | 11 type errors are known and older than recent work | There is no automated end-to-end suite. Changes to the game flow were checked by driving Chrome with -`playwright-core` (installed outside the repo, `executablePath: /usr/bin/google-chrome`) against the local stack. +`playwright-core` (installed outside the repo, e.g. `npm install playwright-core@1.48.2` in a scratch folder; +`executablePath: /usr/bin/google-chrome`) against the local stack. A live game is `GET /game/:id` on the backend, +which helps to check what the server holds. ## How we work @@ -92,6 +95,10 @@ There is no automated end-to-end suite. Changes to the game flow were checked by left with `GameClocks.getClockTimes`, never store a ticking value. `GAME_CLOCK_MS=40000` makes games short for manual tests. - `Rtmt.listenOnClientConnectionChange` adds a listener (several are allowed). +- The bot plays as `bot:` (`BotLevel.isBotId`) in many games at once: `GameStore` does not map it to a game, + nothing is sent to it, it is always online and `PublicIds` keeps its id. The bot's search must never run on the + game loop; `BotPlayer` runs it on its own pool and plays the move back on the loop. +- `-Dgroups=...` runs only the tests with that tag; run the untagged tests separately too. - Unit tests build the logic with fakes (`FakeRtmt`, in-memory stores, `ManualScheduler` instead of timers and direct executors); only storage, HTTP and WebSocket tests are `@QuarkusTest`s. `FakeTokenVerifier` replaces Keycloak in all `@QuarkusTest`s. diff --git a/TODO.md b/TODO.md index 7b73634..7ab5374 100644 --- a/TODO.md +++ b/TODO.md @@ -54,6 +54,17 @@ See `docs/specs/game-clock`. - [ ] Clocks in the home page's live game previews and in the ongoing-game banner - [ ] Try the mobile clocks on a device; only the type check and the bundle were run +## Game bot + +See `docs/specs/game-bot`. + +- [ ] Play against the bot without a connection (a bot in the TypeScript engine on the device) +- [ ] Keep bot games apart once there are statistics or ratings +- [ ] Hints or move analysis for players, with the same search +- [ ] Rebuild `MancalaGame` on `Position`, or use `Position` to check moves before running them (see Security) +- [ ] Tune the levels from real games (`games.bot` holds each game's level, strength and seed) +- [ ] Try the bot on a mobile device; only the type check and the bundle were run + ## Web redesign See `docs/specs/web-redesign`. diff --git a/apps/backend/README.md b/apps/backend/README.md index fb5c3ef..ac0a548 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -21,7 +21,8 @@ The Docker image is built from the repo root: `docker build -f apps/backend/Dock | Package | What | |---|---| -| `engine` | The game rules, a port of `packages/mancala.js` | +| `engine` | The game rules, a port of `packages/mancala.js`; `Position`, a compact board for searching moves | +| `bot` | The bot: levels, strength, the search and `BotPlayer`, which plays its turns in live games | | `model` | The JSON sent to clients and stored, and the shared `ObjectMapper` | | `loop` | `GameLoop`, the single thread that owns all live game state | | `auth` | Keycloak token verification | @@ -93,6 +94,24 @@ backend is down is not charged. The design is in `docs/specs/game-clock`. |---|---|---| | `GAME_CLOCK_MS` | `300000` | Time each player has for a game; a short value such as `40000` helps when testing by hand | +## Bot + +Players can play against a bot at four levels (`new_bot_game` with `{ level }`); the lobby offers it after 10 seconds +without an opponent. Each level is a range of strengths, and the bot draws one per game, so games at the same level +differ. It plays as `bot:` through the same game logic as players, with the clock, saving and spectators, and +waits for the previous move's animation and a thinking time before moving. Its moves are worked out with alpha-beta +search on `Position` on a thread pool of their own, never on the game loop. The design is in `docs/specs/game-bot`. + +| Variable | Default | Purpose | +|---|---|---| +| `BOT_SEARCH_THREADS` | half the processors | Threads working out the bot's moves | + +After changing the search or the strength table, check that every level still beats the one below: + +```sh +./mvnw test -Dtest=BotTournamentTest -Dgroups=calibration -DexcludedGroups=none +``` + | Route | Auth | Response | |---|---|---| | `GET /game/:id` | – | A live game, or `{ status: "finished", record }` | diff --git a/apps/backend/pom.xml b/apps/backend/pom.xml index 52f430f..7293f4c 100644 --- a/apps/backend/pom.xml +++ b/apps/backend/pom.xml @@ -17,6 +17,8 @@ 3.5.6 5006 + + calibration diff --git a/apps/backend/src/main/java/app/playmancala/Backend.java b/apps/backend/src/main/java/app/playmancala/Backend.java index 2386982..2be5492 100644 --- a/apps/backend/src/main/java/app/playmancala/Backend.java +++ b/apps/backend/src/main/java/app/playmancala/Backend.java @@ -1,6 +1,7 @@ package app.playmancala; import java.sql.SQLException; +import java.util.SplittableRandom; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -12,6 +13,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; import app.playmancala.auth.TokenVerifier; +import app.playmancala.bot.BotPlayer; import app.playmancala.game.GameClockScheduler; import app.playmancala.game.GameManager; import app.playmancala.game.GameRecorder; @@ -55,6 +57,10 @@ public class Backend { @ConfigProperty(name = "mancala.game-clock-ms") long gameClockMs; + // 0: half the processors. + @ConfigProperty(name = "mancala.bot-search-threads") + int botSearchThreads; + private GameLoop loop; // Valkey writes, in order. private ExecutorService storeWriter; @@ -62,6 +68,8 @@ public class Backend { private ScheduledExecutorService recorderIo; // Token checks. private ExecutorService verifier; + // Working out the bot's moves. + private ExecutorService botSearch; private RtmtServer rtmt; private GameStore gameStore; @@ -76,6 +84,10 @@ public class Backend { storeWriter = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("valkey-writer").daemon().factory()); recorderIo = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform().name("game-recorder").daemon().factory()); verifier = Executors.newVirtualThreadPerTaskExecutor(); + int searchThreads = botSearchThreads > 0 ? botSearchThreads + : Math.max(1, Runtime.getRuntime().availableProcessors() / 2); + botSearch = Executors.newFixedThreadPool(searchThreads, + Thread.ofPlatform().name("bot-search-", 0).daemon().factory()); ValkeyLiveGameStore liveGameStore = new ValkeyLiveGameStore(redis); PostgresGameHistoryRepository repository = new PostgresGameHistoryRepository(dataSource); @@ -90,6 +102,7 @@ public class Backend { gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, storeWriter, loop::now, gameClockMs); liveGames = new LiveGames(rtmt, gameStore, gameManager, loop); + new BotPlayer(gameManager, gameStore, loop, botSearch, new SplittableRandom()); clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop, GameClockScheduler.CLOCK_CHECK_INTERVAL_MS); @@ -112,6 +125,7 @@ public class Backend { storeWriter.shutdown(); recorderIo.shutdownNow(); verifier.shutdownNow(); + botSearch.shutdownNow(); } private void migrate() { diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotLevel.java b/apps/backend/src/main/java/app/playmancala/bot/BotLevel.java new file mode 100644 index 0000000..574b293 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotLevel.java @@ -0,0 +1,80 @@ +package app.playmancala.bot; + +import java.util.random.RandomGenerator; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +// The levels players choose from. Each is a range of strengths (0-100); the bot draws its strength for a game from +// it. The ranges leave gaps so neighbouring levels stay clearly apart. +public enum BotLevel { + EASY("easy", 5, 25, 800, 1800), + MEDIUM("medium", 30, 55, 1000, 2200), + HARD("hard", 60, 80, 1200, 2600), + MASTER("master", 85, 100, 1400, 3000); + + private static final String BOT_ID_PREFIX = "bot:"; + + private final String wireName; + private final int minStrength; + private final int maxStrength; + // How long the bot seems to think before a move, on top of the previous move's animation. + private final int minThinkMs; + private final int maxThinkMs; + + BotLevel(String wireName, int minStrength, int maxStrength, int minThinkMs, int maxThinkMs) { + this.wireName = wireName; + this.minStrength = minStrength; + this.maxStrength = maxStrength; + this.minThinkMs = minThinkMs; + this.maxThinkMs = maxThinkMs; + } + + @JsonValue + public String wireName() { + return wireName; + } + + public int minStrength() { + return minStrength; + } + + public int maxStrength() { + return maxStrength; + } + + public int middleStrength() { + return (minStrength + maxStrength) / 2; + } + + public int drawStrength(RandomGenerator random) { + return random.nextInt(minStrength, maxStrength + 1); + } + + public long drawThinkMs(RandomGenerator random) { + return random.nextLong(minThinkMs, maxThinkMs + 1); + } + + // The user id the bot plays with at this level, e.g. bot:medium. + public String botId() { + return BOT_ID_PREFIX + wireName; + } + + // The level from its wire name, or null. + @JsonCreator + public static BotLevel fromWireName(String wireName) { + for (BotLevel level : values()) { + if (level.wireName.equals(wireName)) return level; + } + return null; + } + + public static boolean isBotId(String userId) { + return userId != null && userId.startsWith(BOT_ID_PREFIX); + } + + // The level of a bot id, or null if it is not one. + public static BotLevel fromBotId(String userId) { + return isBotId(userId) ? fromWireName(userId.substring(BOT_ID_PREFIX.length())) : null; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java b/apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java new file mode 100644 index 0000000..5203605 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java @@ -0,0 +1,106 @@ +package app.playmancala.bot; + +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.random.RandomGenerator; + +import org.jboss.logging.Logger; + +import app.playmancala.engine.GameState; +import app.playmancala.engine.GameStep; +import app.playmancala.engine.MancalaGame; +import app.playmancala.engine.MoveHistoryItem; +import app.playmancala.engine.Position; +import app.playmancala.game.BackendGame; +import app.playmancala.game.GameClocks; +import app.playmancala.game.GameManager; +import app.playmancala.game.GameStore; +import app.playmancala.loop.Scheduler; +import app.playmancala.model.Seat; + +// Plays the bot's turns in live games. The move is worked out on `searchExecutor`, never on the game loop; it is +// played on the loop once the previous move's animation and the bot's thinking time are over, if the game is still +// waiting for it. Everything except the search runs on the loop. +public class BotPlayer { + private static final Logger LOG = Logger.getLogger(BotPlayer.class); + // PitAnimator's DEFAULT_ANIMATION_INTERVAL in packages/core: clients animate a move one step at a time. + static final long ANIMATION_STEP_MS = 300; + // The game page needs a moment to open before the bot's first move. + static final long GAME_START_DELAY_MS = 1_500; + // With less time than this on its clock, the bot stops pretending to think. + static final long LOW_TIME_MS = 30_000; + static final long LOW_TIME_THINK_MS = 300; + + private final GameManager gameManager; + private final GameStore gameStore; + private final Scheduler loop; + private final Executor searchExecutor; + private final RandomGenerator random; + // Games whose bot move is being worked out; by identity, since a restored game is a new object with the same id. + private final Set pending = Collections.newSetFromMap(new IdentityHashMap<>()); + + public BotPlayer(GameManager gameManager, GameStore gameStore, Scheduler loop, Executor searchExecutor, + RandomGenerator random) { + this.gameManager = gameManager; + this.gameStore = gameStore; + this.loop = loop; + this.searchExecutor = searchExecutor; + this.random = random; + gameManager.onTurnStarted(this::onTurnStarted); + } + + private void onTurnStarted(BackendGame game) { + if (!game.isBotTurn() || game.mancalaGame.getState() == GameState.ENDED || !pending.add(game)) return; + Position position = Position.of(game.mancalaGame); + int moveNumber = game.mancalaGame.getHistory().size(); + BotSettings bot = game.bot; + long readyAt = loop.now() + delayMs(game); + searchExecutor.execute(() -> { + int pit = choose(position, bot, moveNumber); + loop.execute(() -> loop.schedule(() -> play(game, moveNumber, pit), Math.max(0, readyAt - loop.now()))); + }); + } + + private static int choose(Position position, BotSettings bot, int moveNumber) { + try { + return BotPolicy.chooseMove(position, bot, moveNumber); + } catch (RuntimeException e) { + // A failed search must not leave the game waiting: play any move. + LOG.errorf(e, "(BotPlayer) Search failed in %s", position); + int[] pits = new int[Position.PIT_COUNT]; + position.legalPits(pits); + return pits[0]; + } + } + + private void play(BackendGame game, int moveNumber, int pit) { + pending.remove(game); + // The game may have ended (the player left or ran out of time) or been replaced by a restore meanwhile. + if (gameStore.get(game.id) != game || game.mancalaGame.getHistory().size() != moveNumber || !game.isBotTurn()) { + return; + } + gameManager.playMove(game, game.bot.botId(), pit); + } + + // The previous move's animation plus the bot's thinking time, shorter when its clock runs low. + long delayMs(BackendGame game) { + MancalaGame mancalaGame = game.mancalaGame; + Seat seat = GameClocks.getSeat(mancalaGame.getPlayer1Id(), mancalaGame.getPlayer2Id(), game.bot.botId()); + if (game.clock.turnStartedAt() != null + && GameClocks.getRemainingMs(game.clock, seat, seat, loop.now()) < LOW_TIME_MS) { + return LOW_TIME_THINK_MS; + } + List history = mancalaGame.getHistory(); + long waitBefore = history.isEmpty() ? GAME_START_DELAY_MS : animationMs(history.getLast()); + return waitBefore + game.bot.level().drawThinkMs(random); + } + + static long animationMs(MoveHistoryItem move) { + long steps = move.getGameSteps().stream().map(GameStep::type) + .filter(MancalaGame.GAME_STEP_GAME_MOVE::equals).count(); + return (steps + 1) * ANIMATION_STEP_MS; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotPolicy.java b/apps/backend/src/main/java/app/playmancala/bot/BotPolicy.java new file mode 100644 index 0000000..c6a8992 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotPolicy.java @@ -0,0 +1,58 @@ +package app.playmancala.bot; + +import java.util.SplittableRandom; + +import app.playmancala.engine.Position; + +// Chooses the bot's move. Everything random comes from the game's seed and the move number, so a move can be +// reproduced from the saved game. +public final class BotPolicy { + // How far the strength of a single move may stray from the game's strength. + static final int MOVE_STRENGTH_SPREAD = 5; + private static final long MOVE_NUMBER_MIX = 0x9E3779B97F4A7C15L; + + private BotPolicy() { + } + + // The pit (0-5) of the side to move to play. `moveNumber` is the number of moves played in the game so far. + public static int chooseMove(Position position, BotSettings bot, int moveNumber) { + return chooseMove(position, bot, moveNumber, BotSearch.DEFAULT_NODE_BUDGET); + } + + static int chooseMove(Position position, BotSettings bot, int moveNumber, long nodeBudget) { + SplittableRandom random = new SplittableRandom(bot.seed() + MOVE_NUMBER_MIX * (moveNumber + 1)); + int strength = Math.clamp(bot.strength() + random.nextInt(-MOVE_STRENGTH_SPREAD, MOVE_STRENGTH_SPREAD + 1), 0, 100); + BotStrength.Params params = BotStrength.at(strength); + int[] pits = new int[Position.PIT_COUNT]; + int count = position.legalPits(pits); + if (count == 0) throw new IllegalArgumentException("No moves in " + position); + // Drawn every time, so the draws that follow do not depend on how many moves there are. + double randomMoveDraw = random.nextDouble(); + double choiceDraw = random.nextDouble(); + if (count == 1) return pits[0]; + if (randomMoveDraw < params.randomMoveChance()) return pits[(int) (choiceDraw * count)]; + + BotSearch.Result result = BotSearch.scoreMoves(position, params.depth(), nodeBudget); + return pick(result, params.temperature(), choiceDraw); + } + + // A move with probability proportional to exp(score / temperature); temperature in stones. + static int pick(BotSearch.Result result, double temperature, double draw) { + int[] scores = result.scores(); + int best = Integer.MIN_VALUE; + for (int score : scores) best = Math.max(best, score); + double scale = Math.max(temperature, 0.001) * 100; + double[] weights = new double[scores.length]; + double total = 0; + for (int i = 0; i < scores.length; i++) { + weights[i] = Math.exp((scores[i] - best) / scale); + total += weights[i]; + } + double target = draw * total; + for (int i = 0; i < scores.length; i++) { + target -= weights[i]; + if (target < 0) return result.pits()[i]; + } + return result.pits()[scores.length - 1]; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotSearch.java b/apps/backend/src/main/java/app/playmancala/bot/BotSearch.java new file mode 100644 index 0000000..e844587 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotSearch.java @@ -0,0 +1,140 @@ +package app.playmancala.bot; + +import java.util.Arrays; + +import app.playmancala.engine.Position; + +// Scores every move of a position with negamax and alpha-beta pruning. Scores are from the mover's view in +// hundredths of a stone; a move that gives an extra turn keeps the side to move, so its score is not negated. +// Deepens one ply at a time up to the depth; when the node budget runs out, the scores of the last completed depth +// are returned. Nothing depends on time, so the same position, depth and budget always give the same scores. +public final class BotSearch { + public static final long DEFAULT_NODE_BUDGET = 3_000_000; + // An ended (or decided) game scores beyond any bank difference. + public static final int WIN = 100_000; + // A bank with more than half of the 48 stones has won. + private static final int DECIDING_BANK = 24; + private static final int STONE = 100; + // Leaf score for each stone more in the mover's pits than in the opponent's. + static final int PIT_STONE_WEIGHT = 0; + + public record Result(int[] pits, int[] scores, int depth, long nodes) { + public int count() { + return pits.length; + } + } + + private static final class OutOfBudget extends RuntimeException { + OutOfBudget() { + super(null, null, false, false); + } + } + + private final long nodeBudget; + private Position[][] children; + private int[][] order; + private boolean[][] extraTurns; + private int[][] orderKeys; + private long nodes; + private boolean budgetApplies; + + private BotSearch(long nodeBudget) { + this.nodeBudget = nodeBudget; + } + + public static Result scoreMoves(Position root, int maxDepth) { + return scoreMoves(root, maxDepth, DEFAULT_NODE_BUDGET); + } + + public static Result scoreMoves(Position root, int maxDepth, long nodeBudget) { + return new BotSearch(nodeBudget).run(root, Math.max(1, maxDepth)); + } + + private Result run(Position root, int maxDepth) { + children = new Position[maxDepth + 1][Position.PIT_COUNT]; + order = new int[maxDepth + 1][Position.PIT_COUNT]; + extraTurns = new boolean[maxDepth + 1][Position.PIT_COUNT]; + orderKeys = new int[maxDepth + 1][Position.PIT_COUNT]; + for (Position[] ply : children) { + for (int i = 0; i < ply.length; i++) ply[i] = root.copy(); + } + int[] pits = new int[Position.PIT_COUNT]; + int count = root.legalPits(pits); + if (count == 0) throw new IllegalArgumentException("No moves in " + root); + pits = Arrays.copyOf(pits, count); + Position[] rootChildren = new Position[count]; + boolean[] rootExtraTurns = new boolean[count]; + for (int i = 0; i < count; i++) { + rootChildren[i] = root.copy(); + rootExtraTurns[i] = root.play(pits[i], rootChildren[i]); + } + + int[] scores = null; + int completedDepth = 0; + for (int depth = 1; depth <= maxDepth; depth++) { + budgetApplies = depth > 1; + int[] depthScores = new int[count]; + try { + for (int i = 0; i < count; i++) { + nodes++; + int value = negamax(rootChildren[i], depth - 1, -Integer.MAX_VALUE, Integer.MAX_VALUE, 1); + depthScores[i] = rootExtraTurns[i] ? value : -value; + } + } catch (OutOfBudget e) { + break; + } + scores = depthScores; + completedDepth = depth; + } + return new Result(pits, scores, completedDepth, nodes); + } + + private int negamax(Position position, int depth, int alpha, int beta, int ply) { + if (budgetApplies && nodes > nodeBudget) throw new OutOfBudget(); + if (position.isOver()) return endScore(position.bankDifference()); + int ownBank = position.stonesAt(position.isPlayer1ToMove() ? Position.PLAYER1_BANK : Position.PLAYER2_BANK); + int opponentBank = position.stonesAt(position.isPlayer1ToMove() ? Position.PLAYER2_BANK : Position.PLAYER1_BANK); + if (ownBank > DECIDING_BANK || opponentBank > DECIDING_BANK) return endScore(ownBank - opponentBank); + if (depth == 0) return STONE * (ownBank - opponentBank) + PIT_STONE_WEIGHT * position.pitStoneDifference(); + + // Play every move once to order them: extra turns first, then by the stones they bring to the bank. + Position[] next = children[ply]; + int[] moveOrder = order[ply]; + boolean[] extra = extraTurns[ply]; + int[] keys = orderKeys[ply]; + int count = 0; + for (int pit = 0; pit < Position.PIT_COUNT; pit++) { + if (position.stonesAt(position.boardIndex(pit)) == 0) continue; + boolean extraTurn = position.play(pit, next[pit]); + extra[pit] = extraTurn; + int gain = next[pit].stonesAt(position.isPlayer1ToMove() ? Position.PLAYER1_BANK : Position.PLAYER2_BANK) - ownBank; + int key = (extraTurn ? 1000 : 0) + gain; + int at = count++; + while (at > 0 && keys[at - 1] < key) { + keys[at] = keys[at - 1]; + moveOrder[at] = moveOrder[at - 1]; + at--; + } + keys[at] = key; + moveOrder[at] = pit; + } + + int best = -Integer.MAX_VALUE; + for (int i = 0; i < count; i++) { + int pit = moveOrder[i]; + nodes++; + int value = extra[pit] + ? negamax(next[pit], depth - 1, alpha, beta, ply + 1) + : -negamax(next[pit], depth - 1, -beta, -alpha, ply + 1); + if (value > best) best = value; + if (best > alpha) alpha = best; + if (alpha >= beta) break; + } + return best; + } + + private static int endScore(int bankDifference) { + int sign = Integer.signum(bankDifference); + return sign * WIN + STONE * bankDifference; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotSettings.java b/apps/backend/src/main/java/app/playmancala/bot/BotSettings.java new file mode 100644 index 0000000..e91b567 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotSettings.java @@ -0,0 +1,16 @@ +package app.playmancala.bot; + +import java.util.random.RandomGenerator; + +// How the bot plays one game: its level, the strength drawn for the game and the seed its choices come from. Kept +// with the live game and the saved game, so a restart does not change how it plays and its moves can be reproduced. +public record BotSettings(BotLevel level, int strength, long seed) { + + public static BotSettings draw(BotLevel level, RandomGenerator random) { + return new BotSettings(level, level.drawStrength(random), random.nextLong()); + } + + public String botId() { + return level.botId(); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/bot/BotStrength.java b/apps/backend/src/main/java/app/playmancala/bot/BotStrength.java new file mode 100644 index 0000000..20d5207 --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/bot/BotStrength.java @@ -0,0 +1,34 @@ +package app.playmancala.bot; + +// What a strength (0-100) means for one move: how deep the bot searches, how strongly it prefers the best move +// (softmax temperature in stones; lower is more exact) and how often it plays a random move instead. Values between +// the rows of the table are interpolated linearly. +public final class BotStrength { + + public record Params(int depth, double temperature, double randomMoveChance) { + } + + private static final double[] STRENGTHS = { 0, 25, 50, 75, 100 }; + private static final double[] DEPTHS = { 1, 2, 4, 7, 16 }; + private static final double[] TEMPERATURES = { 4, 2, 1, 0.4, 0.05 }; + private static final double[] RANDOM_MOVE_CHANCES = { 0.35, 0.15, 0.05, 0.01, 0 }; + + private BotStrength() { + } + + public static Params at(double strength) { + double s = Math.clamp(strength, 0, 100); + return new Params((int) Math.round(interpolate(DEPTHS, s)), interpolate(TEMPERATURES, s), + interpolate(RANDOM_MOVE_CHANCES, s)); + } + + private static double interpolate(double[] values, double strength) { + for (int row = 1; row < STRENGTHS.length; row++) { + if (strength <= STRENGTHS[row]) { + double t = (strength - STRENGTHS[row - 1]) / (STRENGTHS[row] - STRENGTHS[row - 1]); + return values[row - 1] + t * (values[row] - values[row - 1]); + } + } + return values[values.length - 1]; + } +} diff --git a/apps/backend/src/main/java/app/playmancala/engine/Position.java b/apps/backend/src/main/java/app/playmancala/engine/Position.java new file mode 100644 index 0000000..454ecac --- /dev/null +++ b/apps/backend/src/main/java/app/playmancala/engine/Position.java @@ -0,0 +1,176 @@ +package app.playmancala.engine; + +import java.util.Arrays; + +// A board as 14 stone counts and whose turn it is, with the rules of CommonMancalaGame played on it directly. Fast +// enough for searching millions of moves: playing a move allocates nothing. Kept equal to MancalaGame by +// PositionTest; MancalaGame stays the engine for live games, since it also records the steps clients animate. +public final class Position { + public static final int SIZE = 14; + public static final int PIT_COUNT = 6; + public static final int PLAYER1_BANK = 6; + public static final int PLAYER2_BANK = 13; + private static final int INITIAL_STONES_IN_PIT = 4; + + // Player 1's pits 0-5 and bank 6; player 2's pits 7-12 and bank 13, as in CommonBoard. + private final int[] stones; + private boolean player1ToMove; + + private Position(int[] stones, boolean player1ToMove) { + this.stones = stones; + this.player1ToMove = player1ToMove; + } + + public static Position initial(boolean player1ToMove) { + int[] stones = new int[SIZE]; + for (int pit = 0; pit < PIT_COUNT; pit++) { + stones[pit] = INITIAL_STONES_IN_PIT; + stones[PLAYER1_BANK + 1 + pit] = INITIAL_STONES_IN_PIT; + } + return new Position(stones, player1ToMove); + } + + public static Position of(int[] stones, boolean player1ToMove) { + if (stones.length != SIZE) throw new IllegalArgumentException("A board has " + SIZE + " pits: " + stones.length); + return new Position(stones.clone(), player1ToMove); + } + + public static Position of(MancalaGame game) { + return of(game.getBoard().getStoneArray(), game.isTurnPlayer1()); + } + + public Position copy() { + return new Position(stones.clone(), player1ToMove); + } + + public int[] stones() { + return stones.clone(); + } + + public int stonesAt(int index) { + return stones[index]; + } + + public boolean isPlayer1ToMove() { + return player1ToMove; + } + + // Writes the mover's playable pits (0-5) into `into` and returns how many there are. + public int legalPits(int[] into) { + int first = firstPitIndex(player1ToMove); + int count = 0; + for (int pit = 0; pit < PIT_COUNT; pit++) { + if (stones[first + pit] > 0) into[count++] = pit; + } + return count; + } + + // Plays the mover's pit (0-5) into `into`, which may be this position. Returns true if the mover plays again. + public boolean play(int pit, Position into) { + if (pit < 0 || pit >= PIT_COUNT) throw new IllegalArgumentException("No such pit: " + pit); + int index = firstPitIndex(player1ToMove) + pit; + int stoneCount = stones[index]; + if (stoneCount <= 0) throw new IllegalArgumentException("Pit " + pit + " is empty"); + int[] board = into.stones; + if (into != this) System.arraycopy(stones, 0, board, 0, SIZE); + boolean player1 = player1ToMove; + int ownBank = player1 ? PLAYER1_BANK : PLAYER2_BANK; + int opponentBank = player1 ? PLAYER2_BANK : PLAYER1_BANK; + + // Sowing: a single stone goes to the next pit; otherwise the first stone goes back into the played pit. + board[index] = 0; + int current = stoneCount == 1 ? next(index) : index; + int last = current; + for (int dropped = 0; dropped < stoneCount; current = next(current)) { + if (current == opponentBank) continue; + board[current]++; + last = current; + dropped++; + } + + // The last stone in an own empty pit takes the stones opposite, if there are any. + if (board[last] == 1 && isOwnPit(last, player1)) { + int opposite = 12 - last; + if (board[opposite] > 0) { + board[ownBank] += 1 + board[opposite]; + board[opposite] = 0; + board[last] = 0; + } + } + // The last stone making an opponent's pit even takes them. + if (board[last] % 2 == 0 && isOwnPit(last, !player1)) { + board[ownBank] += board[last]; + board[last] = 0; + } + boolean extraTurn = last == ownBank; + into.player1ToMove = extraTurn == player1; + + // When a side is empty, its player gets the stones left on the other side. + int player1Stones = sumPits(board, 0); + int player2Stones = sumPits(board, PLAYER1_BANK + 1); + if (player1Stones == 0) { + board[PLAYER1_BANK] += player2Stones; + Arrays.fill(board, PLAYER1_BANK + 1, PLAYER2_BANK, 0); + } else if (player2Stones == 0) { + board[PLAYER2_BANK] += player1Stones; + Arrays.fill(board, 0, PLAYER1_BANK, 0); + } + return extraTurn; + } + + // One side is empty, so the game has ended. + public boolean isOver() { + return sumPits(stones, 0) == 0 || sumPits(stones, PLAYER1_BANK + 1) == 0; + } + + // The mover's bank minus the opponent's. + public int bankDifference() { + int difference = stones[PLAYER1_BANK] - stones[PLAYER2_BANK]; + return player1ToMove ? difference : -difference; + } + + // The mover's stones in pits minus the opponent's. + public int pitStoneDifference() { + int difference = sumPits(stones, 0) - sumPits(stones, PLAYER1_BANK + 1); + return player1ToMove ? difference : -difference; + } + + // Board index of the mover's pit (0-5). + public int boardIndex(int pit) { + return firstPitIndex(player1ToMove) + pit; + } + + private static int firstPitIndex(boolean player1) { + return player1 ? 0 : PLAYER1_BANK + 1; + } + + private static boolean isOwnPit(int index, boolean player1) { + return player1 ? index < PLAYER1_BANK : index > PLAYER1_BANK && index < PLAYER2_BANK; + } + + private static int next(int index) { + return index == SIZE - 1 ? 0 : index + 1; + } + + private static int sumPits(int[] board, int first) { + int sum = 0; + for (int index = first; index < first + PIT_COUNT; index++) sum += board[index]; + return sum; + } + + @Override + public boolean equals(Object other) { + return other instanceof Position position && player1ToMove == position.player1ToMove + && Arrays.equals(stones, position.stones); + } + + @Override + public int hashCode() { + return 31 * Arrays.hashCode(stones) + Boolean.hashCode(player1ToMove); + } + + @Override + public String toString() { + return Arrays.toString(stones) + (player1ToMove ? " player 1 to move" : " player 2 to move"); + } +} diff --git a/apps/backend/src/main/java/app/playmancala/game/BackendGame.java b/apps/backend/src/main/java/app/playmancala/game/BackendGame.java index 570bbf4..0a4ac6f 100644 --- a/apps/backend/src/main/java/app/playmancala/game/BackendGame.java +++ b/apps/backend/src/main/java/app/playmancala/game/BackendGame.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import app.playmancala.bot.BotSettings; import app.playmancala.engine.MancalaGame; import app.playmancala.model.GameClock; import app.playmancala.model.GameUsersConnectionInfo; @@ -27,10 +28,12 @@ public class BackendGame { public GameClock clock; // The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock. public final List moveRemainingMs; + // How the bot plays in a game against the bot; null in games between people. + public final BotSettings bot; public BackendGame(MancalaGame mancalaGame, GameUsersConnectionInfo gameUsersConnectionInfo, List allSpectatorIds, int peakSpectators, Map players, long startedAt, - List moveTimes, GameClock clock, List moveRemainingMs) { + List moveTimes, GameClock clock, List moveRemainingMs, BotSettings bot) { this.id = mancalaGame.getId(); this.mancalaGame = mancalaGame; this.gameUsersConnectionInfo = gameUsersConnectionInfo; @@ -41,6 +44,11 @@ public class BackendGame { this.moveTimes = new ArrayList<>(moveTimes); this.clock = clock; this.moveRemainingMs = new ArrayList<>(moveRemainingMs); + this.bot = bot; + } + + public boolean isBotTurn() { + return bot != null && bot.botId().equals(mancalaGame.getTurnPlayerId()); } public PlayerSummary player(String userId) { diff --git a/apps/backend/src/main/java/app/playmancala/game/GameManager.java b/apps/backend/src/main/java/app/playmancala/game/GameManager.java index 545155a..a92d460 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameManager.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameManager.java @@ -7,12 +7,16 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.Executor; import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Consumer; +import java.util.function.IntSupplier; import java.util.function.LongSupplier; import org.jboss.logging.Logger; import com.fasterxml.jackson.databind.JsonNode; +import app.playmancala.bot.BotLevel; +import app.playmancala.bot.BotSettings; import app.playmancala.engine.CommonMancalaGame; import app.playmancala.engine.GameState; import app.playmancala.engine.MancalaGame; @@ -35,6 +39,8 @@ import app.playmancala.rtmt.Rtmt; // Writes to Valkey go through `storeExecutor`, saving finished games through the recorder; neither blocks the loop. public class GameManager { private static final Logger LOG = Logger.getLogger(GameManager.class); + // Clients show the bot by its id ("Bot · Medium"); the name is for anything that only shows names. + static final String BOT_NAME = "Bot"; private final GameStore gameStore; private final Rtmt rtmt; @@ -46,6 +52,7 @@ public class GameManager { // Time each player has for a new game. private final long gameClockMs; private final List liveGamesListeners = new ArrayList<>(); + private final List> turnListeners = new ArrayList<>(); public GameManager(GameStore gameStore, Rtmt rtmt, MatchMaker matchMaker, LiveGameStore liveGameStore, GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) { @@ -67,6 +74,12 @@ public class GameManager { liveGamesListeners.add(listener); } + // Called whenever a player is to move: when a game starts or is restored, and after a move that did not end it, + // including one that gives an extra turn. + public void onTurnStarted(Consumer listener) { + turnListeners.add(listener); + } + private void listenRtmtMessages() { rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> { BackendGame activeGame = gameStore.getGameByUser(userId); @@ -76,6 +89,7 @@ public class GameManager { } matchMaker.join(userId); }); + rtmt.listenMessage(Channels.NEW_BOT_GAME, this::onNewBotGame); rtmt.listenMessage(Channels.CANCEL_NEW_GAME, (userId, message) -> matchMaker.cancel(userId)); rtmt.listenMessage(Channels.GAME_MOVE, this::onGameMove); rtmt.listenMessage(Channels.LEAVE_GAME, (userId, message) -> onPlayerLeave(userId)); @@ -87,12 +101,44 @@ public class GameManager { }); } + private void onNewBotGame(String userId, JsonNode message) { + BotLevel level = BotLevel.fromWireName(message.path("level").asText(null)); + if (level == null) { + LOG.debugf("(GameManager) Unknown bot level: %s", message); + return; + } + BackendGame activeGame = gameStore.getGameByUser(userId); + if (activeGame != null) { + rtmt.sendMessage(userId, Channels.ON_NEW_GAME_REJECTED, Map.of("gameId", activeGame.id)); + return; + } + matchMaker.cancel(userId); + BotSettings bot = BotSettings.draw(level, ThreadLocalRandom.current()); + startGame(createMancalaGame(userId, bot.botId(), bot)); + } + private void onGameMove(String userId, JsonNode gameMove) { BackendGame game = gameStore.getGameByUser(userId); if (game == null) { LOG.debug("Game not found!"); return; } + playMove(game, userId, () -> { + JsonNode index = gameMove.path("index"); + if (!index.canConvertToInt() || !index.isIntegralNumber()) { + throw new IllegalStateException("Invalid move: " + gameMove); + } + return index.asInt(); + }); + } + + // Plays a player's pit (0-5) in a live game; the bot's moves come here too. + public void playMove(BackendGame game, String userId, int pitIndex) { + playMove(game, userId, () -> pitIndex); + } + + // The pit is read after the clock check, so an invalid move after the time has run out still ends the game on time. + private void playMove(BackendGame game, String userId, IntSupplier pitIndex) { MancalaGame mancalaGame = game.mancalaGame; try { long now = clock.getAsLong(); @@ -101,13 +147,10 @@ public class GameManager { endGameOnTime(game); return; } - JsonNode index = gameMove.path("index"); - if (!index.canConvertToInt() || !index.isIntegralNumber()) { - throw new IllegalStateException("Invalid move: " + gameMove); - } + int index = pitIndex.getAsInt(); int movesBefore = mancalaGame.getHistory().size(); Seat moverSeat = seatOf(mancalaGame, userId); - mancalaGame.moveByPlayerPit(userId, index.asInt()); + mancalaGame.moveByPlayerPit(userId, index); if (mancalaGame.getHistory().size() > movesBefore && moverSeat != null) { game.moveTimes.add(now); GameClocks.ChargedMove charged = GameClocks.chargeMove(game.clock, moverSeat, now); @@ -121,6 +164,7 @@ public class GameManager { } else { saveLiveGame(game); notifyLiveGamesChanged(); + notifyTurnStarted(game); } } catch (RuntimeException e) { onGameError(game, e); @@ -226,6 +270,7 @@ public class GameManager { if (restored > 0) { LOG.infof("(GameManager) Restored %d live game(s)", restored); notifyLiveGamesChanged(); + gameStore.getAll().forEach(this::notifyTurnStarted); } return restored; } @@ -256,17 +301,27 @@ public class GameManager { } private void sendMessageToPlayersAndSpectators(BackendGame game, String channel, Object message) { - rtmt.sendMessage(game.mancalaGame.getPlayer1Id(), channel, message); - rtmt.sendMessage(game.mancalaGame.getPlayer2Id(), channel, message); + sendMessageToPlayer(game.mancalaGame.getPlayer1Id(), channel, message); + sendMessageToPlayer(game.mancalaGame.getPlayer2Id(), channel, message); List.copyOf(game.spectatorIds).forEach(spectatorId -> rtmt.sendMessage(spectatorId, channel, message)); } + // The bot has no connection to send to. + private void sendMessageToPlayer(String playerId, String channel, Object message) { + if (!BotLevel.isBotId(playerId)) rtmt.sendMessage(playerId, channel, message); + } + public ClientGame toClientGame(BackendGame game) { return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt, List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong()); } public BackendGame createMancalaGame(String userId1, String userId2) { + return createMancalaGame(userId1, userId2, null); + } + + // `bot` is set for a game against the bot, which plays as one of the two users. + public BackendGame createMancalaGame(String userId1, String userId2, BotSettings bot) { MancalaGame mancalaGame = new CommonMancalaGame(UUID.randomUUID().toString(), userId1, userId2); mancalaGame.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? userId1 : userId2); long now = clock.getAsLong(); @@ -276,7 +331,7 @@ public class GameManager { players.put(userId1, player1); players.put(userId2, player2); BackendGame game = new BackendGame(mancalaGame, getGameUsersConnectionInfo(player1, player2), List.of(), 0, - players, now, List.of(), GameClocks.start(gameClockMs, now), List.of()); + players, now, List.of(), GameClocks.start(gameClockMs, now), List.of(), bot); gameStore.set(game.id, game); gameStore.setGameByUser(userId1, game); gameStore.setGameByUser(userId2, game); @@ -284,6 +339,7 @@ public class GameManager { } private PlayerSummary getPlayerInfo(String userId) { + if (BotLevel.isBotId(userId)) return new PlayerSummary(userId, BOT_NAME, false); AuthUser user = rtmt.getUser(userId); return new PlayerSummary(userId, user != null ? user.name() : null, user == null || user.isGuest()); } @@ -295,8 +351,13 @@ public class GameManager { private GameUsersConnectionInfo getGameUsersConnectionInfo(PlayerSummary player1, PlayerSummary player2) { return new GameUsersConnectionInfo( - UserConnectionInfo.of(player1, rtmt.isClientOnline(player1.userId())), - UserConnectionInfo.of(player2, rtmt.isClientOnline(player2.userId()))); + UserConnectionInfo.of(player1, isOnline(player1.userId())), + UserConnectionInfo.of(player2, isOnline(player2.userId()))); + } + + // The bot is always there. + private boolean isOnline(String userId) { + return BotLevel.isBotId(userId) || rtmt.isClientOnline(userId); } public void startGame(BackendGame game) { @@ -304,6 +365,7 @@ public class GameManager { sendUserConnectionInfo(game); saveLiveGame(game); notifyLiveGamesChanged(); + notifyTurnStarted(game); } public void sendUserConnectionInfo(BackendGame game) { @@ -337,6 +399,10 @@ public class GameManager { liveGamesListeners.forEach(Runnable::run); } + private void notifyTurnStarted(BackendGame game) { + turnListeners.forEach(listener -> listener.accept(game)); + } + private void listenUserConnectionChange() { rtmt.listenOnClientConnectionChange((clientId, isOnline) -> { BackendGame game = gameStore.getGameByUser(clientId); diff --git a/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java index 7331021..b394159 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java @@ -5,12 +5,15 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; import org.jboss.logging.Logger; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.JsonNode; +import app.playmancala.bot.BotLevel; +import app.playmancala.bot.BotSettings; import app.playmancala.engine.CommonMancalaGame; import app.playmancala.engine.GameState; import app.playmancala.engine.MancalaGame; @@ -53,7 +56,9 @@ public final class GameSerialization { int peakSpectators, List allSpectatorIds, GameClock clock, - @JsonInclude(JsonInclude.Include.ALWAYS) List moveRemainingMs) { + @JsonInclude(JsonInclude.Include.ALWAYS) List moveRemainingMs, + // Games against the bot only. + BotSettings bot) { } public record GameEnding(EndReason endReason, String winnerId, String leftUserId) { @@ -69,7 +74,7 @@ public final class GameSerialization { mancalaGame.getTurnPlayerId(), mancalaGame.getState(), boxed(mancalaGame.getBoard().getStoneArray()), List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt, List.copyOf(game.moveTimes), game.peakSpectators, List.copyOf(game.allSpectatorIds), game.clock, - Collections.unmodifiableList(new ArrayList<>(game.moveRemainingMs))); + Collections.unmodifiableList(new ArrayList<>(game.moveRemainingMs)), game.bot); } // The snapshot in the JSON, or null if it is not one this backend can restore. @@ -112,7 +117,16 @@ public final class GameSerialization { return new BackendGame(mancalaGame, new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)), orEmpty(snapshot.allSpectatorIds()), snapshot.peakSpectators(), players, snapshot.startedAt(), - orEmpty(snapshot.moveTimes()), clock, moveRemainingMs); + orEmpty(snapshot.moveTimes()), clock, moveRemainingMs, restoreBot(snapshot)); + } + + // The snapshot's bot settings. Every bot game is saved with them; should they be missing, the bot plays on at + // the middle of its level. + private static BotSettings restoreBot(LiveGameSnapshot snapshot) { + if (snapshot.bot() != null) return snapshot.bot(); + BotLevel level = BotLevel.fromBotId(snapshot.player2Id()); + if (level == null) level = BotLevel.fromBotId(snapshot.player1Id()); + return level == null ? null : new BotSettings(level, level.middleStrength(), ThreadLocalRandom.current().nextLong()); } // Null when the game ended without a winner and was not completed, e.g. both players left. @@ -138,7 +152,7 @@ public final class GameSerialization { publicSummary(game, mancalaGame.getPlayer2Id()), boxed(mancalaGame.getBoard().getStoneArray()), getResult(game, ending), ending.endReason(), ending.leftUserId() == null ? null : PublicIds.publicUserId(ending.leftUserId()), game.startedAt, endedAt, - moves.size(), game.peakSpectators, game.clock.initialMs(), moves, game.allSpectatorIds.size()); + moves.size(), game.peakSpectators, game.clock.initialMs(), moves, game.allSpectatorIds.size(), game.bot); } // The player with a public id, for anything saved or listed. diff --git a/apps/backend/src/main/java/app/playmancala/game/GameStore.java b/apps/backend/src/main/java/app/playmancala/game/GameStore.java index 2387f78..e957e50 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameStore.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameStore.java @@ -6,6 +6,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import app.playmancala.bot.BotLevel; + // Live games in memory, the source of truth for them; Valkey only holds a copy. Only touched on the game loop. public class GameStore { // In the order games started. @@ -28,7 +30,9 @@ public class GameStore { games.remove(id); } + // Bots play many games at once, so they are not mapped to one. public void setGameByUser(String userId, BackendGame game) { + if (BotLevel.isBotId(userId)) return; gameIdsByUser.put(userId, game.id); } diff --git a/apps/backend/src/main/java/app/playmancala/game/PublicIds.java b/apps/backend/src/main/java/app/playmancala/game/PublicIds.java index 9ee5652..498e91b 100644 --- a/apps/backend/src/main/java/app/playmancala/game/PublicIds.java +++ b/apps/backend/src/main/java/app/playmancala/game/PublicIds.java @@ -6,9 +6,10 @@ import java.security.NoSuchAlgorithmException; import java.util.HexFormat; import app.playmancala.auth.KeycloakTokenVerifier; +import app.playmancala.bot.BotLevel; // Ids of legacy userKey users are the secret they log in with, so anything saved or listed publicly uses a hash -// instead. Keycloak ids are not secret and stay as they are. +// instead. Keycloak ids and bot ids are not secret and stay as they are. public final class PublicIds { private static final String LEGACY_PUBLIC_ID_PREFIX = "legacy:"; @@ -16,7 +17,8 @@ public final class PublicIds { } public static String publicUserId(String userId) { - if (userId.startsWith(KeycloakTokenVerifier.KEYCLOAK_USER_ID_PREFIX) || userId.startsWith(LEGACY_PUBLIC_ID_PREFIX)) { + if (userId.startsWith(KeycloakTokenVerifier.KEYCLOAK_USER_ID_PREFIX) || userId.startsWith(LEGACY_PUBLIC_ID_PREFIX) + || BotLevel.isBotId(userId)) { return userId; } try { diff --git a/apps/backend/src/main/java/app/playmancala/history/Migrations.java b/apps/backend/src/main/java/app/playmancala/history/Migrations.java index 136b54c..779afc2 100644 --- a/apps/backend/src/main/java/app/playmancala/history/Migrations.java +++ b/apps/backend/src/main/java/app/playmancala/history/Migrations.java @@ -23,7 +23,7 @@ public final class Migrations { private static final long MIGRATION_LOCK_ID = 727274; // Applied in this order; never change a migration that has been released, add a new one instead. - public static final List VERSIONS = List.of("001_games", "002_clock"); + public static final List VERSIONS = List.of("001_games", "002_clock", "003_bot"); private Migrations() { } diff --git a/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java b/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java index b3ed987..13f70fc 100644 --- a/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java +++ b/apps/backend/src/main/java/app/playmancala/history/PostgresGameHistoryRepository.java @@ -14,6 +14,7 @@ import javax.sql.DataSource; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import app.playmancala.bot.BotSettings; import app.playmancala.model.EndReason; import app.playmancala.model.FinishedGameSummary; import app.playmancala.model.GameRecord; @@ -58,9 +59,9 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository { int inserted; try (PreparedStatement statement = connection.prepareStatement(""" INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at, - move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms) + move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms, bot) VALUES (?::uuid, ?, ?, ?, ?, ?, to_timestamp(? / 1000.0), to_timestamp(? / 1000.0), - ?, ?::jsonb, ?::jsonb, ?, ?, ?) + ?, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb) ON CONFLICT (id) DO NOTHING""")) { statement.setString(1, record.id()); statement.setString(2, record.player1().userId()); @@ -77,6 +78,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository { statement.setInt(13, record.totalSpectators()); if (record.clockInitialMs() == null) statement.setNull(14, Types.INTEGER); else statement.setInt(14, Math.toIntExact(record.clockInitialMs())); + statement.setString(15, record.bot() == null ? null : Json.write(record.bot())); inserted = statement.executeUpdate(); } if (inserted == 0) return null; @@ -103,7 +105,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository { if (!UUID_PATTERN.matcher(id).matches()) return null; return query(connection -> { try (PreparedStatement statement = connection.prepareStatement( - "SELECT " + SUMMARY_COLUMNS + ", g.moves, g.total_spectators " + FROM_GAMES + " WHERE g.id = ?::uuid")) { + "SELECT " + SUMMARY_COLUMNS + ", g.moves, g.total_spectators, g.bot " + FROM_GAMES + " WHERE g.id = ?::uuid")) { statement.setString(1, id); try (ResultSet row = statement.executeQuery()) { if (!row.next()) return null; @@ -111,7 +113,8 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository { return new GameRecord(summary.id(), summary.player1(), summary.player2(), summary.board(), summary.result(), summary.endReason(), summary.leftUserId(), summary.startedAt(), summary.endedAt(), summary.moveCount(), summary.peakSpectators(), summary.clockInitialMs(), - Json.MAPPER.readValue(row.getString("moves"), MOVES), row.getInt("total_spectators")); + Json.MAPPER.readValue(row.getString("moves"), MOVES), row.getInt("total_spectators"), + row.getString("bot") == null ? null : Json.MAPPER.readValue(row.getString("bot"), BotSettings.class)); } } }); diff --git a/apps/backend/src/main/java/app/playmancala/model/Channels.java b/apps/backend/src/main/java/app/playmancala/model/Channels.java index 3336683..25fb9e2 100644 --- a/apps/backend/src/main/java/app/playmancala/model/Channels.java +++ b/apps/backend/src/main/java/app/playmancala/model/Channels.java @@ -4,6 +4,8 @@ package app.playmancala.model; public final class Channels { public static final String NEW_GAME = "new_game"; public static final String CANCEL_NEW_GAME = "cancel_new_game"; + // client -> server: { level }, starts a game against the bot at that level (docs/specs/game-bot). + public static final String NEW_BOT_GAME = "new_bot_game"; public static final String ON_GAME_START = "on_game_start"; public static final String GAME_MOVE = "game_move"; public static final String ON_GAME_UPDATE = "on_game_update"; diff --git a/apps/backend/src/main/java/app/playmancala/model/GameRecord.java b/apps/backend/src/main/java/app/playmancala/model/GameRecord.java index 325ab9c..a3014f3 100644 --- a/apps/backend/src/main/java/app/playmancala/model/GameRecord.java +++ b/apps/backend/src/main/java/app/playmancala/model/GameRecord.java @@ -5,10 +5,12 @@ import java.util.List; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import app.playmancala.bot.BotSettings; + // A saved, finished game: a FinishedGameSummary plus its moves and the number of distinct spectators. Every id in it -// is a public id. +// is a public id. `bot` is how the bot played, in games against the bot. @JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt", - "moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators" }) + "moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators", "bot" }) public record GameRecord( String id, PlayerSummary player1, @@ -23,7 +25,16 @@ public record GameRecord( int peakSpectators, @JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs, List moves, - int totalSpectators) { + int totalSpectators, + @JsonInclude(JsonInclude.Include.NON_NULL) BotSettings bot) { + + // A game between people. + public GameRecord(String id, PlayerSummary player1, PlayerSummary player2, List board, GameResult result, + EndReason endReason, String leftUserId, long startedAt, long endedAt, int moveCount, int peakSpectators, + Long clockInitialMs, List moves, int totalSpectators) { + this(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, moveCount, peakSpectators, + clockInitialMs, moves, totalSpectators, null); + } public FinishedGameSummary toSummary() { return new FinishedGameSummary(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index b55fd58..a6e3fe4 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -23,6 +23,9 @@ mancala.auth.legacy-userkey-auth=${LEGACY_USERKEY_AUTH:true} # Time each player has for a whole game (docs/specs/game-clock). mancala.game-clock-ms=${GAME_CLOCK_MS:300000} +# Threads working out the bot's moves (docs/specs/game-bot); 0 is half the processors. +mancala.bot-search-threads=${BOT_SEARCH_THREADS:0} + # Postgres. Tests start one with Dev Services. The backend starts without it; migrations are retried. quarkus.datasource.db-kind=postgresql %dev,prod.quarkus.datasource.jdbc.url=${DATABASE_JDBC_URL:jdbc:postgresql://localhost:5433/mancala} diff --git a/apps/backend/src/main/resources/db/migrations/003_bot.sql b/apps/backend/src/main/resources/db/migrations/003_bot.sql new file mode 100644 index 0000000..0119f3d --- /dev/null +++ b/apps/backend/src/main/resources/db/migrations/003_bot.sql @@ -0,0 +1,2 @@ +-- How the bot played in a game against the bot: { level, strength, seed }; NULL for games between people. +ALTER TABLE games ADD COLUMN bot jsonb; diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java new file mode 100644 index 0000000..a6ae07b --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java @@ -0,0 +1,250 @@ +package app.playmancala.bot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.SplittableRandom; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import app.playmancala.engine.GameState; +import app.playmancala.engine.Pit; +import app.playmancala.fakes.FakeRtmt; +import app.playmancala.fakes.InMemoryGameHistoryRepository; +import app.playmancala.fakes.InMemoryLiveGameStore; +import app.playmancala.game.BackendGame; +import app.playmancala.game.GameManager; +import app.playmancala.game.GameRecorder; +import app.playmancala.game.GameStore; +import app.playmancala.game.MatchMaker; +import app.playmancala.game.PublicIds; +import app.playmancala.loop.ManualScheduler; +import app.playmancala.model.AuthUser; +import app.playmancala.model.Channels; +import app.playmancala.model.EndReason; +import app.playmancala.model.GameClock; +import app.playmancala.model.GameRecord; +import app.playmancala.model.Json; +import app.playmancala.model.PlayerSummary; + +// Games against the bot, with the real game logic, a manual clock and searches that run at once. +class BotPlayerTest { + static final AuthUser ALICE = new AuthUser("kc:alice", "alice", false); + static final AuthUser CAROL = new AuthUser("kc:carol", "carol", false); + static final long CLOCK_MS = 5 * 60_000; + // Longer than any wait: the start delay or a long animation plus the longest thinking time. + static final long LONGEST_WAIT_MS = 20_000; + + ManualScheduler loop; + FakeRtmt rtmt; + GameStore gameStore; + InMemoryLiveGameStore liveGameStore; + InMemoryGameHistoryRepository repository; + GameManager gameManager; + BotPlayer botPlayer; + + void createBackend() { + rtmt = new FakeRtmt(); + gameStore = new GameStore(); + GameRecorder recorder = new GameRecorder(repository, liveGameStore, Runnable::run); + gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, Runnable::run, + loop::now, CLOCK_MS); + botPlayer = new BotPlayer(gameManager, gameStore, loop, Runnable::run, new SplittableRandom(1)); + rtmt.connect(ALICE); + rtmt.connect(CAROL); + } + + @BeforeEach + void setUp() { + loop = new ManualScheduler(Instant.parse("2026-10-16T12:00:00Z").toEpochMilli()); + liveGameStore = new InMemoryLiveGameStore(); + repository = new InMemoryGameHistoryRepository(); + createBackend(); + } + + // A game between Alice and the bot, with the given player to move first. + BackendGame startBotGame(BotSettings bot, String firstPlayerId, int... stones) { + BackendGame game = gameManager.createMancalaGame(ALICE.id(), bot.botId(), bot); + game.mancalaGame.setTurnPlayerId(firstPlayerId); + setBoard(game, stones); + gameManager.startGame(game); + return game; + } + + static void setBoard(BackendGame game, int... stones) { + List pits = game.mancalaGame.getBoard().getPits(); + for (int i = 0; i < stones.length; i++) pits.get(i).setStoneCount(stones[i]); + } + + void playAnyMove(BackendGame game, String playerId) { + List pits = game.mancalaGame.getBoard().getPits(); + for (int pit = 0; pit < 6; pit++) { + if (pits.get(game.mancalaGame.getBoardIndexByPlayerId(playerId, pit)).getStoneCount() > 0) { + rtmt.receive(playerId, Channels.GAME_MOVE, Map.of("index", pit)); + return; + } + } + throw new IllegalStateException("no move"); + } + + @Test + void startsAGameAgainstTheBotAtTheChosenLevel() { + rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "medium")); + BackendGame game = gameStore.getGameByUser(ALICE.id()); + assertNotNull(game); + assertEquals("bot:medium", game.mancalaGame.getPlayer2Id()); + assertEquals(BotLevel.MEDIUM, game.bot.level()); + assertTrue(game.bot.strength() >= 30 && game.bot.strength() <= 55); + assertEquals(new PlayerSummary("bot:medium", "Bot", false), game.player("bot:medium")); + + JsonNode start = rtmt.lastMessageTo(ALICE.id(), Channels.ON_GAME_START); + assertEquals(game.id, start.get("id").asText()); + JsonNode bot = start.at("/gameUsersConnectionInfo/user2ConnectionInfo"); + assertEquals(Json.tree(Map.of("userId", "bot:medium", "name", "Bot", "isGuest", false, "isOnline", true)), bot); + assertTrue(rtmt.sent.stream().noneMatch(message -> message.clientId().startsWith("bot:"))); + // The bot is not mapped to the game; it plays many at once. + assertNull(gameStore.getGameByUser("bot:medium")); + } + + @Test + void endsTheSearchForAnOpponent() { + rtmt.receive(ALICE.id(), Channels.NEW_GAME); + rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "easy")); + rtmt.receive(CAROL.id(), Channels.NEW_GAME); + assertEquals("bot:easy", gameStore.getGameByUser(ALICE.id()).mancalaGame.getPlayer2Id()); + assertNull(gameStore.getGameByUser(CAROL.id())); + } + + @Test + void refusesABotGameDuringAnotherGame() { + rtmt.receive(ALICE.id(), Channels.NEW_GAME); + rtmt.receive(CAROL.id(), Channels.NEW_GAME); + BackendGame game = gameStore.getGameByUser(ALICE.id()); + rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "hard")); + assertSame(game, gameStore.getGameByUser(ALICE.id())); + assertEquals(List.of(Json.tree(Map.of("gameId", game.id))), + rtmt.messagesTo(ALICE.id(), Channels.ON_NEW_GAME_REJECTED)); + } + + @Test + void ignoresUnknownLevels() { + rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, Map.of("level", "impossible")); + rtmt.receive(ALICE.id(), Channels.NEW_BOT_GAME, "master"); + assertNull(gameStore.getGameByUser(ALICE.id())); + } + + @Test + void movesAfterTheStartDelayAndThinkingTime() { + BackendGame game = startBotGame(new BotSettings(BotLevel.EASY, 15, 7), "bot:easy"); + loop.advance(BotPlayer.GAME_START_DELAY_MS + 800 - 1); + assertEquals(0, game.mancalaGame.getHistory().size()); + loop.advance(1000 + 1); + assertEquals(1, game.mancalaGame.getHistory().size()); + assertEquals("bot:easy", game.mancalaGame.getHistory().getFirst().getPlayerId()); + assertEquals(1, rtmt.messagesTo(ALICE.id(), Channels.ON_GAME_UPDATE).size()); + } + + @Test + void waitsForTheAnimationOfThePlayersMove() { + BackendGame game = startBotGame(new BotSettings(BotLevel.EASY, 15, 7), ALICE.id()); + // Pit 2 with 4 stones passes the turn: four steps to animate. + rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 2)); + assertTrue(game.isBotTurn()); + long animation = 5 * BotPlayer.ANIMATION_STEP_MS; + loop.advance(animation + 800 - 1); + assertEquals(1, game.mancalaGame.getHistory().size()); + loop.advance(1000 + 1); + assertEquals(2, game.mancalaGame.getHistory().size()); + } + + @Test + void playsAgainAfterAnExtraTurn() { + // The only move, pit 11 with 3 stones, ends in the bank and leaves stones in pits 11 and 12. + BackendGame game = startBotGame(new BotSettings(BotLevel.MASTER, 100, 3), "bot:master", + 3, 3, 3, 3, 3, 3, 10, 0, 0, 0, 0, 3, 0, 10); + loop.advance(LONGEST_WAIT_MS); + List movers = game.mancalaGame.getHistory().stream().map(move -> move.getPlayerId()).toList(); + assertEquals(List.of("bot:master", "bot:master"), movers.subList(0, 2)); + assertEquals(11, game.mancalaGame.getHistory().getFirst().getMoveIndex()); + } + + @Test + void dropsTheMoveWhenTheGameEndedMeanwhile() { + BackendGame game = startBotGame(new BotSettings(BotLevel.HARD, 70, 1), "bot:hard"); + rtmt.receive(ALICE.id(), Channels.LEAVE_GAME); + loop.advance(LONGEST_WAIT_MS); + assertEquals(0, game.mancalaGame.getHistory().size()); + assertEquals(0, loop.pendingTimers()); + GameRecord record = repository.records.get(game.id); + assertEquals(EndReason.LEFT, record.endReason()); + } + + @Test + void hurriesWhenItsClockRunsLow() { + BackendGame game = startBotGame(new BotSettings(BotLevel.MASTER, 90, 1), ALICE.id()); + game.clock = new GameClock(CLOCK_MS, CLOCK_MS, 20_000, loop.now()); + rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 2)); + loop.advance(BotPlayer.LOW_TIME_THINK_MS); + assertEquals(2, game.mancalaGame.getHistory().size()); + } + + @Test + void playsAWholeGameThatIsSavedWithTheBot() { + BackendGame game = startBotGame(new BotSettings(BotLevel.MEDIUM, 40, 11), ALICE.id()); + for (int turn = 0; turn < 200 && game.mancalaGame.getState() != GameState.ENDED; turn++) { + if (game.isBotTurn()) loop.advance(LONGEST_WAIT_MS); + else playAnyMove(game, ALICE.id()); + } + GameRecord record = repository.records.get(game.id); + assertNotNull(record, "the game did not end"); + assertEquals(EndReason.COMPLETED, record.endReason()); + assertEquals(new PlayerSummary("bot:medium", "Bot", false), record.player2()); + assertEquals(new BotSettings(BotLevel.MEDIUM, 40, 11), record.bot()); + assertTrue(record.moves().stream().anyMatch(move -> move.playerId().equals("bot:medium"))); + assertTrue(rtmt.sent.stream().noneMatch(message -> message.clientId().startsWith("bot:"))); + } + + @Test + void goesOnAfterARestartAtTheSameStrength() { + BotSettings bot = new BotSettings(BotLevel.HARD, 66, 99); + BackendGame game = startBotGame(bot, "bot:hard"); + assertEquals(Json.tree(Map.of("level", "hard", "strength", 66, "seed", 99L)), + liveGameStore.games.get(game.id).get("bot")); + + // A new process: the old loop and its timers are gone. + loop = new ManualScheduler(loop.now()); + createBackend(); + assertEquals(1, gameManager.restoreLiveGames()); + BackendGame restored = gameStore.get(game.id); + assertEquals(bot, restored.bot); + loop.advance(LONGEST_WAIT_MS); + assertEquals(1, restored.mancalaGame.getHistory().size()); + } + + @Test + void restoresABotGameWithoutSettingsAtTheMiddleOfItsLevel() { + BackendGame game = startBotGame(new BotSettings(BotLevel.EASY, 6, 5), ALICE.id()); + ((ObjectNode) liveGameStore.games.get(game.id)).remove("bot"); + + loop = new ManualScheduler(loop.now()); + createBackend(); + gameManager.restoreLiveGames(); + assertEquals(BotLevel.EASY, gameStore.get(game.id).bot.level()); + assertEquals(BotLevel.EASY.middleStrength(), gameStore.get(game.id).bot.strength()); + } + + @Test + void botIdsArePublic() { + assertEquals("bot:easy", PublicIds.publicUserId("bot:easy")); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotPolicyTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotPolicyTest.java new file mode 100644 index 0000000..68bf92e --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/bot/BotPolicyTest.java @@ -0,0 +1,126 @@ +package app.playmancala.bot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.SplittableRandom; + +import org.junit.jupiter.api.Test; + +import app.playmancala.engine.Position; + +class BotPolicyTest { + + @Test + void levelsHaveWireNamesAndBotIds() { + assertEquals(BotLevel.MEDIUM, BotLevel.fromWireName("medium")); + assertNull(BotLevel.fromWireName("impossible")); + assertEquals("bot:master", BotLevel.MASTER.botId()); + assertEquals(BotLevel.EASY, BotLevel.fromBotId("bot:easy")); + assertTrue(BotLevel.isBotId("bot:hard")); + assertNull(BotLevel.fromBotId("kc:someone")); + } + + @Test + void strengthsAreDrawnWithinTheLevel() { + SplittableRandom random = new SplittableRandom(1); + for (BotLevel level : BotLevel.values()) { + for (int i = 0; i < 500; i++) { + int strength = BotSettings.draw(level, random).strength(); + assertTrue(strength >= level.minStrength() && strength <= level.maxStrength(), level + " " + strength); + } + } + } + + @Test + void strengthIsInterpolatedFromTheTable() { + assertParams(1, 4, 0.35, BotStrength.at(0)); + assertParams(4, 1, 0.05, BotStrength.at(50)); + assertParams(16, 0.05, 0, BotStrength.at(100)); + BotStrength.Params between = BotStrength.at(37.5); + assertEquals(3, between.depth()); + assertEquals(1.5, between.temperature(), 1e-9); + assertEquals(BotStrength.at(100), BotStrength.at(130)); + } + + private static void assertParams(int depth, double temperature, double randomMoveChance, BotStrength.Params params) { + assertEquals(depth, params.depth()); + assertEquals(temperature, params.temperature(), 1e-9); + assertEquals(randomMoveChance, params.randomMoveChance(), 1e-9); + } + + @Test + void sameSeedAndMoveNumberGiveTheSameMove() { + Position position = Position.of(new int[] { 3, 5, 0, 7, 2, 4, 6, 1, 6, 0, 3, 5, 2, 4 }, true); + for (BotLevel level : BotLevel.values()) { + BotSettings bot = new BotSettings(level, level.middleStrength(), 42); + int first = BotPolicy.chooseMove(position, bot, 10); + for (int i = 0; i < 5; i++) assertEquals(first, BotPolicy.chooseMove(position, bot, 10)); + } + } + + @Test + void playsTheOnlyMove() { + Position position = Position.of(new int[] { 0, 0, 0, 0, 0, 3, 20, 1, 1, 1, 1, 1, 1, 19 }, true); + assertEquals(5, BotPolicy.chooseMove(position, new BotSettings(BotLevel.EASY, 5, 1), 0)); + } + + @Test + void easyMissesAClearBestMoveMuchMoreOftenThanMaster() { + // Pit 0 captures 10 stones; no other move comes close. + Position position = Position.of(new int[] { 1, 0, 2, 2, 2, 3, 9, 2, 2, 2, 2, 9, 2, 10 }, true); + int[] master = new int[Position.PIT_COUNT]; + int[] easy = new int[Position.PIT_COUNT]; + for (int seed = 0; seed < 200; seed++) { + master[BotPolicy.chooseMove(position, new BotSettings(BotLevel.MASTER, 100, seed), 0)]++; + easy[BotPolicy.chooseMove(position, new BotSettings(BotLevel.EASY, 5, seed), 0)]++; + } + assertTrue(master[0] >= 195, "Master: " + Arrays.toString(master)); + assertTrue(easy[0] <= 180, "Easy: " + Arrays.toString(easy)); + } + + @Test + void gamesAtTheSameLevelDiffer() { + // R2.5: 20 games against an opponent that always plays its first pit; the bot moves first. + Set> openings = new HashSet<>(); + SplittableRandom random = new SplittableRandom(3); + int[] pits = new int[Position.PIT_COUNT]; + for (int game = 0; game < 20; game++) { + BotSettings bot = BotSettings.draw(BotLevel.MEDIUM, random); + Position position = Position.initial(true); + List botMoves = new ArrayList<>(); + int moveNumber = 0; + while (!position.isOver() && botMoves.size() < 5) { + int pit; + if (position.isPlayer1ToMove()) { + pit = BotPolicy.chooseMove(position, bot, moveNumber); + botMoves.add(pit); + } else { + position.legalPits(pits); + pit = pits[0]; + } + position.play(pit, position); + moveNumber++; + } + openings.add(botMoves); + } + assertTrue(openings.size() > 1, "every game opened with " + openings); + } + + @Test + void picksMovesByTheirScores() { + BotSearch.Result result = new BotSearch.Result(new int[] { 0, 2, 4 }, new int[] { 0, 500, 100 }, 1, 0); + // At a low temperature the best move takes almost all the weight. + assertEquals(2, BotPolicy.pick(result, 0.05, 0.01)); + assertEquals(2, BotPolicy.pick(result, 0.05, 0.999)); + // At a high temperature every move can be drawn. + assertEquals(0, BotPolicy.pick(result, 100, 0.0)); + assertEquals(4, BotPolicy.pick(result, 100, 0.999)); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotSearchTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotSearchTest.java new file mode 100644 index 0000000..d8fc568 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/bot/BotSearchTest.java @@ -0,0 +1,82 @@ +package app.playmancala.bot; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import app.playmancala.engine.Position; + +class BotSearchTest { + + private static int bestPit(BotSearch.Result result) { + int best = 0; + for (int i = 1; i < result.count(); i++) { + if (result.scores()[i] > result.scores()[best]) best = i; + } + return result.pits()[best]; + } + + @Test + void scoresEveryLegalMove() { + BotSearch.Result result = BotSearch.scoreMoves(Position.initial(true), 3); + assertArrayEquals(new int[] { 0, 1, 2, 3, 4, 5 }, result.pits()); + assertEquals(6, result.scores().length); + assertEquals(3, result.depth()); + } + + @Test + void takesTheExtraTurn() { + // Pit 3 ends in the bank and gives another move; pits 4 and 5 gain a stone too but pass the turn. + BotSearch.Result result = BotSearch.scoreMoves(Position.initial(true), 2); + assertEquals(3, bestPit(result)); + } + + @Test + void capturesTheStonesOpposite() { + // Pit 0's single stone lands in the empty pit 1, opposite pit 11 with 9 stones. + Position position = Position.of(new int[] { 1, 0, 2, 2, 2, 3, 9, 2, 2, 2, 2, 9, 2, 10 }, true); + assertEquals(0, bestPit(BotSearch.scoreMoves(position, 1))); + } + + @Test + void takesAnEvenPitOfTheOpponent() { + // Pit 5 with 3 stones: 5, the bank and pit 7, which then holds 6. + Position position = Position.of(new int[] { 1, 1, 1, 1, 1, 3, 10, 5, 1, 1, 1, 1, 1, 20 }, true); + BotSearch.Result result = BotSearch.scoreMoves(position, 1); + assertEquals(5, bestPit(result)); + } + + @Test + void seesAWinTwoMovesAhead() { + // Pit 5's stone goes to the bank; with the extra turn, pit 4's stone captures pit 7, the side is empty and + // takes the opponent's stones. Playing pit 4 first passes the turn. + Position position = Position.of(new int[] { 0, 0, 0, 0, 1, 1, 21, 1, 1, 1, 1, 1, 1, 19 }, true); + BotSearch.Result result = BotSearch.scoreMoves(position, 2); + int best = 0; + for (int i = 1; i < result.count(); i++) { + if (result.scores()[i] > result.scores()[best]) best = i; + } + assertEquals(5, result.pits()[best]); + assertTrue(result.scores()[best] > BotSearch.WIN, "a won game: " + result.scores()[best]); + } + + @Test + void sameInputGivesTheSameScores() { + Position position = Position.of(new int[] { 3, 5, 0, 7, 2, 4, 6, 1, 6, 0, 3, 5, 2, 4 }, false); + BotSearch.Result first = BotSearch.scoreMoves(position, 8); + BotSearch.Result second = BotSearch.scoreMoves(position, 8); + assertArrayEquals(first.scores(), second.scores()); + assertEquals(first.nodes(), second.nodes()); + } + + @Test + void stopsAtTheNodeBudgetWithTheLastCompletedDepth() { + BotSearch.Result result = BotSearch.scoreMoves(Position.initial(true), 30, 20_000); + assertTrue(result.depth() >= 1 && result.depth() < 30, "depth " + result.depth()); + assertEquals(6, result.scores().length); + // Stops within the depth that ran out of budget. + assertTrue(result.nodes() < 20_000 + 20_000, "nodes " + result.nodes()); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/bot/BotTournamentTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotTournamentTest.java new file mode 100644 index 0000000..bcbad53 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/bot/BotTournamentTest.java @@ -0,0 +1,88 @@ +package app.playmancala.bot; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.SplittableRandom; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import app.playmancala.engine.Position; + +// Plays the levels against each other (R2.4). The full tournament is slow and tagged `calibration`, so it is left +// out of the normal tests; run it with ./mvnw test -Dgroups=calibration -DexcludedGroups=none after changing the +// search or the strength table. +class BotTournamentTest { + + record Score(int wins, int losses, int draws, long moves, long nanos) { + int games() { + return wins + losses + draws; + } + + double winRate() { + return (wins + draws / 2.0) / games(); + } + } + + @Test + void masterBeatsEasy() { + Score score = play(BotLevel.MASTER, BotLevel.EASY, 20, 1); + assertTrue(score.wins() >= 19, () -> "Master against Easy: " + score); + } + + @Test + @Tag("calibration") + void strongerLevelsWin() { + int games = Integer.getInteger("bot.tournament.games", 200); + BotLevel[] levels = BotLevel.values(); + StringBuilder report = new StringBuilder(); + boolean ok = true; + for (int i = 1; i < levels.length; i++) { + Score score = play(levels[i], levels[i - 1], games, 100 + i); + report.append(line(levels[i], levels[i - 1], score)); + ok &= score.winRate() >= 0.65; + } + Score masterEasy = play(BotLevel.MASTER, BotLevel.EASY, games, 200); + report.append(line(BotLevel.MASTER, BotLevel.EASY, masterEasy)); + ok &= masterEasy.winRate() >= 0.95; + System.out.println(report); + assertTrue(ok, report::toString); + } + + private static String line(BotLevel stronger, BotLevel weaker, Score score) { + return String.format("%-6s vs %-6s won %3d lost %3d drawn %3d rate %.2f %.1f ms/move%n", stronger, weaker, + score.wins(), score.losses(), score.draws(), score.winRate(), + score.nanos() / 1e6 / Math.max(1, score.moves())); + } + + // Plays `games` games with strengths drawn per game, alternating who moves first. + static Score play(BotLevel first, BotLevel second, int games, long seed) { + SplittableRandom random = new SplittableRandom(seed); + int wins = 0, losses = 0, draws = 0; + long moves = 0, nanos = 0; + for (int game = 0; game < games; game++) { + BotSettings a = BotSettings.draw(first, random); + BotSettings b = BotSettings.draw(second, random); + boolean aIsPlayer1 = game % 2 == 0; + Position position = Position.initial(random.nextBoolean()); + int moveNumber = 0; + while (!position.isOver()) { + boolean aToMove = position.isPlayer1ToMove() == aIsPlayer1; + long start = System.nanoTime(); + int pit = BotPolicy.chooseMove(position, aToMove ? a : b, moveNumber); + if (aToMove) { + nanos += System.nanoTime() - start; + moves++; + } + position.play(pit, position); + moveNumber++; + } + int player1Lead = position.stonesAt(Position.PLAYER1_BANK) - position.stonesAt(Position.PLAYER2_BANK); + int aLead = aIsPlayer1 ? player1Lead : -player1Lead; + if (aLead > 0) wins++; + else if (aLead < 0) losses++; + else draws++; + } + return new Score(wins, losses, draws, moves, nanos); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/engine/PositionTest.java b/apps/backend/src/test/java/app/playmancala/engine/PositionTest.java new file mode 100644 index 0000000..566d3ae --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/engine/PositionTest.java @@ -0,0 +1,106 @@ +package app.playmancala.engine; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.util.SplittableRandom; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; + +import app.playmancala.model.Json; + +// Position must play exactly like MancalaGame: the recorded games from the TypeScript engine and random games are +// played on both, move by move. +class PositionTest { + + @Test + void startsWithFourStonesInEveryPit() { + Position position = Position.initial(true); + assertArrayEquals(new CommonMancalaGame("g", "a", "b").getBoard().getStoneArray(), position.stones()); + assertTrue(position.isPlayer1ToMove()); + int[] pits = new int[Position.PIT_COUNT]; + assertEquals(6, position.legalPits(pits)); + } + + @Test + void lastStoneInOwnBankGivesAnExtraTurn() { + Position position = Position.initial(true); + // Pit 3 with 4 stones: 3, 4, 5 and the bank. + assertTrue(position.play(3, position)); + assertArrayEquals(new int[] { 4, 4, 4, 1, 5, 5, 1, 4, 4, 4, 4, 4, 4, 0 }, position.stones()); + assertTrue(position.isPlayer1ToMove()); + } + + @Test + void playingIntoAnotherPositionLeavesThisOneAlone() { + Position position = Position.initial(false); + Position next = Position.initial(true); + assertFalse(position.play(0, next)); + assertEquals(Position.initial(false), position); + assertTrue(next.isPlayer1ToMove()); + } + + @Test + void refusesEmptyPitsAndBanks() { + Position position = Position.of(new int[] { 0, 1, 0, 0, 0, 0, 20, 1, 0, 0, 0, 0, 0, 26 }, true); + assertThrows(IllegalArgumentException.class, () -> position.play(0, position)); + assertThrows(IllegalArgumentException.class, () -> position.play(6, position)); + } + + @Test + void playsTheRecordedGamesLikeTheEngine() throws IOException { + JsonNode games; + try (InputStream in = getClass().getResourceAsStream("/engine/recorded-games.json")) { + games = Json.MAPPER.readTree(in).get("games"); + } + for (JsonNode recorded : games) { + String player1 = recorded.get("player1Id").asText(); + String player2 = recorded.get("player2Id").asText(); + MancalaGame game = new CommonMancalaGame("g", player1, player2); + game.setTurnPlayerId(recorded.get("turnPlayerId").asText()); + int number = 0; + for (JsonNode move : recorded.get("moves")) { + number++; + // Refused moves, and moves after the end, which the engine ignores. + if (move.has("error") || game.getState() == GameState.ENDED) continue; + String player = move.get("playerId").asText(); + playOnBoth(game, player, move.get("pitIndex").asInt(), recorded.get("id").asText() + " move " + number); + } + } + } + + @Test + void playsRandomGamesLikeTheEngine() { + SplittableRandom random = new SplittableRandom(7); + int[] pits = new int[Position.PIT_COUNT]; + for (int gameNumber = 0; gameNumber < 3000; gameNumber++) { + MancalaGame game = new CommonMancalaGame("g", "p1", "p2"); + game.setTurnPlayerId(random.nextBoolean() ? "p1" : "p2"); + int moves = 0; + while (game.getState() != GameState.ENDED) { + Position position = Position.of(game); + int count = position.legalPits(pits); + int pit = pits[random.nextInt(count)]; + playOnBoth(game, game.getTurnPlayerId(), pit, "game " + gameNumber + " move " + ++moves); + } + } + } + + private static void playOnBoth(MancalaGame game, String player, int pit, String where) { + Position position = Position.of(game); + assertEquals(game.isTurnPlayer1(), position.isPlayer1ToMove(), where); + boolean extraTurn = position.play(pit, position); + game.moveByPlayerPit(player, pit); + assertArrayEquals(game.getBoard().getStoneArray(), position.stones(), where); + assertEquals(game.isTurnPlayer1(), position.isPlayer1ToMove(), where); + assertEquals(player.equals(game.getTurnPlayerId()), extraTurn, where); + assertEquals(game.checkGameIsEnded(), position.isOver(), where); + } +} diff --git a/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java b/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java index dcf8bb9..a68a211 100644 --- a/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java +++ b/apps/backend/src/test/java/app/playmancala/history/PostgresGameHistoryRepositoryTest.java @@ -19,6 +19,8 @@ import org.junit.jupiter.api.Test; import com.fasterxml.jackson.databind.JsonNode; +import app.playmancala.bot.BotLevel; +import app.playmancala.bot.BotSettings; import app.playmancala.engine.GameStep; import app.playmancala.model.EndReason; import app.playmancala.model.FinishedGameSummary; @@ -88,7 +90,7 @@ class PostgresGameHistoryRepositoryTest { ResultSet rows = statement.executeQuery("SELECT version FROM schema_migrations ORDER BY version")) { while (rows.next()) versions.add(rows.getString(1)); } - assertEquals(List.of("001_games", "002_clock"), versions); + assertEquals(List.of("001_games", "002_clock", "003_bot"), versions); } @Test @@ -115,6 +117,20 @@ class PostgresGameHistoryRepositoryTest { assertFalse(saved.at("/moves/0").has("remainingMs")); } + @Test + void keepsHowTheBotPlayed() { + GameRecord game = record(ALICE, new PlayerSummary("bot:hard", "Bot", false)); + GameRecord botGame = new GameRecord(game.id(), game.player1(), game.player2(), game.board(), game.result(), + game.endReason(), null, game.startedAt(), game.endedAt(), game.moveCount(), game.peakSpectators(), null, + game.moves(), game.totalSpectators(), new BotSettings(BotLevel.HARD, 72, -5_123_456_789_012L)); + repository.save(botGame); + assertEquals(Json.tree(botGame), Json.tree(repository.get(game.id()))); + assertEquals("bot:hard", repository.getRecentForUser(ALICE.userId(), 1).getFirst().player2().userId()); + GameRecord betweenPeople = record(); + repository.save(betweenPeople); + assertFalse(Json.tree(repository.get(betweenPeople.id())).has("bot")); + } + @Test void keepsLeftUserIdForGamesAPlayerLeft() { GameRecord game = record(ALICE, BOB, GameResult.PLAYER2, EndReason.LEFT, ALICE.userId(), 0, 0, null, null); diff --git a/apps/frontend/src/components/BotLevelMenu.tsx b/apps/frontend/src/components/BotLevelMenu.tsx new file mode 100644 index 0000000..6a43278 --- /dev/null +++ b/apps/frontend/src/components/BotLevelMenu.tsx @@ -0,0 +1,36 @@ +import { Menu, MenuHeader, MenuItem } from "@szhsin/react-menu"; +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { BOT_LEVELS, BotLevel, getBotLevelTextKey, getLastBotLevel, setLastBotLevel } from "@mancala/core"; +import { Context } from "../context/context"; +import { BotIcon } from "./Icons"; +import "@szhsin/react-menu/dist/index.css"; +import "@szhsin/react-menu/dist/transitions/slide.css" + +// "Play with bot" in the header: a menu of the levels, the one chosen last marked. +const BotLevelMenu: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => { + const { t } = useTranslation(); + const lastLevel = getLastBotLevel(context.storage); + const menuButton = ( + + ); + const choose = (level: BotLevel) => { + setLastBotLevel(context.storage, level); + onChoose(level); + }; + return ( + + {t("ChooseBotLevel")} + {BOT_LEVELS.map((level) => ( + choose(level)}> + {t(getBotLevelTextKey(level))} + + ))} + + ); +}; + +export default BotLevelMenu; diff --git a/apps/frontend/src/components/BotLevelPicker.tsx b/apps/frontend/src/components/BotLevelPicker.tsx new file mode 100644 index 0000000..e190d71 --- /dev/null +++ b/apps/frontend/src/components/BotLevelPicker.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import { FunctionComponent } from "react"; +import { useTranslation } from "react-i18next"; +import { BOT_LEVELS, BotLevel, getBotLevelTextKey, getLastBotLevel, setLastBotLevel } from "@mancala/core"; +import { Context } from "../context/context"; + +// The four bot levels as buttons; the level chosen last is highlighted and remembered on this device. +const BotLevelPicker: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => { + const { t } = useTranslation(); + const lastLevel = getLastBotLevel(context.storage); + const choose = (level: BotLevel) => { + setLastBotLevel(context.storage, level); + onChoose(level); + }; + return ( +
+ {BOT_LEVELS.map((level) => ( + + ))} + +
+ ); +}; + +export default BotLevelPicker; diff --git a/apps/frontend/src/components/Icons.tsx b/apps/frontend/src/components/Icons.tsx index 49fadab..3f7e5c0 100644 --- a/apps/frontend/src/components/Icons.tsx +++ b/apps/frontend/src/components/Icons.tsx @@ -67,3 +67,7 @@ export const PlayIcon = ({ size = 22 }: IconProps) => ( export const PauseIcon = ({ size = 22 }: IconProps) => ( ); + +export const BotIcon = ({ size = 20 }: IconProps) => ( + +); diff --git a/apps/frontend/src/components/game/GameOverCard.tsx b/apps/frontend/src/components/game/GameOverCard.tsx index cd01de7..23e3789 100644 --- a/apps/frontend/src/components/game/GameOverCard.tsx +++ b/apps/frontend/src/components/game/GameOverCard.tsx @@ -9,9 +9,11 @@ const GameOverCard: FunctionComponent<{ title: string, reason?: string, score: string, + // "New Game" unless given, e.g. "Play again" after a game against the bot. + newGameLabel?: string, onNewGame: () => void, onClose: () => void, -}> = ({ title, reason, score, onNewGame, onClose }) => { +}> = ({ title, reason, score, newGameLabel, onNewGame, onClose }) => { const { t } = useTranslation(); const navigate = useNavigate(); return ( @@ -22,7 +24,7 @@ const GameOverCard: FunctionComponent<{ {reason &&
{reason}
}
{score}
- +
diff --git a/apps/frontend/src/routes/GamePage.tsx b/apps/frontend/src/routes/GamePage.tsx index 7f33d2e..bd3f812 100644 --- a/apps/frontend/src/routes/GamePage.tsx +++ b/apps/frontend/src/routes/GamePage.tsx @@ -12,7 +12,7 @@ import { LeaveIcon, PlusIcon } from '../components/Icons'; import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove } from '@mancala/core'; import { Context } from '../context/context'; import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; -import { Theme, getPlayerDisplayName } from '@mancala/core'; +import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core'; import { BoardViewModel } from "@mancala/core"; import notyf from '../util/Notyf'; import swal from 'sweetalert'; @@ -191,6 +191,14 @@ const GamePage: FunctionComponent<{ navigate("/loby") }; + // In a game against the bot, the game over card starts another one at the same level. + const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId()); + + const onPlayAgainClick = () => { + if (Util.checkConnectionAndMaybeAlert(context, t)) return; + navigate(`/loby?bot=${opponentBotLevel}`) + }; + const onPitSelect = (index: number, pit: Pit) => { if (!game || isSpectator || !userKey) { return; @@ -386,7 +394,8 @@ const GamePage: FunctionComponent<{ title={resultTitle ?? t("GameEnded")} reason={resultReason} score={`${bottomLocatedUser.name} ${board.pits[bottomStore].stoneCount} – ${board.pits[topStore].stoneCount} ${topLocatedUser.name}`} - onNewGame={onNewGameClick} + newGameLabel={opponentBotLevel ? t("PlayAgain") : undefined} + onNewGame={opponentBotLevel ? onPlayAgainClick : onNewGameClick} onClose={() => setGameOverClosed(true)} /> )} diff --git a/apps/frontend/src/routes/Home.tsx b/apps/frontend/src/routes/Home.tsx index e61acf6..d6e0690 100644 --- a/apps/frontend/src/routes/Home.tsx +++ b/apps/frontend/src/routes/Home.tsx @@ -1,10 +1,11 @@ import * as React from "react"; import { FunctionComponent, useEffect, useState } from "react"; import { LiveGameSummary } from "@mancala/core"; -import { AuthUser, Theme } from "@mancala/core"; +import { AuthUser, BotLevel, Theme } from "@mancala/core"; import AppHeader from "../components/headerbar/AppHeader"; import PageContainer from "../components/PageContainer"; import { PlusIcon } from "../components/Icons"; +import BotLevelMenu from "../components/BotLevelMenu"; import ActiveGameBanner from "../components/home/ActiveGameBanner"; import LiveGamesSection from "../components/home/LiveGamesSection"; import RecentGamesSection from "../components/home/RecentGamesSection"; @@ -43,9 +44,15 @@ const Home: FunctionComponent<{ navigate("/loby") }; + const onBotLevelChosen = (level: BotLevel) => { + if(Util.checkConnectionAndMaybeAlert(context, t)) return; + navigate(`/loby?bot=${level}`) + }; + return ( + diff --git a/apps/frontend/src/routes/LobyPage.tsx b/apps/frontend/src/routes/LobyPage.tsx index f5e7f38..49371f3 100644 --- a/apps/frontend/src/routes/LobyPage.tsx +++ b/apps/frontend/src/routes/LobyPage.tsx @@ -1,10 +1,11 @@ import { CommonMancalaGame } from 'mancala.js'; import * as React from 'react'; import { FunctionComponent, useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import AppHeader from '../components/headerbar/AppHeader'; import PageContainer from '../components/PageContainer'; -import { channel_cancel_new_game, channel_on_game_start, channel_on_new_game_rejected } from '@mancala/core'; +import { BOT_OFFER_DELAY_MS, BotLevel, channel_cancel_new_game, channel_new_bot_game, channel_new_game, channel_on_game_start, channel_on_new_game_rejected, isBotLevel } from '@mancala/core'; +import BotLevelPicker from '../components/BotLevelPicker'; import { Context } from '../context/context'; import { Theme } from '@mancala/core'; import { useTranslation } from 'react-i18next'; @@ -19,9 +20,17 @@ const LobyPage: FunctionComponent<{ const { t } = useTranslation(); + // /loby?bot= starts a game against the bot at once instead of searching for an opponent. + const [searchParams] = useSearchParams(); + const botParam = searchParams.get("bot"); + const botLevel: BotLevel | undefined = isBotLevel(botParam) ? botParam : undefined; + // Set when the server refuses a new game because the user has one in progress. const [activeGameId, setActiveGameId] = useState(undefined); + // Shown when no opponent has been found for a while. + const [botOffered, setBotOffered] = useState(false); + const onNewGameRejected = (message: { gameId: string }) => setActiveGameId(message.gameId); const onGameStart = (message: Object) => { @@ -29,6 +38,10 @@ const LobyPage: FunctionComponent<{ navigate(`/game/${newGame.id}`) } + const startBotGame = (level: BotLevel) => { + context.rtmt.sendMessage(channel_new_bot_game, { level }); + } + const onCancelNewGameClick = () => { if (context.rtmt.connectionState === "connected") { context.rtmt.sendMessage(channel_cancel_new_game, ""); @@ -38,8 +51,15 @@ const LobyPage: FunctionComponent<{ useEffect(() => { context.rtmt.addMessageListener(channel_on_game_start, onGameStart); context.rtmt.addMessageListener(channel_on_new_game_rejected, onNewGameRejected); - context.rtmt.sendMessage("new_game", {}); + let botOfferTimer: ReturnType | undefined; + if (botLevel) { + startBotGame(botLevel); + } else { + context.rtmt.sendMessage(channel_new_game, {}); + botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS); + } return () => { + if (botOfferTimer) clearTimeout(botOfferTimer); context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); context.rtmt.removeMessageListener(channel_on_new_game_rejected, onNewGameRejected); } @@ -51,8 +71,14 @@ const LobyPage: FunctionComponent<{
{!activeGameId && } -

{activeGameId ? t("GameInProgress") : t("SearchingOpponent")}

+

{activeGameId ? t("GameInProgress") : botLevel ? t("PlayWithBot") : t("SearchingOpponent")}

{!activeGameId &&

{`${t("PleaseWait")}...`}

} + {!activeGameId && botOffered && ( +
+

{t("NoOpponentYet")}

+ +
+ )}
{activeGameId && ( @@ -90,6 +116,14 @@ const LobyPage: FunctionComponent<{ margin: 0; color: var(--muted); } + .offer { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; + padding-top: 16px; + border-top: 1px solid var(--line); + } .actions { display: flex; gap: 10px; diff --git a/apps/mobile/src/components/BotLevelPicker.tsx b/apps/mobile/src/components/BotLevelPicker.tsx new file mode 100644 index 0000000..6cbda7a --- /dev/null +++ b/apps/mobile/src/components/BotLevelPicker.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { FunctionComponent } from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { BOT_LEVELS, BotLevel, getBotLevelTextKey, getLastBotLevel, setLastBotLevel } from '@mancala/core'; +import { Context } from '../context/context'; +import CircularPanel from './CircularPanel'; + +// The four bot levels; the level chosen last is highlighted and remembered on the device. +const BotLevelPicker: FunctionComponent<{ context: Context, onChoose: (level: BotLevel) => void }> = ({ context, onChoose }) => { + const { t } = useTranslation(); + const theme = context.themeManager.theme; + const lastLevel = getLastBotLevel(context.storage); + return ( + + {BOT_LEVELS.map((level) => ( + { + setLastBotLevel(context.storage, level); + onChoose(level); + }}> + {t(getBotLevelTextKey(level))}} /> + + ))} + + ); +}; + +export default BotLevelPicker; diff --git a/apps/mobile/src/screens/GameScreen.tsx b/apps/mobile/src/screens/GameScreen.tsx index d05709e..404b432 100644 --- a/apps/mobile/src/screens/GameScreen.tsx +++ b/apps/mobile/src/screens/GameScreen.tsx @@ -3,7 +3,7 @@ import { View, Text, useWindowDimensions, Alert, Pressable } from 'react-native' import { useTranslation } from 'react-i18next'; import { GameScreenProps } from '../types'; import { useState } from 'react'; -import { Game, GameUsersConnectionInfo, GameMove, LoadingState, BoardViewModel, PitAnimator, getColorByBrightness, GameEndMessage, getSeat } from "@mancala/core"; +import { Game, GameUsersConnectionInfo, GameMove, LoadingState, BoardViewModel, PitAnimator, getColorByBrightness, GameEndMessage, getSeat, getBotLevel, getPlayerDisplayName } from "@mancala/core"; import { MancalaGame, Pit } from 'mancala.js'; import { v4 } from 'uuid'; import { channel_on_game_update, channel_on_game_crashed, channel_on_game_user_leave, channel_on_user_connection_change, channel_listen_game_events, channel_unlisten_game_events, channel_leave_game, channel_game_move, channel_on_game_end } from '@mancala/core'; @@ -159,10 +159,12 @@ export function GameScreen({ navigation, route }: GameScreenProps) { }); }; + // In a game against the bot, a new game is another one at the same level. const onNewGameClick = () => { if (Util.checkConnectionAndMaybeAlert(context, t("ConnectionLost"))) return; context.storage.deleteString('lastGameId'); - navigation.replace("Loby", { context }) + const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId()); + navigation.replace("Loby", { context, botLevel: opponentBotLevel }) }; const onPitSelect = (index: number, pit: Pit) => { @@ -250,7 +252,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) { onLeaveGameClick(); } }}> - {isError ? t('Home') : (isGameEnded ? t('NewGame') : t('Leave'))}} /> + {isError ? t('Home') : (isGameEnded ? (!isSpectator && getBotLevel(getOpponentId()) ? t('PlayAgain') : t('NewGame')) : t('Leave'))}} /> { navigation.push("Help", { context }) @@ -278,17 +280,20 @@ export function GameScreen({ navigation, route }: GameScreenProps) { const showBoardView = game && boardViewModel && userKey && true; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; + // Players have no names on mobile yet (legacy users), except the bot. + const topBotName = getPlayerDisplayName({ userId: topLocatedUserId }, t); + const bottomBotName = getPlayerDisplayName({ userId: bottomLocatedUserId }, t); const topLocatedUser = { id: topLocatedUserId, - name: "Anonymous", + name: topBotName ?? "Anonymous", isOnline: isUserOnline(topLocatedUserId), - isAnonymous: true + isAnonymous: !topBotName }; const bottomLocatedUser = { id: bottomLocatedUserId, - name: "Anonymous", + name: bottomBotName ?? "Anonymous", isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected", - isAnonymous: true + isAnonymous: !bottomBotName }; const currentUser = isSpectator ? { id: "2", diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx index aa7757b..0bbf6b4 100644 --- a/apps/mobile/src/screens/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen.tsx @@ -3,6 +3,7 @@ import { View, Button, Pressable, Text } from 'react-native'; import { useTranslation } from 'react-i18next'; import { HomeScreenProps } from '../types'; import CircularPanel from '../components/CircularPanel'; +import BotLevelPicker from '../components/BotLevelPicker'; import { SvgXml } from 'react-native-svg'; import helpSvg from '../svg/help'; import { getColorByBrightness } from "@mancala/core"; @@ -12,6 +13,7 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) { const { context } = route.params; const { t } = useTranslation(); const [lastGameId, setLastGameId] = React.useState(undefined); + const [choosingBotLevel, setChoosingBotLevel] = React.useState(false); const textColorOnAppBar = getColorByBrightness( context.themeManager.theme.appBarBgColor, @@ -44,6 +46,17 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) { navigation.replace('Loby', { context })}> {t('NewGame')}} /> + + {choosingBotLevel ? ( + + {t('ChooseBotLevel')} + navigation.replace('Loby', { context, botLevel })} /> + + ) : ( + setChoosingBotLevel(true)}> + {t('PlayWithBot')}} /> + + )} {lastGameId && ( <> diff --git a/apps/mobile/src/screens/LobyScreen.tsx b/apps/mobile/src/screens/LobyScreen.tsx index 7c5bf78..76648d4 100644 --- a/apps/mobile/src/screens/LobyScreen.tsx +++ b/apps/mobile/src/screens/LobyScreen.tsx @@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next'; import { LobyScreenProps } from '../types'; import { useEffect } from 'react'; import { CommonMancalaGame } from 'mancala.js'; -import { channel_cancel_new_game, channel_on_game_start } from '@mancala/core'; +import { BOT_OFFER_DELAY_MS, BotLevel, channel_cancel_new_game, channel_new_bot_game, channel_new_game, channel_on_game_start } from '@mancala/core'; +import BotLevelPicker from '../components/BotLevelPicker'; import CircularPanel from '../components/CircularPanel'; import { getColorByBrightness } from "@mancala/core"; import { SvgXml } from 'react-native-svg'; @@ -12,7 +13,13 @@ import helpSvg from '../svg/help'; export default function LobyScreen({ navigation, route }: LobyScreenProps) { - const { context } = route.params; + const { context, botLevel } = route.params; + // Shown when no opponent has been found for a while. + const [botOffered, setBotOffered] = React.useState(false); + + const startBotGame = (level: BotLevel) => { + context.rtmt.sendMessage(channel_new_bot_game, { level }); + } const { t } = useTranslation(); const onGameStart = async (message: Object) => { @@ -32,8 +39,15 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) { useEffect(() => { context.rtmt.addMessageListener(channel_on_game_start, onGameStart); - context.rtmt.sendMessage("new_game", {}); + let botOfferTimer: ReturnType | undefined; + if (botLevel) { + startBotGame(botLevel); + } else { + context.rtmt.sendMessage(channel_new_game, {}); + botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS); + } return () => { + if (botOfferTimer) clearTimeout(botOfferTimer); context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); } }, []); @@ -62,7 +76,13 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) { return ( - {t('SearchingOpponent') + " " + t('PleaseWait') + "..."}} /> + {(botLevel ? t('PlayWithBot') : t('SearchingOpponent')) + " " + t('PleaseWait') + "..."}} /> + {botOffered && ( + + {t('NoOpponentYet')} + + + )} {t('Cancel')}} /> diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts index 4b2db02..190597a 100644 --- a/apps/mobile/src/types/index.ts +++ b/apps/mobile/src/types/index.ts @@ -1,9 +1,11 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { BotLevel } from '@mancala/core'; import { Context } from '../context/context'; export type RootStackParamList = { Home: { context: Context }, - Loby: { context: Context }, + // botLevel: start a game against the bot at once instead of searching for an opponent. + Loby: { context: Context, botLevel?: BotLevel }, Game: { context: Context, gameId: string, userKey: string } Help: { context: Context } }; diff --git a/docs/specs/game-bot/design.md b/docs/specs/game-bot/design.md new file mode 100644 index 0000000..82f9c69 --- /dev/null +++ b/docs/specs/game-bot/design.md @@ -0,0 +1,239 @@ +# Game bot: design + +See `requirements.md` for what is built; requirement ids (R1.1, …) are referenced below. Builds on `game-clock` +(the clock), `game-history` (saved games, live previews) and `quarkus-backend` (the Java backend and its engine). + +## Decisions + +| Decision | Reason | +|---|---| +| The bot runs in the backend, in Java | The rules are already in Java (`app.playmancala.engine`) and kept equal to the TypeScript engine by `EngineParityTest`. A bot game is then an ordinary server game: the clock, saving, spectators, previews and restarts work without changes in the clients (R4). A bot on the device would only add offline play, at the cost of a second bot in TypeScript (out of scope). | +| A compact board, `Position` (`int[14]` and whose turn it is), in the engine package | Searching means playing millions of moves; `MancalaGame` builds `Pit` objects, lists and animation steps for each, which is far too slow. `Position` plays a move into an existing array without allocating. It is in `engine`, not `bot`, so other code can use it too, e.g. checking a move before running it (see `TODO.md`, Security). It is tested against `MancalaGame` with the recorded parity games and random games. | +| `MancalaGame` is not rebuilt on `Position` now | `MancalaGame` also produces the game steps clients animate, the history and the JSON the Node backend wrote, and it must stay equal to the TypeScript engine. Rebuilding it is a separate refactor once `Position` has proven itself (out of scope). | +| Negamax with alpha-beta pruning, limited by depth and a node budget, not by time | Mangala is small (6 moves at most per turn), so alpha-beta is strong enough: at depth 12 the bot plays close to perfectly. Limits that do not depend on time make a move depend only on the position, the strength and the seed, so a game can be reproduced for debugging; a slower machine only takes longer. | +| Strength (0–100) sets three things: search depth, how much the bot prefers the best move (softmax temperature) and how often it plays a random move | Depth alone gives few, coarse steps and a predictable bot. With softmax, a weaker bot more often plays a move that is nearly as good, which looks like a human mistake; the random moves make Easy beatable by beginners (R2.3). Interpolating a table gives smooth steps between levels. | +| Each level is a range of strengths; the strength is drawn per game and varies by ±5 per move | R2.2. The ranges have gaps between them so neighbouring levels stay clearly apart (R2.4). | +| Randomness comes from a per-game seed and the move number | The bot's choice of a move is reproducible from the saved game, and a restart does not change how it plays (R4.6). The seed and strength are kept with the live game and the saved game. | +| Searching runs on its own small thread pool; the result goes back to the game loop, which checks that the game is still waiting for that move | The game loop must never wait (R3.5, `AGENTS.md`). The pool has a fixed number of threads, so many bot games share the CPU instead of starving the loop and the WebSocket threads. A result is dropped if the game ended or moved on meanwhile. | +| The bot's waiting time is computed on the server: the previous move's animation (300 ms per step, as `PitAnimator`) plus a random 1–3 seconds | R3.2. Clients do not need to know about bots for this. Searching happens during the wait, so the time it takes is not added to it. | +| Bot ids are `bot:`, e.g. `bot:medium` | One stable id per level reads well in saved games and lets later statistics group games by level. It is public and cannot belong to a person: Keycloak ids start with `kc:` and legacy ids must be UUIDs (`RtmtServer`). Because one id plays many games at once, `GameStore` does not map it to a game. Clients recognise bots by the id, so `PlayerSummary` keeps its shape. | +| The offer to play the bot (R1.1) is a timer in the client | The server does not need to know how long someone has waited. Matchmaking stays as it is; choosing a level sends a new message that also ends the search on the server. | +| Bot games are ordinary games everywhere: clock, saving, spectators, live previews, popular games | R4. Fewer special cases. The bot's moves go through the same code as a player's moves (R3.1). | + +## Shared types (`packages/core`) + +```ts +// rtmt/channel_names.ts +export const channel_new_bot_game = "new_bot_game"; // client -> server: { level: BotLevel } + +// models/Bot.ts +export type BotLevel = "easy" | "medium" | "hard" | "master"; +export const BOT_LEVELS: BotLevel[] = ["easy", "medium", "hard", "master"]; +export const BOT_OFFER_DELAY_MS = 10_000; // R1.1 +export function isBotId(userId: string | undefined): boolean; // starts with "bot:" +export function getBotLevel(userId: string | undefined): BotLevel | undefined; +export function isBotLevel(value: unknown): value is BotLevel; +export function getBotLevelTextKey(level: BotLevel): string; // e.g. BotLevelMedium +export function getLastBotLevel(storage): BotLevel; // Medium until one is chosen (R1.4) +export function setLastBotLevel(storage, level: BotLevel): void; +``` + +- `getPlayerDisplayName(player, t)` takes `userId` too (every caller has it) and returns e.g. "Bot · Orta" for + bots (R4.1). +- Texts (en, tr): `PlayWithBot` "Play with bot" / "Bota karşı oyna", `NoOpponentYet` "No opponent yet. Play with + the bot?" / "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?", `ChooseBotLevel` "Choose how strong the bot + is" / "Botun ne kadar güçlü olacağını seç", `Bot` "Bot" / "Bot", `BotLevelEasy`, + `BotLevelMedium`, `BotLevelHard`, `BotLevelMaster` "Easy", "Medium", "Hard", "Master" / "Kolay", "Orta", "Zor", + "Usta", `PlayAgain` "Play again" / "Tekrar oyna". + +## Backend + +### Modules +``` +engine/Position.java compact board and the rules on it +bot/BotLevel.java the levels, their wire names and strength ranges +bot/BotSettings.java record (level, strength, seed); kept with a bot game +bot/BotStrength.java strength -> depth, temperature, random move chance +bot/BotSearch.java negamax with alpha-beta; scores of the root moves +bot/BotPolicy.java chooses a move: strength, search, softmax, random moves +bot/BotPlayer.java plays the bot's turns in live games +db/migrations/003_bot.sql bot column +``` +`engine` and `bot` (except `BotPlayer`) have no Quarkus or game loop code, so they can be tested and run on their +own. + +### `Position` +```java +public final class Position { + // CommonBoard layout: player 1's pits 0-5, bank 6; player 2's pits 7-12, bank 13. + public static Position initial(boolean player1ToMove); + public static Position of(int[] stones, boolean player1ToMove); + public static Position of(MancalaGame game); + public int[] stones(); // a copy + public boolean isPlayer1ToMove(); + public int legalPits(int[] into); // pit numbers 0-5 of the mover's non-empty pits; returns the count + // Plays pit 0-5 of the mover into `into` (may be this). Returns true if the mover plays again. + public boolean play(int pit, Position into); + public boolean isOver(); // one side is empty + public int bankDifference(); // mover's bank minus the opponent's +} +``` +`play` applies the rules in the engine's order: sowing (one stone goes to the next pit, otherwise the first stone +goes back into the played pit; the opponent's bank is skipped), last stone in an own empty pit, even count in an +opponent's pit, last stone in the own bank (extra turn) or the turn passes, and clearing the board when a side is +empty. + +### Search (`BotSearch`) +- Negamax with alpha-beta. A move that gives an extra turn keeps the side to move, so its child's score is not + negated. +- Scores are from the mover's view, in hundredths of a stone. An ended game scores + `±100_000 + 100 × final bank difference`; a game where a bank holds more than 24 stones is decided and scores as + ended. Other leaves: `100 × bank difference`. A term for the stones on each side (`PIT_STONE_WEIGHT`) is there + but set to 0: the levels met R2.4 without it, and stones on a side may still go to either player. +- Move order: extra turns first, then by the stones a move brings to the bank. Each node plays its moves once to + order them, into arrays kept per ply, so searching allocates nothing. +- Iterative deepening up to the depth; each root move is searched with a full window so every root move gets an + exact score (the softmax needs them). If the node budget (3 million) runs out, the scores of the last completed + depth are used; depth 1 always completes. + +### Strength (`BotLevel`, `BotStrength`) + +| Level | Wire name | Strength | +|---|---|---| +| Easy | `easy` | 5–25 | +| Medium | `medium` | 30–55 | +| Hard | `hard` | 60–80 | +| Master | `master` | 85–100 | + +Interpolated linearly between rows (depth rounded): + +| Strength | Depth | Temperature (stones) | Random move chance | +|---|---|---|---| +| 0 | 1 | 4 | 35% | +| 25 | 2 | 2 | 15% | +| 50 | 4 | 1 | 5% | +| 75 | 7 | 0.4 | 1% | +| 100 | 16 | 0.05 | 0% | + +The first table had depth 12 at strength 100 and already met R2.4. Searching deeper still helped: with best moves +only, depth 16 scored 64% against depth 12 (depth 14: 54%), at about 60 ms per move on average and 1 s at most +without a budget. So the top row is 16, and the node budget keeps the slowest moves near 0.2 s. + +`BotTournamentTest` with these values, 200 games per pair (October 2026, 24 cores, one thread): + +| Pair | Won | Lost | Drawn | Score | Stronger level's time per move | +|---|---|---|---|---|---| +| Medium vs Easy | 155 | 34 | 11 | 0.80 | < 0.1 ms | +| Hard vs Medium | 158 | 29 | 13 | 0.82 | 0.2 ms | +| Master vs Hard | 177 | 16 | 7 | 0.90 | 20 ms | +| Master vs Easy | 200 | 0 | 0 | 1.00 | 22 ms | + +### Choosing a move (`BotPolicy`) +```java +static int chooseMove(Position position, BotSettings bot, int moveNumber); // pit 0-5 +``` +1. A random source from `bot.seed()` and `moveNumber` (number of moves played in the game). +2. This move's strength: `bot.strength()` ±5, kept within 0–100. +3. With the random move chance: a random legal pit. +4. Otherwise search to the depth and pick a root move with probability ∝ `exp(score / (temperature × 100))`. +5. With a single legal pit, play it without searching. + +### Bot games in the game logic (`GameManager`) +- New channel `new_bot_game` with `{ level }`. An unknown level is logged and ignored. A player with a game in + progress gets `on_new_game_rejected` (R1.5). Otherwise `matchMaker.cancel(userId)`, then a game with the player + as player 1 and `bot:` as player 2, a random first mover (R1.6), and + `bot = new BotSettings(level, random strength in the level's range, random seed)`; then `startGame` as usual. +- `BackendGame` gains `BotSettings bot` (null for games between people) and `botId()`. +- The bot's `PlayerSummary` is `(bot:, "Bot", false)`. +- `onGameMove` is split: the socket handler finds the game and calls `playMove(game, playerId, pitIndex)`, which + holds today's logic (clock check, engine, clock charge, updates, ending). The bot calls the same method (R3.1). +- `onTurnStarted(Consumer)`: called after a game starts, after a move that does not end the game + (including an extra turn) and for each restored game. +- Connection info shows the bot online (R3.4); messages to the bot's id are not sent. +- `GameStore.setGameByUser` and `removeGameByPlayer` ignore bot ids. +- `PublicIds.publicUserId` returns bot ids as they are. + +### `BotPlayer` +```java +public BotPlayer(GameManager gameManager, GameStore gameStore, Scheduler loop, Executor searchExecutor, + RandomGenerator random) // random: thinking times only; used on the loop +``` +Listens to `onTurnStarted`. When it is the bot's turn and no move is being worked out for that game: +1. On the loop: copy the position (`Position.of`), note the move number and compute + `readyAt = now + animationMs(last move) + thinkMs(level)`. `animationMs` is 300 ms per `GAME_STEP_GAME_MOVE` step + of the last move plus 300 ms; before the first move it is 1.5 s, so the game page has opened. `thinkMs` is random: Easy 0.8–1.8 s, Medium 1.0–2.2 s, Hard 1.2–2.6 s, Master + 1.4–3.0 s. With less than 30 s on the bot's clock it is 0.3 s (R3.3). +2. On `searchExecutor`: `BotPolicy.chooseMove`. If it throws, the error is logged and the first legal pit is used, + so the game never waits for a bot that failed. +3. Back on the loop (`loop.execute`, then `loop.schedule` for what is left until `readyAt`): if the game is still + live, it is the same `BackendGame`, the move number has not changed and it is the bot's turn, call + `gameManager.playMove(game, botId, pit)`; otherwise drop the result. + +Games being worked on are kept in a set by identity, so a game restored with the same id is a new game for it. + +`searchExecutor` is a fixed pool of `BOT_SEARCH_THREADS` platform threads (default: half the processors, at least +1), created and closed in `Backend`. Unit tests pass a direct executor and `ManualScheduler`. + +### Restore (R4.6) +- `LiveGameSnapshot` gains `bot` (`@JsonInclude(NON_NULL)`); the snapshot stays version 2, since the field is + optional. `fromSnapshot` restores it. A snapshot with a bot player but no `bot` field (not expected) gets the + middle strength of the level and a new seed. +- `restoreLiveGames` calls the turn listeners, so a bot whose turn it was moves. + +### Saving (R4.3) +- `GameRecord` gains `bot` (`@JsonInclude(NON_NULL)`): `{ level, strength, seed }`, sent with `GET /game/:id`; + clients do not use it. It allows tuning the levels from real games later. +- Migration `003_bot`: `ALTER TABLE games ADD COLUMN bot jsonb;` (NULL for games between people). + `PostgresGameHistoryRepository` writes and reads it. + +### Configuration +`BOT_SEARCH_THREADS` (default: half the processors, at least 1). + +## Web app +- `components/BotLevelPicker.tsx`: the four levels as buttons; the last chosen level is highlighted. It is kept with + `getLastBotLevel` / `setLastBotLevel` from core in the app's storage (R1.4). +- Home page: a "Play with bot" button next to "New Game" opens `BotLevelMenu`, a menu of the levels like the account + menu, with the last level checked; choosing a level goes to `/loby?bot=` (R1.3). +- `LobyPage`: with `?bot=`, it sends `new_bot_game` instead of `new_game`. Otherwise, after + `BOT_OFFER_DELAY_MS` without a game, it shows `NoOpponentYet` and the picker below the search; choosing a level + sends `new_bot_game` (R1.1, R1.2). The existing `on_game_start` and `on_new_game_rejected` handling stays. +- `GamePage` / `GameOverCard`: in a bot game, the main button is "Play again" and goes to `/loby?bot=` with + the bot's level (R4.4). +- Bot names: every place that shows a player uses `getPlayerDisplayName` with the player's `userId` (R4.1). + +## Mobile app +- `LobyScreen`: the same offer after `BOT_OFFER_DELAY_MS`, and a `botLevel` route parameter that sends + `new_bot_game` at once. +- `HomeScreen`: a "Play with bot" button with the level choice; the last level is kept in MMKV. +- Bot names through `getPlayerDisplayName`. + +## Testing +- `Position`: replays the recorded parity games (`/engine/recorded-games.json`) and a few thousand random games + move by move against `MancalaGame`, comparing stones, whose turn it is and the end. +- `BotSearch`: finds an extra turn, a capture and a win in one or two moves; the same position, depth and budget + give the same scores; the node budget is respected. +- `BotStrength` and `BotLevel`: interpolation, the ranges, strengths drawn stay in range. +- `BotPolicy`: the same seed and move number give the same move; random moves only at the expected rate; a + single legal pit is played; R2.5 with a fixed opponent. +- Calibration: `BotTournamentTest`, tagged `calibration` and left out of `./mvnw test` + (`./mvnw test -Dgroups=calibration`), plays 200 games for each pair of neighbouring levels and Master against + Easy, alternating the first mover, and fails if R2.4 does not hold. It also reports the average search time per + level. A quick version (20 games, Master against Easy) runs with the normal tests. +- `BotPlayer` with `FakeRtmt`, `ManualScheduler` and a direct executor: the bot moves after the delay; extra turns; + a game that ended or moved on while searching drops the result; low time shortens the wait; a restored game where + it is the bot's turn gets a move. (The fallback for a failing search has no test: `BotPolicy` is static and cannot + be made to fail from outside.) +- `GameManager`: `new_bot_game` starts a game with the bot and ends the search; it is refused during a game; + unknown levels are ignored; the bot shows as online and gets no messages; the saved record keeps the bot id and + `bot`; leaving and running out of time end the game as usual. +- Postgres repository: the `bot` column is saved and read. Snapshots with and without `bot` restore. +- End to end in Chrome with `GAME_CLOCK_MS=60000`: the offer appears after 10 s in the lobby and starts a game; + "Play with bot" on the home page; the bot moves after the animation; the game ends and shows in recent games and + the replay as "Bot · "; "Play again" starts a new bot game; a backend restart during the bot's turn; a + second browser watches a bot game from the live previews. + +Checked end to end in Chrome on 2026-10-16 with the default clock: the home menu and the lobby offer (shown after +10.3 s) start games at the chosen level; "Bot · Easy" / "Bot · Hard" on the game page, in recent games and in a +second browser's live previews; an Easy game played to the end (the bot waited 1.45–4 s per move, longer after long +animations); "Play again" starts a new game at the same level; the backend stopped right after a Master game started +and the bot made its first move after the restart. diff --git a/docs/specs/game-bot/requirements.md b/docs/specs/game-bot/requirements.md new file mode 100644 index 0000000..20d2cf3 --- /dev/null +++ b/docs/specs/game-bot/requirements.md @@ -0,0 +1,74 @@ +# Game bot: requirements + +Status: implemented on branch `feature/game-bot` (based on `develop`). +Scope: backend, `packages/core`, web app and mobile app. + +## Goals + +Players can always get a game: when no other player is looking for one, they can play against a bot. They choose +how strong the bot is, but within a level the bot is not always equally strong, so games against it differ from +each other. A game against the bot is a normal game: it has the clock, can be watched and is saved like any other. + +## Terms + +- **Bot**: a player run by the server. +- **Level**: what the player chooses: Easy, Medium, Hard or Master (Turkish: Kolay, Orta, Zor, Usta). +- **Strength**: a number from 0 to 100 that sets how well the bot plays. Each level is a range of strengths; the bot + takes a strength from it at the start of each game. +- **Bot game**: a game between a player and the bot. + +## Requirements + +### R1. Starting a bot game +- R1.1 When a player has been looking for an opponent for 10 seconds without one being found, the lobby offers a + game against the bot with the four levels to choose from. The search goes on while the offer is shown; if an + opponent is found first, the game with them starts as usual. +- R1.2 Choosing a level ends the search and starts a bot game at that level at once. +- R1.3 The home page also has a "Play with bot" button that opens the same choice of levels, without searching for + an opponent first. +- R1.4 The level chosen last is preselected the next time (kept on the device). +- R1.5 A player who has a game in progress cannot start a bot game; they are offered to continue that game, as when + they look for an opponent. +- R1.6 Who moves first is chosen at random, as in other games. + +### R2. Levels and strength +- R2.1 There are four levels, from weakest to strongest: Easy, Medium, Hard, Master. +- R2.2 At the start of each game the bot takes a random strength within the level's range. Within a game, it plays + each move with a strength that varies a little around that value. +- R2.3 Easy makes obvious mistakes and can be beaten by a beginner. Master plays close to perfectly and is rarely + beaten. +- R2.4 Each level is clearly stronger than the one below: played against each other by the server, the stronger + of two neighbouring levels wins at least 65% of the games, and Master wins at least 95% against Easy. +- R2.5 Two games at the same level from the same starting position do not play out the same way: in 20 such games + against the same sequence of the opponent's moves, the bot's first five moves are not all the same. + +### R3. How the bot plays +- R3.1 The bot plays by the same rules as players; its moves go through the same game logic. +- R3.2 The bot does not move instantly: it waits until the previous move's animation has finished, then about 1 to + 3 seconds more, a little longer at the higher levels. An extra turn is played the same way. +- R3.3 The bot has a clock like any player and its waiting counts on it. It never runs out of time. +- R3.4 The bot never leaves a game and is always shown as online. +- R3.5 Working out the bot's moves never delays other games: other players' moves are handled as fast as without + bots, even while many bot games are being played. + +### R4. During and after the game +- R4.1 The bot is shown as "Bot" with its level, e.g. "Bot · Medium" / "Bot · Orta", wherever players are shown: + the game page, live game previews, recent and popular games, and replays. +- R4.2 Everything that ends a game works as in other games: a player who leaves loses, a player whose time runs out + loses, a completed game is won by the player with more stones. +- R4.3 Bot games are saved like other games and appear in the player's recent games and can be replayed. +- R4.4 At the end of a bot game, the game over card offers "Play again", which starts a new bot game at the same + level. +- R4.5 Bot games can be watched by others and appear in live game previews like other games. +- R4.6 A bot game survives a backend restart: the bot goes on at the same strength, and if it was its turn, it moves. + +### R5. Mobile app +- R5.1 The mobile app has the offer in the lobby (R1.1, R1.2), the button on the home screen (R1.3) and shows the + bot's name with its level (R4.1). + +## Out of scope (TODO) +- Playing against the bot without a connection (a bot in the TypeScript engine on the device). +- Ratings or statistics; when they come, bot games should be kept apart from games against people. +- Hints or move analysis for players. +- Choosing an exact strength or who moves first. +- Rebuilding the game engine (`MancalaGame`, `Board`, the rules) on the compact board this feature adds. diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md new file mode 100644 index 0000000..5ef03f9 --- /dev/null +++ b/docs/specs/game-bot/tasks.md @@ -0,0 +1,21 @@ +# Game bot: tasks + +Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed. + +- [x] 1. **Spec**: requirements, design and tasks. +- [x] 2. **Compact board**: `Position` in the engine package; tests against `MancalaGame` with the recorded parity + games and random games. +- [x] 3. **Search and strength**: `BotLevel`, `BotSettings`, `BotStrength`, `BotSearch`, `BotPolicy` with unit + tests; `BotTournamentTest` (tag `calibration`) and its quick version; tune the evaluation weights and the + strength table until R2.4 holds, and write the final values into `design.md`. +- [x] 4. **Bot games in the backend**: `new_bot_game`, `BackendGame.bot`, `playMove` split out of `onGameMove`, + `onTurnStarted`, bot ids in `GameStore`, `PublicIds` and the connection info; `BotPlayer` and its executor in + `Backend`, `BOT_SEARCH_THREADS`; `bot` in snapshots and their restore; unit tests. +- [x] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. +- [x] 6. **Core**: `channel_new_bot_game`, `BotLevel`, `BOT_LEVELS`, `BOT_OFFER_DELAY_MS`, `isBotId`, + `getBotLevel`, `getPlayerDisplayName` for bots, texts in English and Turkish; unit tests. +- [x] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot + game, bot names everywhere players are shown. +- [x] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle. +- [x] 9. **End to end and docs**: the Chrome tests from `design.md`; `README.md`, `apps/backend/README.md`, + `AGENTS.md` (the specs list, bot ids, the calibration test) and `TODO.md` (the out of scope items). diff --git a/packages/core/src/localization/en.ts b/packages/core/src/localization/en.ts index 58ed73e..22ef873 100644 --- a/packages/core/src/localization/en.ts +++ b/packages/core/src/localization/en.ts @@ -106,4 +106,13 @@ export const en = { StoreLabel: "{{name}}'s store, {{count}} stones", OpenGame: "Open the game", Score: "Score", + PlayWithBot: "Play with bot", + NoOpponentYet: "No opponent yet. Play with the bot?", + ChooseBotLevel: "Choose how strong the bot is", + Bot: "Bot", + BotLevelEasy: "Easy", + BotLevelMedium: "Medium", + BotLevelHard: "Hard", + BotLevelMaster: "Master", + PlayAgain: "Play again", } \ No newline at end of file diff --git a/packages/core/src/localization/tr.ts b/packages/core/src/localization/tr.ts index 153a1b5..d6c07c0 100644 --- a/packages/core/src/localization/tr.ts +++ b/packages/core/src/localization/tr.ts @@ -106,4 +106,13 @@ export const tr = { StoreLabel: "{{name}} hazinesi, {{count}} taş", OpenGame: "Oyunu aç", Score: "Skor", + PlayWithBot: "Bota karşı oyna", + NoOpponentYet: "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?", + ChooseBotLevel: "Botun ne kadar güçlü olacağını seç", + Bot: "Bot", + BotLevelEasy: "Kolay", + BotLevelMedium: "Orta", + BotLevelHard: "Zor", + BotLevelMaster: "Usta", + PlayAgain: "Tekrar oyna", }; \ No newline at end of file diff --git a/packages/core/src/models/Bot.ts b/packages/core/src/models/Bot.ts new file mode 100644 index 0000000..90254e7 --- /dev/null +++ b/packages/core/src/models/Bot.ts @@ -0,0 +1,42 @@ +// Games against the bot (docs/specs/game-bot). The bot plays with the user id `bot:`. +export type BotLevel = "easy" | "medium" | "hard" | "master"; + +// Weakest first. +export const BOT_LEVELS: BotLevel[] = ["easy", "medium", "hard", "master"]; + +// How long the lobby searches for an opponent before it offers the bot. +export const BOT_OFFER_DELAY_MS = 10_000; + +const BOT_ID_PREFIX = "bot:"; + +export function isBotId(userId: string | undefined): boolean { + return userId?.startsWith(BOT_ID_PREFIX) ?? false; +} + +// The level of a bot id, or undefined if it is not one. +export function getBotLevel(userId: string | undefined): BotLevel | undefined { + if (!userId || !isBotId(userId)) return undefined; + const level = userId.slice(BOT_ID_PREFIX.length); + return (BOT_LEVELS as string[]).includes(level) ? level as BotLevel : undefined; +} + +export function isBotLevel(value: unknown): value is BotLevel { + return typeof value === "string" && (BOT_LEVELS as string[]).includes(value); +} + +// Translation key of a level's name, e.g. BotLevelMedium. +export function getBotLevelTextKey(level: BotLevel): string { + return `BotLevel${level.charAt(0).toUpperCase()}${level.slice(1)}`; +} + +const LAST_BOT_LEVEL_KEY = "botLevel"; + +// The level chosen last on this device; Medium at first. +export function getLastBotLevel(storage: { getString(key: string): string | undefined }): BotLevel { + const saved = storage.getString(LAST_BOT_LEVEL_KEY); + return isBotLevel(saved) ? saved : "medium"; +} + +export function setLastBotLevel(storage: { setString(key: string, value: string): void }, level: BotLevel): void { + storage.setString(LAST_BOT_LEVEL_KEY, level); +} diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts index 408e5bb..9745bb5 100644 --- a/packages/core/src/models/index.ts +++ b/packages/core/src/models/index.ts @@ -6,3 +6,4 @@ export * from './UserConnectionInfo' export * from './AuthUser' export * from './GameHistory' export * from './GameClock' +export * from './Bot' diff --git a/packages/core/src/rtmt/channel_names.ts b/packages/core/src/rtmt/channel_names.ts index e3b532d..ecb9e34 100644 --- a/packages/core/src/rtmt/channel_names.ts +++ b/packages/core/src/rtmt/channel_names.ts @@ -1,5 +1,7 @@ export const channel_new_game = "new_game" export const channel_cancel_new_game = "cancel_new_game" +// client -> server: { level: BotLevel }, starts a game against the bot; ends a search for an opponent. +export const channel_new_bot_game = "new_bot_game" export const channel_on_game_start = "on_game_start" export const channel_game_move = "game_move" export const channel_on_game_update = "on_game_update" diff --git a/packages/core/src/util/PlayerUtil.ts b/packages/core/src/util/PlayerUtil.ts index 3aafc5d..1c169d6 100644 --- a/packages/core/src/util/PlayerUtil.ts +++ b/packages/core/src/util/PlayerUtil.ts @@ -1,11 +1,15 @@ +import { getBotLevel, getBotLevelTextKey } from "../models/Bot"; + const GUEST_USERNAME_PREFIX = "guest-"; // Name to show for a player, or undefined if the server sent none (legacy userKey players). -// Guests show as e.g. "Guest 4F2A" instead of their generated username. +// Guests show as e.g. "Guest 4F2A" instead of their generated username, the bot as e.g. "Bot · Medium". export function getPlayerDisplayName( - player: { name?: string, isGuest?: boolean } | undefined, + player: { userId?: string, name?: string, isGuest?: boolean } | undefined, t: (key: string) => string ): string | undefined { + const botLevel = getBotLevel(player?.userId); + if (botLevel) return `${t("Bot")} · ${t(getBotLevelTextKey(botLevel))}`; if (!player?.name) return undefined; if (player.isGuest) { const suffix = player.name.startsWith(GUEST_USERNAME_PREFIX) ? player.name.slice(GUEST_USERNAME_PREFIX.length) : player.name; diff --git a/packages/core/tests/models/Bot.test.ts b/packages/core/tests/models/Bot.test.ts new file mode 100644 index 0000000..e711d97 --- /dev/null +++ b/packages/core/tests/models/Bot.test.ts @@ -0,0 +1,55 @@ +import { BOT_LEVELS, getBotLevel, getBotLevelTextKey, getLastBotLevel, isBotId, isBotLevel, setLastBotLevel } from "../../src/models"; +import { en, tr } from "../../src/localization"; +import { getPlayerDisplayName } from "../../src/util"; + +describe("bot ids", () => { + it("recognises bot ids and their levels", () => { + expect(isBotId("bot:medium")).toBe(true); + expect(isBotId("kc:alice")).toBe(false); + expect(isBotId(undefined)).toBe(false); + expect(getBotLevel("bot:master")).toBe("master"); + expect(getBotLevel("bot:impossible")).toBeUndefined(); + expect(getBotLevel("kc:alice")).toBeUndefined(); + }); + + it("checks levels", () => { + expect(isBotLevel("hard")).toBe(true); + expect(isBotLevel("impossible")).toBe(false); + expect(isBotLevel(3)).toBe(false); + }); + + it("has a name for every level in both languages", () => { + for (const level of BOT_LEVELS) { + const key = getBotLevelTextKey(level) as keyof typeof en; + expect(en[key]).toBeTruthy(); + expect(tr[key as keyof typeof tr]).toBeTruthy(); + } + }); +}); + +describe("last bot level", () => { + it("is Medium until one is chosen, then the chosen one", () => { + const values = new Map(); + const storage = { getString: (key: string) => values.get(key), setString: (key: string, value: string) => { values.set(key, value); } }; + expect(getLastBotLevel(storage)).toBe("medium"); + setLastBotLevel(storage, "master"); + expect(getLastBotLevel(storage)).toBe("master"); + values.set("botLevel", "impossible"); + expect(getLastBotLevel(storage)).toBe("medium"); + }); +}); + +describe("getPlayerDisplayName", () => { + const t = (key: string) => (tr as Record)[key] ?? key; + + it("shows the bot with its level", () => { + expect(getPlayerDisplayName({ userId: "bot:medium", name: "Bot", isGuest: false }, t)).toBe("Bot · Orta"); + expect(getPlayerDisplayName({ userId: "bot:easy" }, t)).toBe("Bot · Kolay"); + }); + + it("still shows people and guests as before", () => { + expect(getPlayerDisplayName({ userId: "kc:alice", name: "alice", isGuest: false }, t)).toBe("alice"); + expect(getPlayerDisplayName({ userId: "kc:g", name: "guest-4f2a9b", isGuest: true }, t)).toBe("Misafir 4F2A"); + expect(getPlayerDisplayName({ userId: "0b9f6d1e" }, t)).toBeUndefined(); + }); +});