Requirements, design and tasks for playing against a bot with four levels and a strength drawn per game.
211 lines
15 KiB
Markdown
211 lines
15 KiB
Markdown
# 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:<level>`, 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:<level>` 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:<level>, "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<BackendGame>)`: 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=<level>` (R1.3).
|
||
- `LobyPage`: with `?bot=<level>`, 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=<level>` 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 · <level>"; "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.
|