From 91577c08980414d89ca91583ed21b6011e0d4c79 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 12:46:20 +0300 Subject: [PATCH 1/9] docs(game-bot): add the spec Requirements, design and tasks for playing against a bot with four levels and a strength drawn per game. --- docs/specs/game-bot/design.md | 210 ++++++++++++++++++++++++++++ docs/specs/game-bot/requirements.md | 74 ++++++++++ docs/specs/game-bot/tasks.md | 21 +++ 3 files changed, 305 insertions(+) create mode 100644 docs/specs/game-bot/design.md create mode 100644 docs/specs/game-bot/requirements.md create mode 100644 docs/specs/game-bot/tasks.md diff --git a/docs/specs/game-bot/design.md b/docs/specs/game-bot/design.md new file mode 100644 index 0000000..79fd051 --- /dev/null +++ b/docs/specs/game-bot/design.md @@ -0,0 +1,210 @@ +# 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; +``` + +- `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?", `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` plus small terms for stones on each side and moves that give an extra + turn; the weights are tuned in task 3. +- Move order: extra turns, then captures, then the rest, then the best move of the previous iteration. +- 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. + +### Strength (`BotLevel`, `BotStrength`) + +| Level | Wire name | Strength | +|---|---|---| +| Easy | `easy` | 5–25 | +| Medium | `medium` | 30–55 | +| Hard | `hard` | 60–80 | +| Master | `master` | 85–100 | + +Starting values, interpolated linearly between rows (depth rounded); tuned in task 3 until R2.4 holds: + +| 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 | 12 | 0.05 | 0% | + +### 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, GameLoop loop, Executor searchExecutor) +``` +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. `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. + +`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 preselected and saved in + local storage (R1.4). +- Home page: a "Play with bot" button next to "New Game" opens the picker in a `FloatingPanel`; 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; a failing search plays the first legal pit; + low time shortens the wait; a restored game where it is the bot's turn gets a move. +- `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. diff --git a/docs/specs/game-bot/requirements.md b/docs/specs/game-bot/requirements.md new file mode 100644 index 0000000..f3dfb95 --- /dev/null +++ b/docs/specs/game-bot/requirements.md @@ -0,0 +1,74 @@ +# Game bot: requirements + +Status: spec, not implemented yet. 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..6e44062 --- /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. +- [ ] 2. **Compact board**: `Position` in the engine package; tests against `MancalaGame` with the recorded parity + games and random games. +- [ ] 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`. +- [ ] 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. +- [ ] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. +- [ ] 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. +- [ ] 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. +- [ ] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle. +- [ ] 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). From f9a96fc5f54560f7a61493162046ea652d7a5482 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 12:47:32 +0300 Subject: [PATCH 2/9] feat(backend): add a compact board for searching moves Position keeps the board as 14 stone counts and plays the Mangala rules on it without allocating. Tested against MancalaGame with the recorded games and random games. --- .../java/app/playmancala/engine/Position.java | 176 ++++++++++++++++++ .../app/playmancala/engine/PositionTest.java | 106 +++++++++++ docs/specs/game-bot/tasks.md | 2 +- 3 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/main/java/app/playmancala/engine/Position.java create mode 100644 apps/backend/src/test/java/app/playmancala/engine/PositionTest.java 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/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/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index 6e44062..20f91e8 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -3,7 +3,7 @@ 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. -- [ ] 2. **Compact board**: `Position` in the engine package; tests against `MancalaGame` with the recorded parity +- [x] 2. **Compact board**: `Position` in the engine package; tests against `MancalaGame` with the recorded parity games and random games. - [ ] 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 From 31b51d04af22a2c6b51a054bc6954b319a0f71a4 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:11:46 +0300 Subject: [PATCH 3/9] feat(backend): choose bot moves by strength Negamax with alpha-beta on Position scores every move; the strength sets the depth, the softmax temperature and the chance of a random move. The levels are ranges of strengths drawn per game. A calibration tournament checks that each level beats the one below. --- apps/backend/pom.xml | 2 + .../java/app/playmancala/bot/BotLevel.java | 80 ++++++++++ .../java/app/playmancala/bot/BotPolicy.java | 58 ++++++++ .../java/app/playmancala/bot/BotSearch.java | 140 ++++++++++++++++++ .../java/app/playmancala/bot/BotSettings.java | 16 ++ .../java/app/playmancala/bot/BotStrength.java | 34 +++++ .../app/playmancala/bot/BotPolicyTest.java | 126 ++++++++++++++++ .../app/playmancala/bot/BotSearchTest.java | 82 ++++++++++ .../playmancala/bot/BotTournamentTest.java | 88 +++++++++++ docs/specs/game-bot/design.md | 26 +++- docs/specs/game-bot/tasks.md | 2 +- 11 files changed, 647 insertions(+), 7 deletions(-) create mode 100644 apps/backend/src/main/java/app/playmancala/bot/BotLevel.java create mode 100644 apps/backend/src/main/java/app/playmancala/bot/BotPolicy.java create mode 100644 apps/backend/src/main/java/app/playmancala/bot/BotSearch.java create mode 100644 apps/backend/src/main/java/app/playmancala/bot/BotSettings.java create mode 100644 apps/backend/src/main/java/app/playmancala/bot/BotStrength.java create mode 100644 apps/backend/src/test/java/app/playmancala/bot/BotPolicyTest.java create mode 100644 apps/backend/src/test/java/app/playmancala/bot/BotSearchTest.java create mode 100644 apps/backend/src/test/java/app/playmancala/bot/BotTournamentTest.java 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/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/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/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/docs/specs/game-bot/design.md b/docs/specs/game-bot/design.md index 79fd051..e5fdf23 100644 --- a/docs/specs/game-bot/design.md +++ b/docs/specs/game-bot/design.md @@ -83,12 +83,13 @@ empty. 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` plus small terms for stones on each side and moves that give an extra - turn; the weights are tuned in task 3. -- Move order: extra turns, then captures, then the rest, then the best move of the previous iteration. + 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 are used; depth 1 always completes. ### Strength (`BotLevel`, `BotStrength`) @@ -99,7 +100,7 @@ empty. | Hard | `hard` | 60–80 | | Master | `master` | 85–100 | -Starting values, interpolated linearly between rows (depth rounded); tuned in task 3 until R2.4 holds: +Interpolated linearly between rows (depth rounded): | Strength | Depth | Temperature (stones) | Random move chance | |---|---|---|---| @@ -107,7 +108,20 @@ Starting values, interpolated linearly between rows (depth rounded); tuned in ta | 25 | 2 | 2 | 15% | | 50 | 4 | 1 | 5% | | 75 | 7 | 0.4 | 1% | -| 100 | 12 | 0.05 | 0% | +| 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 diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index 20f91e8..b7290c3 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -5,7 +5,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [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. -- [ ] 3. **Search and strength**: `BotLevel`, `BotSettings`, `BotStrength`, `BotSearch`, `BotPolicy` with unit +- [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`. - [ ] 4. **Bot games in the backend**: `new_bot_game`, `BackendGame.bot`, `playMove` split out of `onGameMove`, From f0b75e5cee4271bffd286a8373494eea908a9b62 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:18:45 +0300 Subject: [PATCH 4/9] feat(backend): play games against the bot new_bot_game starts a game against the bot at the chosen level and ends the search for an opponent. BotPlayer works out the bot's moves on its own thread pool and plays them on the game loop after the previous move's animation and a thinking time. Bot games keep their settings across restarts. --- .../main/java/app/playmancala/Backend.java | 14 + .../java/app/playmancala/bot/BotPlayer.java | 106 ++++++++ .../app/playmancala/game/BackendGame.java | 10 +- .../app/playmancala/game/GameManager.java | 86 +++++- .../playmancala/game/GameSerialization.java | 20 +- .../java/app/playmancala/game/GameStore.java | 4 + .../java/app/playmancala/game/PublicIds.java | 6 +- .../java/app/playmancala/model/Channels.java | 2 + .../src/main/resources/application.properties | 3 + .../app/playmancala/bot/BotPlayerTest.java | 249 ++++++++++++++++++ docs/specs/game-bot/design.md | 12 +- docs/specs/game-bot/tasks.md | 2 +- 12 files changed, 493 insertions(+), 21 deletions(-) create mode 100644 apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java create mode 100644 apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java 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/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/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..97990d6 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. 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/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/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/test/java/app/playmancala/bot/BotPlayerTest.java b/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java new file mode 100644 index 0000000..ae5faa8 --- /dev/null +++ b/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java @@ -0,0 +1,249 @@ +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()); + 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/docs/specs/game-bot/design.md b/docs/specs/game-bot/design.md index e5fdf23..3d14fa8 100644 --- a/docs/specs/game-bot/design.md +++ b/docs/specs/game-bot/design.md @@ -150,12 +150,13 @@ static int chooseMove(Position position, BotSettings bot, int moveNumber); // ### `BotPlayer` ```java -public BotPlayer(GameManager gameManager, GameStore gameStore, GameLoop loop, Executor searchExecutor) +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. `thinkMs` is random: Easy 0.8–1.8 s, Medium 1.0–2.2 s, Hard 1.2–2.6 s, Master + 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. @@ -163,6 +164,8 @@ Listens to `onTurnStarted`. When it is the bot's turn and no move is being worke 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`. @@ -212,8 +215,9 @@ Listens to `onTurnStarted`. When it is the bot's turn and no move is being worke 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; a failing search plays the first legal pit; - low time shortens the wait; a restored game where it is the bot's turn gets a move. + 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. diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index b7290c3..abd5d58 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -8,7 +8,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [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`. -- [ ] 4. **Bot games in the backend**: `new_bot_game`, `BackendGame.bot`, `playMove` split out of `onGameMove`, +- [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. - [ ] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. From 4a018c7b53a187e525e81f5ad6c35cc29c74b827 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:19:56 +0300 Subject: [PATCH 5/9] feat(backend): save how the bot played Saved games against the bot keep the bot's level, strength and seed in a new bot column, so the levels can later be tuned from real games. --- .../playmancala/game/GameSerialization.java | 2 +- .../app/playmancala/history/Migrations.java | 2 +- .../history/PostgresGameHistoryRepository.java | 11 +++++++---- .../java/app/playmancala/model/GameRecord.java | 17 ++++++++++++++--- .../main/resources/db/migrations/003_bot.sql | 2 ++ .../app/playmancala/bot/BotPlayerTest.java | 1 + .../PostgresGameHistoryRepositoryTest.java | 18 +++++++++++++++++- docs/specs/game-bot/tasks.md | 2 +- 8 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 apps/backend/src/main/resources/db/migrations/003_bot.sql 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 97990d6..b394159 100644 --- a/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java +++ b/apps/backend/src/main/java/app/playmancala/game/GameSerialization.java @@ -152,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/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/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/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 index ae5faa8..a6ae07b 100644 --- a/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java +++ b/apps/backend/src/test/java/app/playmancala/bot/BotPlayerTest.java @@ -209,6 +209,7 @@ class BotPlayerTest { 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:"))); } 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/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index abd5d58..6d688c6 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -11,7 +11,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [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. -- [ ] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. +- [x] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests. - [ ] 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. - [ ] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot From 32d1ec98e7123deb46db66fee1acd229bb51aa73 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:20:31 +0300 Subject: [PATCH 6/9] feat(core): add bot levels, the bot game channel and bot names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPlayerDisplayName shows the bot as e.g. "Bot · Medium" from its id. --- docs/specs/game-bot/tasks.md | 2 +- packages/core/src/localization/en.ts | 9 ++++ packages/core/src/localization/tr.ts | 9 ++++ packages/core/src/models/Bot.ts | 42 +++++++++++++++++++ packages/core/src/models/index.ts | 1 + packages/core/src/rtmt/channel_names.ts | 2 + packages/core/src/util/PlayerUtil.ts | 8 +++- packages/core/tests/models/Bot.test.ts | 55 +++++++++++++++++++++++++ 8 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/models/Bot.ts create mode 100644 packages/core/tests/models/Bot.test.ts diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index 6d688c6..432a5fc 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -12,7 +12,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. `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. -- [ ] 6. **Core**: `channel_new_bot_game`, `BotLevel`, `BOT_LEVELS`, `BOT_OFFER_DELAY_MS`, `isBotId`, +- [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. - [ ] 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. 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(); + }); +}); From e66c4a3a41cfebae437e48d64367c146e22dcbc5 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:22:04 +0300 Subject: [PATCH 7/9] feat(frontend): offer games against the bot The lobby offers the bot levels after 10 seconds without an opponent, the home page has a Play with bot menu, and a game against the bot ends with Play again at the same level. --- apps/frontend/src/components/BotLevelMenu.tsx | 36 ++++++++++++++++ .../src/components/BotLevelPicker.tsx | 34 +++++++++++++++ apps/frontend/src/components/Icons.tsx | 4 ++ .../src/components/game/GameOverCard.tsx | 6 ++- apps/frontend/src/routes/GamePage.tsx | 13 +++++- apps/frontend/src/routes/Home.tsx | 9 +++- apps/frontend/src/routes/LobyPage.tsx | 42 +++++++++++++++++-- docs/specs/game-bot/design.md | 15 ++++--- docs/specs/game-bot/tasks.md | 2 +- 9 files changed, 146 insertions(+), 15 deletions(-) create mode 100644 apps/frontend/src/components/BotLevelMenu.tsx create mode 100644 apps/frontend/src/components/BotLevelPicker.tsx 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/docs/specs/game-bot/design.md b/docs/specs/game-bot/design.md index 3d14fa8..7d25bae 100644 --- a/docs/specs/game-bot/design.md +++ b/docs/specs/game-bot/design.md @@ -32,12 +32,17 @@ 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?", `Bot` "Bot" / "Bot", `BotLevelEasy`, + 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". @@ -185,10 +190,10 @@ Games being worked on are kept in a set by identity, so a game restored with the `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 preselected and saved in - local storage (R1.4). -- Home page: a "Play with bot" button next to "New Game" opens the picker in a `FloatingPanel`; choosing a level - goes to `/loby?bot=` (R1.3). +- `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. diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index 432a5fc..dc3a7d3 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -14,7 +14,7 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [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. -- [ ] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot +- [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. - [ ] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle. - [ ] 9. **End to end and docs**: the Chrome tests from `design.md`; `README.md`, `apps/backend/README.md`, From d1918fed10c760dec127df3668c516fb3abd11f2 Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:23:22 +0300 Subject: [PATCH 8/9] feat(mobile): offer games against the bot The lobby offers the bot levels after 10 seconds without an opponent, the home screen has Play with bot, the bot is shown with its level, and a new game after a bot game is one at the same level. --- apps/mobile/src/components/BotLevelPicker.tsx | 31 +++++++++++++++++++ apps/mobile/src/screens/GameScreen.tsx | 19 +++++++----- apps/mobile/src/screens/HomeScreen.tsx | 13 ++++++++ apps/mobile/src/screens/LobyScreen.tsx | 28 ++++++++++++++--- apps/mobile/src/types/index.ts | 4 ++- docs/specs/game-bot/tasks.md | 2 +- 6 files changed, 84 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/src/components/BotLevelPicker.tsx 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/tasks.md b/docs/specs/game-bot/tasks.md index dc3a7d3..3459e5f 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -16,6 +16,6 @@ Work through the tasks in order; each ends with its tests passing and a commit. `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. -- [ ] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle. +- [x] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle. - [ ] 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). From f7a76c861e5a59a2f8e1bd7463e8e3c298acbcec Mon Sep 17 00:00:00 2001 From: Halit Aksoy Date: Fri, 16 Oct 2026 14:27:49 +0300 Subject: [PATCH 9/9] docs: describe the game bot The bot in the backend README with the calibration command, AGENTS.md notes on bot ids and test tags, and the open work in TODO.md. --- AGENTS.md | 13 ++++++++++--- TODO.md | 11 +++++++++++ apps/backend/README.md | 21 ++++++++++++++++++++- docs/specs/game-bot/design.md | 6 ++++++ docs/specs/game-bot/requirements.md | 2 +- docs/specs/game-bot/tasks.md | 2 +- 6 files changed, 49 insertions(+), 6 deletions(-) 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/docs/specs/game-bot/design.md b/docs/specs/game-bot/design.md index 7d25bae..82f9c69 100644 --- a/docs/specs/game-bot/design.md +++ b/docs/specs/game-bot/design.md @@ -231,3 +231,9 @@ Games being worked on are kept in a set by identity, so a game restored with the "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 index f3dfb95..20d2cf3 100644 --- a/docs/specs/game-bot/requirements.md +++ b/docs/specs/game-bot/requirements.md @@ -1,6 +1,6 @@ # Game bot: requirements -Status: spec, not implemented yet. Branch `feature/game-bot` (based on `develop`). +Status: implemented on branch `feature/game-bot` (based on `develop`). Scope: backend, `packages/core`, web app and mobile app. ## Goals diff --git a/docs/specs/game-bot/tasks.md b/docs/specs/game-bot/tasks.md index 3459e5f..5ef03f9 100644 --- a/docs/specs/game-bot/tasks.md +++ b/docs/specs/game-bot/tasks.md @@ -17,5 +17,5 @@ Work through the tasks in order; each ends with its tests passing and a commit. - [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. -- [ ] 9. **End to end and docs**: the Chrome tests from `design.md`; `README.md`, `apps/backend/README.md`, +- [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).