Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
51 changed files with 2152 additions and 53 deletions
Showing only changes of commit a6a309b78d - Show all commits

View File

@ -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), 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, `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 ## 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`) | | 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` | | | 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 | | 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 | | 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 | | 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 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 ## 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 left with `GameClocks.getClockTimes`, never store a ticking value. `GAME_CLOCK_MS=40000` makes games short for
manual tests. manual tests.
- `Rtmt.listenOnClientConnectionChange` adds a listener (several are allowed). - `Rtmt.listenOnClientConnectionChange` adds a listener (several are allowed).
- The bot plays as `bot:<level>` (`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 - 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 direct executors); only storage, HTTP and WebSocket tests are `@QuarkusTest`s. `FakeTokenVerifier` replaces
Keycloak in all `@QuarkusTest`s. Keycloak in all `@QuarkusTest`s.

11
TODO.md
View File

@ -54,6 +54,17 @@ See `docs/specs/game-clock`.
- [ ] Clocks in the home page's live game previews and in the ongoing-game banner - [ ] 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 - [ ] 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 ## Web redesign
See `docs/specs/web-redesign`. See `docs/specs/web-redesign`.

View File

@ -21,7 +21,8 @@ The Docker image is built from the repo root: `docker build -f apps/backend/Dock
| Package | What | | 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` | | `model` | The JSON sent to clients and stored, and the shared `ObjectMapper` |
| `loop` | `GameLoop`, the single thread that owns all live game state | | `loop` | `GameLoop`, the single thread that owns all live game state |
| `auth` | Keycloak token verification | | `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 | | `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:<level>` 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 | | Route | Auth | Response |
|---|---|---| |---|---|---|
| `GET /game/:id` | – | A live game, or `{ status: "finished", record }` | | `GET /game/:id` | – | A live game, or `{ status: "finished", record }` |

View File

@ -17,6 +17,8 @@
<surefire-plugin.version>3.5.6</surefire-plugin.version> <surefire-plugin.version>3.5.6</surefire-plugin.version>
<!-- Quarkus dev mode listens for a debugger on 5005 by default, which is the backend's own port. --> <!-- Quarkus dev mode listens for a debugger on 5005 by default, which is the backend's own port. -->
<debug>5006</debug> <debug>5006</debug>
<!-- Slow bot calibration games; run them with -Dgroups=calibration -DexcludedGroups=none. -->
<excludedGroups>calibration</excludedGroups>
</properties> </properties>
<dependencyManagement> <dependencyManagement>

View File

@ -1,6 +1,7 @@
package app.playmancala; package app.playmancala;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.SplittableRandom;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
@ -12,6 +13,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import app.playmancala.auth.TokenVerifier; import app.playmancala.auth.TokenVerifier;
import app.playmancala.bot.BotPlayer;
import app.playmancala.game.GameClockScheduler; import app.playmancala.game.GameClockScheduler;
import app.playmancala.game.GameManager; import app.playmancala.game.GameManager;
import app.playmancala.game.GameRecorder; import app.playmancala.game.GameRecorder;
@ -55,6 +57,10 @@ public class Backend {
@ConfigProperty(name = "mancala.game-clock-ms") @ConfigProperty(name = "mancala.game-clock-ms")
long gameClockMs; long gameClockMs;
// 0: half the processors.
@ConfigProperty(name = "mancala.bot-search-threads")
int botSearchThreads;
private GameLoop loop; private GameLoop loop;
// Valkey writes, in order. // Valkey writes, in order.
private ExecutorService storeWriter; private ExecutorService storeWriter;
@ -62,6 +68,8 @@ public class Backend {
private ScheduledExecutorService recorderIo; private ScheduledExecutorService recorderIo;
// Token checks. // Token checks.
private ExecutorService verifier; private ExecutorService verifier;
// Working out the bot's moves.
private ExecutorService botSearch;
private RtmtServer rtmt; private RtmtServer rtmt;
private GameStore gameStore; private GameStore gameStore;
@ -76,6 +84,10 @@ public class Backend {
storeWriter = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("valkey-writer").daemon().factory()); storeWriter = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("valkey-writer").daemon().factory());
recorderIo = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform().name("game-recorder").daemon().factory()); recorderIo = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform().name("game-recorder").daemon().factory());
verifier = Executors.newVirtualThreadPerTaskExecutor(); 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); ValkeyLiveGameStore liveGameStore = new ValkeyLiveGameStore(redis);
PostgresGameHistoryRepository repository = new PostgresGameHistoryRepository(dataSource); PostgresGameHistoryRepository repository = new PostgresGameHistoryRepository(dataSource);
@ -90,6 +102,7 @@ public class Backend {
gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, storeWriter, gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, storeWriter,
loop::now, gameClockMs); loop::now, gameClockMs);
liveGames = new LiveGames(rtmt, gameStore, gameManager, loop); liveGames = new LiveGames(rtmt, gameStore, gameManager, loop);
new BotPlayer(gameManager, gameStore, loop, botSearch, new SplittableRandom());
clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop, clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop,
GameClockScheduler.CLOCK_CHECK_INTERVAL_MS); GameClockScheduler.CLOCK_CHECK_INTERVAL_MS);
@ -112,6 +125,7 @@ public class Backend {
storeWriter.shutdown(); storeWriter.shutdown();
recorderIo.shutdownNow(); recorderIo.shutdownNow();
verifier.shutdownNow(); verifier.shutdownNow();
botSearch.shutdownNow();
} }
private void migrate() { private void migrate() {

View File

@ -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;
}
}

View File

@ -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<BackendGame> 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<MoveHistoryItem> 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;
}
}

View File

@ -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];
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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];
}
}

View File

@ -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");
}
}

View File

@ -4,6 +4,7 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import app.playmancala.bot.BotSettings;
import app.playmancala.engine.MancalaGame; import app.playmancala.engine.MancalaGame;
import app.playmancala.model.GameClock; import app.playmancala.model.GameClock;
import app.playmancala.model.GameUsersConnectionInfo; import app.playmancala.model.GameUsersConnectionInfo;
@ -27,10 +28,12 @@ public class BackendGame {
public GameClock clock; public GameClock clock;
// The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock. // The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock.
public final List<Long> moveRemainingMs; public final List<Long> 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, public BackendGame(MancalaGame mancalaGame, GameUsersConnectionInfo gameUsersConnectionInfo,
List<String> allSpectatorIds, int peakSpectators, Map<String, PlayerSummary> players, long startedAt, List<String> allSpectatorIds, int peakSpectators, Map<String, PlayerSummary> players, long startedAt,
List<Long> moveTimes, GameClock clock, List<Long> moveRemainingMs) { List<Long> moveTimes, GameClock clock, List<Long> moveRemainingMs, BotSettings bot) {
this.id = mancalaGame.getId(); this.id = mancalaGame.getId();
this.mancalaGame = mancalaGame; this.mancalaGame = mancalaGame;
this.gameUsersConnectionInfo = gameUsersConnectionInfo; this.gameUsersConnectionInfo = gameUsersConnectionInfo;
@ -41,6 +44,11 @@ public class BackendGame {
this.moveTimes = new ArrayList<>(moveTimes); this.moveTimes = new ArrayList<>(moveTimes);
this.clock = clock; this.clock = clock;
this.moveRemainingMs = new ArrayList<>(moveRemainingMs); this.moveRemainingMs = new ArrayList<>(moveRemainingMs);
this.bot = bot;
}
public boolean isBotTurn() {
return bot != null && bot.botId().equals(mancalaGame.getTurnPlayerId());
} }
public PlayerSummary player(String userId) { public PlayerSummary player(String userId) {

View File

@ -7,12 +7,16 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Consumer;
import java.util.function.IntSupplier;
import java.util.function.LongSupplier; import java.util.function.LongSupplier;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import com.fasterxml.jackson.databind.JsonNode; 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.CommonMancalaGame;
import app.playmancala.engine.GameState; import app.playmancala.engine.GameState;
import app.playmancala.engine.MancalaGame; 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. // Writes to Valkey go through `storeExecutor`, saving finished games through the recorder; neither blocks the loop.
public class GameManager { public class GameManager {
private static final Logger LOG = Logger.getLogger(GameManager.class); 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 GameStore gameStore;
private final Rtmt rtmt; private final Rtmt rtmt;
@ -46,6 +52,7 @@ public class GameManager {
// Time each player has for a new game. // Time each player has for a new game.
private final long gameClockMs; private final long gameClockMs;
private final List<Runnable> liveGamesListeners = new ArrayList<>(); private final List<Runnable> liveGamesListeners = new ArrayList<>();
private final List<Consumer<BackendGame>> turnListeners = new ArrayList<>();
public GameManager(GameStore gameStore, Rtmt rtmt, MatchMaker matchMaker, LiveGameStore liveGameStore, public GameManager(GameStore gameStore, Rtmt rtmt, MatchMaker matchMaker, LiveGameStore liveGameStore,
GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) { GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) {
@ -67,6 +74,12 @@ public class GameManager {
liveGamesListeners.add(listener); 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<BackendGame> listener) {
turnListeners.add(listener);
}
private void listenRtmtMessages() { private void listenRtmtMessages() {
rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> { rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> {
BackendGame activeGame = gameStore.getGameByUser(userId); BackendGame activeGame = gameStore.getGameByUser(userId);
@ -76,6 +89,7 @@ public class GameManager {
} }
matchMaker.join(userId); 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.CANCEL_NEW_GAME, (userId, message) -> matchMaker.cancel(userId));
rtmt.listenMessage(Channels.GAME_MOVE, this::onGameMove); rtmt.listenMessage(Channels.GAME_MOVE, this::onGameMove);
rtmt.listenMessage(Channels.LEAVE_GAME, (userId, message) -> onPlayerLeave(userId)); 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) { private void onGameMove(String userId, JsonNode gameMove) {
BackendGame game = gameStore.getGameByUser(userId); BackendGame game = gameStore.getGameByUser(userId);
if (game == null) { if (game == null) {
LOG.debug("Game not found!"); LOG.debug("Game not found!");
return; 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; MancalaGame mancalaGame = game.mancalaGame;
try { try {
long now = clock.getAsLong(); long now = clock.getAsLong();
@ -101,13 +147,10 @@ public class GameManager {
endGameOnTime(game); endGameOnTime(game);
return; return;
} }
JsonNode index = gameMove.path("index"); int index = pitIndex.getAsInt();
if (!index.canConvertToInt() || !index.isIntegralNumber()) {
throw new IllegalStateException("Invalid move: " + gameMove);
}
int movesBefore = mancalaGame.getHistory().size(); int movesBefore = mancalaGame.getHistory().size();
Seat moverSeat = seatOf(mancalaGame, userId); Seat moverSeat = seatOf(mancalaGame, userId);
mancalaGame.moveByPlayerPit(userId, index.asInt()); mancalaGame.moveByPlayerPit(userId, index);
if (mancalaGame.getHistory().size() > movesBefore && moverSeat != null) { if (mancalaGame.getHistory().size() > movesBefore && moverSeat != null) {
game.moveTimes.add(now); game.moveTimes.add(now);
GameClocks.ChargedMove charged = GameClocks.chargeMove(game.clock, moverSeat, now); GameClocks.ChargedMove charged = GameClocks.chargeMove(game.clock, moverSeat, now);
@ -121,6 +164,7 @@ public class GameManager {
} else { } else {
saveLiveGame(game); saveLiveGame(game);
notifyLiveGamesChanged(); notifyLiveGamesChanged();
notifyTurnStarted(game);
} }
} catch (RuntimeException e) { } catch (RuntimeException e) {
onGameError(game, e); onGameError(game, e);
@ -226,6 +270,7 @@ public class GameManager {
if (restored > 0) { if (restored > 0) {
LOG.infof("(GameManager) Restored %d live game(s)", restored); LOG.infof("(GameManager) Restored %d live game(s)", restored);
notifyLiveGamesChanged(); notifyLiveGamesChanged();
gameStore.getAll().forEach(this::notifyTurnStarted);
} }
return restored; return restored;
} }
@ -256,17 +301,27 @@ public class GameManager {
} }
private void sendMessageToPlayersAndSpectators(BackendGame game, String channel, Object message) { private void sendMessageToPlayersAndSpectators(BackendGame game, String channel, Object message) {
rtmt.sendMessage(game.mancalaGame.getPlayer1Id(), channel, message); sendMessageToPlayer(game.mancalaGame.getPlayer1Id(), channel, message);
rtmt.sendMessage(game.mancalaGame.getPlayer2Id(), channel, message); sendMessageToPlayer(game.mancalaGame.getPlayer2Id(), channel, message);
List.copyOf(game.spectatorIds).forEach(spectatorId -> rtmt.sendMessage(spectatorId, 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) { public ClientGame toClientGame(BackendGame game) {
return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt, return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt,
List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong()); List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong());
} }
public BackendGame createMancalaGame(String userId1, String userId2) { 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 mancalaGame = new CommonMancalaGame(UUID.randomUUID().toString(), userId1, userId2);
mancalaGame.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? userId1 : userId2); mancalaGame.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? userId1 : userId2);
long now = clock.getAsLong(); long now = clock.getAsLong();
@ -276,7 +331,7 @@ public class GameManager {
players.put(userId1, player1); players.put(userId1, player1);
players.put(userId2, player2); players.put(userId2, player2);
BackendGame game = new BackendGame(mancalaGame, getGameUsersConnectionInfo(player1, player2), List.of(), 0, 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.set(game.id, game);
gameStore.setGameByUser(userId1, game); gameStore.setGameByUser(userId1, game);
gameStore.setGameByUser(userId2, game); gameStore.setGameByUser(userId2, game);
@ -284,6 +339,7 @@ public class GameManager {
} }
private PlayerSummary getPlayerInfo(String userId) { private PlayerSummary getPlayerInfo(String userId) {
if (BotLevel.isBotId(userId)) return new PlayerSummary(userId, BOT_NAME, false);
AuthUser user = rtmt.getUser(userId); AuthUser user = rtmt.getUser(userId);
return new PlayerSummary(userId, user != null ? user.name() : null, user == null || user.isGuest()); 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) { private GameUsersConnectionInfo getGameUsersConnectionInfo(PlayerSummary player1, PlayerSummary player2) {
return new GameUsersConnectionInfo( return new GameUsersConnectionInfo(
UserConnectionInfo.of(player1, rtmt.isClientOnline(player1.userId())), UserConnectionInfo.of(player1, isOnline(player1.userId())),
UserConnectionInfo.of(player2, rtmt.isClientOnline(player2.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) { public void startGame(BackendGame game) {
@ -304,6 +365,7 @@ public class GameManager {
sendUserConnectionInfo(game); sendUserConnectionInfo(game);
saveLiveGame(game); saveLiveGame(game);
notifyLiveGamesChanged(); notifyLiveGamesChanged();
notifyTurnStarted(game);
} }
public void sendUserConnectionInfo(BackendGame game) { public void sendUserConnectionInfo(BackendGame game) {
@ -337,6 +399,10 @@ public class GameManager {
liveGamesListeners.forEach(Runnable::run); liveGamesListeners.forEach(Runnable::run);
} }
private void notifyTurnStarted(BackendGame game) {
turnListeners.forEach(listener -> listener.accept(game));
}
private void listenUserConnectionChange() { private void listenUserConnectionChange() {
rtmt.listenOnClientConnectionChange((clientId, isOnline) -> { rtmt.listenOnClientConnectionChange((clientId, isOnline) -> {
BackendGame game = gameStore.getGameByUser(clientId); BackendGame game = gameStore.getGameByUser(clientId);

View File

@ -5,12 +5,15 @@ import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonNode; 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.CommonMancalaGame;
import app.playmancala.engine.GameState; import app.playmancala.engine.GameState;
import app.playmancala.engine.MancalaGame; import app.playmancala.engine.MancalaGame;
@ -53,7 +56,9 @@ public final class GameSerialization {
int peakSpectators, int peakSpectators,
List<String> allSpectatorIds, List<String> allSpectatorIds,
GameClock clock, GameClock clock,
@JsonInclude(JsonInclude.Include.ALWAYS) List<Long> moveRemainingMs) { @JsonInclude(JsonInclude.Include.ALWAYS) List<Long> moveRemainingMs,
// Games against the bot only.
BotSettings bot) {
} }
public record GameEnding(EndReason endReason, String winnerId, String leftUserId) { 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()), mancalaGame.getTurnPlayerId(), mancalaGame.getState(), boxed(mancalaGame.getBoard().getStoneArray()),
List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt, List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt,
List.copyOf(game.moveTimes), game.peakSpectators, List.copyOf(game.allSpectatorIds), game.clock, 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. // 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, return new BackendGame(mancalaGame,
new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)), new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)),
orEmpty(snapshot.allSpectatorIds()), snapshot.peakSpectators(), players, snapshot.startedAt(), 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. // Null when the game ended without a winner and was not completed, e.g. both players left.
@ -138,7 +152,7 @@ public final class GameSerialization {
publicSummary(game, mancalaGame.getPlayer2Id()), boxed(mancalaGame.getBoard().getStoneArray()), publicSummary(game, mancalaGame.getPlayer2Id()), boxed(mancalaGame.getBoard().getStoneArray()),
getResult(game, ending), ending.endReason(), getResult(game, ending), ending.endReason(),
ending.leftUserId() == null ? null : PublicIds.publicUserId(ending.leftUserId()), game.startedAt, endedAt, 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. // The player with a public id, for anything saved or listed.

View File

@ -6,6 +6,8 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; 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. // Live games in memory, the source of truth for them; Valkey only holds a copy. Only touched on the game loop.
public class GameStore { public class GameStore {
// In the order games started. // In the order games started.
@ -28,7 +30,9 @@ public class GameStore {
games.remove(id); games.remove(id);
} }
// Bots play many games at once, so they are not mapped to one.
public void setGameByUser(String userId, BackendGame game) { public void setGameByUser(String userId, BackendGame game) {
if (BotLevel.isBotId(userId)) return;
gameIdsByUser.put(userId, game.id); gameIdsByUser.put(userId, game.id);
} }

View File

@ -6,9 +6,10 @@ import java.security.NoSuchAlgorithmException;
import java.util.HexFormat; import java.util.HexFormat;
import app.playmancala.auth.KeycloakTokenVerifier; 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 // 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 { public final class PublicIds {
private static final String LEGACY_PUBLIC_ID_PREFIX = "legacy:"; private static final String LEGACY_PUBLIC_ID_PREFIX = "legacy:";
@ -16,7 +17,8 @@ public final class PublicIds {
} }
public static String publicUserId(String userId) { 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; return userId;
} }
try { try {

View File

@ -23,7 +23,7 @@ public final class Migrations {
private static final long MIGRATION_LOCK_ID = 727274; 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. // Applied in this order; never change a migration that has been released, add a new one instead.
public static final List<String> VERSIONS = List.of("001_games", "002_clock"); public static final List<String> VERSIONS = List.of("001_games", "002_clock", "003_bot");
private Migrations() { private Migrations() {
} }

View File

@ -14,6 +14,7 @@ import javax.sql.DataSource;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import app.playmancala.bot.BotSettings;
import app.playmancala.model.EndReason; import app.playmancala.model.EndReason;
import app.playmancala.model.FinishedGameSummary; import app.playmancala.model.FinishedGameSummary;
import app.playmancala.model.GameRecord; import app.playmancala.model.GameRecord;
@ -58,9 +59,9 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
int inserted; int inserted;
try (PreparedStatement statement = connection.prepareStatement(""" try (PreparedStatement statement = connection.prepareStatement("""
INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at, 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), VALUES (?::uuid, ?, ?, ?, ?, ?, to_timestamp(? / 1000.0), to_timestamp(? / 1000.0),
?, ?::jsonb, ?::jsonb, ?, ?, ?) ?, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb)
ON CONFLICT (id) DO NOTHING""")) { ON CONFLICT (id) DO NOTHING""")) {
statement.setString(1, record.id()); statement.setString(1, record.id());
statement.setString(2, record.player1().userId()); statement.setString(2, record.player1().userId());
@ -77,6 +78,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
statement.setInt(13, record.totalSpectators()); statement.setInt(13, record.totalSpectators());
if (record.clockInitialMs() == null) statement.setNull(14, Types.INTEGER); if (record.clockInitialMs() == null) statement.setNull(14, Types.INTEGER);
else statement.setInt(14, Math.toIntExact(record.clockInitialMs())); else statement.setInt(14, Math.toIntExact(record.clockInitialMs()));
statement.setString(15, record.bot() == null ? null : Json.write(record.bot()));
inserted = statement.executeUpdate(); inserted = statement.executeUpdate();
} }
if (inserted == 0) return null; if (inserted == 0) return null;
@ -103,7 +105,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
if (!UUID_PATTERN.matcher(id).matches()) return null; if (!UUID_PATTERN.matcher(id).matches()) return null;
return query(connection -> { return query(connection -> {
try (PreparedStatement statement = connection.prepareStatement( 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); statement.setString(1, id);
try (ResultSet row = statement.executeQuery()) { try (ResultSet row = statement.executeQuery()) {
if (!row.next()) return null; 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(), return new GameRecord(summary.id(), summary.player1(), summary.player2(), summary.board(),
summary.result(), summary.endReason(), summary.leftUserId(), summary.startedAt(), summary.result(), summary.endReason(), summary.leftUserId(), summary.startedAt(),
summary.endedAt(), summary.moveCount(), summary.peakSpectators(), summary.clockInitialMs(), 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));
} }
} }
}); });

View File

@ -4,6 +4,8 @@ package app.playmancala.model;
public final class Channels { public final class Channels {
public static final String NEW_GAME = "new_game"; public static final String NEW_GAME = "new_game";
public static final String CANCEL_NEW_GAME = "cancel_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 ON_GAME_START = "on_game_start";
public static final String GAME_MOVE = "game_move"; public static final String GAME_MOVE = "game_move";
public static final String ON_GAME_UPDATE = "on_game_update"; public static final String ON_GAME_UPDATE = "on_game_update";

View File

@ -5,10 +5,12 @@ import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyOrder; 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 // 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", @JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt",
"moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators" }) "moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators", "bot" })
public record GameRecord( public record GameRecord(
String id, String id,
PlayerSummary player1, PlayerSummary player1,
@ -23,7 +25,16 @@ public record GameRecord(
int peakSpectators, int peakSpectators,
@JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs, @JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs,
List<MoveRecord> moves, List<MoveRecord> 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<Integer> board, GameResult result,
EndReason endReason, String leftUserId, long startedAt, long endedAt, int moveCount, int peakSpectators,
Long clockInitialMs, List<MoveRecord> moves, int totalSpectators) {
this(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, moveCount, peakSpectators,
clockInitialMs, moves, totalSpectators, null);
}
public FinishedGameSummary toSummary() { public FinishedGameSummary toSummary() {
return new FinishedGameSummary(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt, return new FinishedGameSummary(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt,

View File

@ -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). # Time each player has for a whole game (docs/specs/game-clock).
mancala.game-clock-ms=${GAME_CLOCK_MS:300000} 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. # Postgres. Tests start one with Dev Services. The backend starts without it; migrations are retried.
quarkus.datasource.db-kind=postgresql quarkus.datasource.db-kind=postgresql
%dev,prod.quarkus.datasource.jdbc.url=${DATABASE_JDBC_URL:jdbc:postgresql://localhost:5433/mancala} %dev,prod.quarkus.datasource.jdbc.url=${DATABASE_JDBC_URL:jdbc:postgresql://localhost:5433/mancala}

View File

@ -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;

View File

@ -0,0 +1,250 @@
package app.playmancala.bot;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.SplittableRandom;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import app.playmancala.engine.GameState;
import app.playmancala.engine.Pit;
import app.playmancala.fakes.FakeRtmt;
import app.playmancala.fakes.InMemoryGameHistoryRepository;
import app.playmancala.fakes.InMemoryLiveGameStore;
import app.playmancala.game.BackendGame;
import app.playmancala.game.GameManager;
import app.playmancala.game.GameRecorder;
import app.playmancala.game.GameStore;
import app.playmancala.game.MatchMaker;
import app.playmancala.game.PublicIds;
import app.playmancala.loop.ManualScheduler;
import app.playmancala.model.AuthUser;
import app.playmancala.model.Channels;
import app.playmancala.model.EndReason;
import app.playmancala.model.GameClock;
import app.playmancala.model.GameRecord;
import app.playmancala.model.Json;
import app.playmancala.model.PlayerSummary;
// Games against the bot, with the real game logic, a manual clock and searches that run at once.
class BotPlayerTest {
static final AuthUser ALICE = new AuthUser("kc:alice", "alice", false);
static final AuthUser CAROL = new AuthUser("kc:carol", "carol", false);
static final long CLOCK_MS = 5 * 60_000;
// Longer than any wait: the start delay or a long animation plus the longest thinking time.
static final long LONGEST_WAIT_MS = 20_000;
ManualScheduler loop;
FakeRtmt rtmt;
GameStore gameStore;
InMemoryLiveGameStore liveGameStore;
InMemoryGameHistoryRepository repository;
GameManager gameManager;
BotPlayer botPlayer;
void createBackend() {
rtmt = new FakeRtmt();
gameStore = new GameStore();
GameRecorder recorder = new GameRecorder(repository, liveGameStore, Runnable::run);
gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, Runnable::run,
loop::now, CLOCK_MS);
botPlayer = new BotPlayer(gameManager, gameStore, loop, Runnable::run, new SplittableRandom(1));
rtmt.connect(ALICE);
rtmt.connect(CAROL);
}
@BeforeEach
void setUp() {
loop = new ManualScheduler(Instant.parse("2026-10-16T12:00:00Z").toEpochMilli());
liveGameStore = new InMemoryLiveGameStore();
repository = new InMemoryGameHistoryRepository();
createBackend();
}
// A game between Alice and the bot, with the given player to move first.
BackendGame startBotGame(BotSettings bot, String firstPlayerId, int... stones) {
BackendGame game = gameManager.createMancalaGame(ALICE.id(), bot.botId(), bot);
game.mancalaGame.setTurnPlayerId(firstPlayerId);
setBoard(game, stones);
gameManager.startGame(game);
return game;
}
static void setBoard(BackendGame game, int... stones) {
List<Pit> 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<Pit> 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<String> movers = game.mancalaGame.getHistory().stream().map(move -> move.getPlayerId()).toList();
assertEquals(List.of("bot:master", "bot:master"), movers.subList(0, 2));
assertEquals(11, game.mancalaGame.getHistory().getFirst().getMoveIndex());
}
@Test
void dropsTheMoveWhenTheGameEndedMeanwhile() {
BackendGame game = startBotGame(new BotSettings(BotLevel.HARD, 70, 1), "bot:hard");
rtmt.receive(ALICE.id(), Channels.LEAVE_GAME);
loop.advance(LONGEST_WAIT_MS);
assertEquals(0, game.mancalaGame.getHistory().size());
assertEquals(0, loop.pendingTimers());
GameRecord record = repository.records.get(game.id);
assertEquals(EndReason.LEFT, record.endReason());
}
@Test
void hurriesWhenItsClockRunsLow() {
BackendGame game = startBotGame(new BotSettings(BotLevel.MASTER, 90, 1), ALICE.id());
game.clock = new GameClock(CLOCK_MS, CLOCK_MS, 20_000, loop.now());
rtmt.receive(ALICE.id(), Channels.GAME_MOVE, Map.of("index", 2));
loop.advance(BotPlayer.LOW_TIME_THINK_MS);
assertEquals(2, game.mancalaGame.getHistory().size());
}
@Test
void playsAWholeGameThatIsSavedWithTheBot() {
BackendGame game = startBotGame(new BotSettings(BotLevel.MEDIUM, 40, 11), ALICE.id());
for (int turn = 0; turn < 200 && game.mancalaGame.getState() != GameState.ENDED; turn++) {
if (game.isBotTurn()) loop.advance(LONGEST_WAIT_MS);
else playAnyMove(game, ALICE.id());
}
GameRecord record = repository.records.get(game.id);
assertNotNull(record, "the game did not end");
assertEquals(EndReason.COMPLETED, record.endReason());
assertEquals(new PlayerSummary("bot:medium", "Bot", false), record.player2());
assertEquals(new BotSettings(BotLevel.MEDIUM, 40, 11), record.bot());
assertTrue(record.moves().stream().anyMatch(move -> move.playerId().equals("bot:medium")));
assertTrue(rtmt.sent.stream().noneMatch(message -> message.clientId().startsWith("bot:")));
}
@Test
void goesOnAfterARestartAtTheSameStrength() {
BotSettings bot = new BotSettings(BotLevel.HARD, 66, 99);
BackendGame game = startBotGame(bot, "bot:hard");
assertEquals(Json.tree(Map.of("level", "hard", "strength", 66, "seed", 99L)),
liveGameStore.games.get(game.id).get("bot"));
// A new process: the old loop and its timers are gone.
loop = new ManualScheduler(loop.now());
createBackend();
assertEquals(1, gameManager.restoreLiveGames());
BackendGame restored = gameStore.get(game.id);
assertEquals(bot, restored.bot);
loop.advance(LONGEST_WAIT_MS);
assertEquals(1, restored.mancalaGame.getHistory().size());
}
@Test
void restoresABotGameWithoutSettingsAtTheMiddleOfItsLevel() {
BackendGame game = startBotGame(new BotSettings(BotLevel.EASY, 6, 5), ALICE.id());
((ObjectNode) liveGameStore.games.get(game.id)).remove("bot");
loop = new ManualScheduler(loop.now());
createBackend();
gameManager.restoreLiveGames();
assertEquals(BotLevel.EASY, gameStore.get(game.id).bot.level());
assertEquals(BotLevel.EASY.middleStrength(), gameStore.get(game.id).bot.strength());
}
@Test
void botIdsArePublic() {
assertEquals("bot:easy", PublicIds.publicUserId("bot:easy"));
}
}

View File

@ -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<List<Integer>> 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<Integer> 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));
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -19,6 +19,8 @@ import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import app.playmancala.bot.BotLevel;
import app.playmancala.bot.BotSettings;
import app.playmancala.engine.GameStep; import app.playmancala.engine.GameStep;
import app.playmancala.model.EndReason; import app.playmancala.model.EndReason;
import app.playmancala.model.FinishedGameSummary; import app.playmancala.model.FinishedGameSummary;
@ -88,7 +90,7 @@ class PostgresGameHistoryRepositoryTest {
ResultSet rows = statement.executeQuery("SELECT version FROM schema_migrations ORDER BY version")) { ResultSet rows = statement.executeQuery("SELECT version FROM schema_migrations ORDER BY version")) {
while (rows.next()) versions.add(rows.getString(1)); 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 @Test
@ -115,6 +117,20 @@ class PostgresGameHistoryRepositoryTest {
assertFalse(saved.at("/moves/0").has("remainingMs")); 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 @Test
void keepsLeftUserIdForGamesAPlayerLeft() { void keepsLeftUserIdForGamesAPlayerLeft() {
GameRecord game = record(ALICE, BOB, GameResult.PLAYER2, EndReason.LEFT, ALICE.userId(), 0, 0, null, null); GameRecord game = record(ALICE, BOB, GameResult.PLAYER2, EndReason.LEFT, ALICE.userId(), 0, 0, null, null);

View File

@ -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 = (
<button className="btn ghost ic-m" aria-label={t("PlayWithBot")}>
<BotIcon /><span className="hide-m">{t("PlayWithBot")}</span>
</button>
);
const choose = (level: BotLevel) => {
setLastBotLevel(context.storage, level);
onChoose(level);
};
return (
<Menu menuButton={menuButton} transition align="end" offsetY={8}>
<MenuHeader>{t("ChooseBotLevel")}</MenuHeader>
{BOT_LEVELS.map((level) => (
<MenuItem key={level} type="checkbox" checked={level === lastLevel} onClick={() => choose(level)}>
{t(getBotLevelTextKey(level))}
</MenuItem>
))}
</Menu>
);
};
export default BotLevelMenu;

View File

@ -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 (
<div className="levels" role="group" aria-label={t("ChooseBotLevel")}>
{BOT_LEVELS.map((level) => (
<button key={level} className={`btn ${level === lastLevel ? "pri" : "ghost"}`} onClick={() => choose(level)}>
{t(getBotLevelTextKey(level))}
</button>
))}
<style jsx>{`
.levels {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
`}</style>
</div>
);
};
export default BotLevelPicker;

View File

@ -67,3 +67,7 @@ export const PlayIcon = ({ size = 22 }: IconProps) => (
export const PauseIcon = ({ size = 22 }: IconProps) => ( export const PauseIcon = ({ size = 22 }: IconProps) => (
<svg {...stroke(size, 3)}><path d="M8 5v14M16 5v14" /></svg> <svg {...stroke(size, 3)}><path d="M8 5v14M16 5v14" /></svg>
); );
export const BotIcon = ({ size = 20 }: IconProps) => (
<svg {...stroke(size, 2)}><rect x="4" y="8" width="16" height="12" rx="4" /><path d="M12 4v4" /><circle cx="12" cy="3.5" r="1" /><path d="M9 13v2M15 13v2" /></svg>
);

View File

@ -9,9 +9,11 @@ const GameOverCard: FunctionComponent<{
title: string, title: string,
reason?: string, reason?: string,
score: string, score: string,
// "New Game" unless given, e.g. "Play again" after a game against the bot.
newGameLabel?: string,
onNewGame: () => void, onNewGame: () => void,
onClose: () => void, onClose: () => void,
}> = ({ title, reason, score, onNewGame, onClose }) => { }> = ({ title, reason, score, newGameLabel, onNewGame, onClose }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
@ -22,7 +24,7 @@ const GameOverCard: FunctionComponent<{
{reason && <div className="oreason">{reason}</div>} {reason && <div className="oreason">{reason}</div>}
<div className="oscore">{score}</div> <div className="oscore">{score}</div>
<div className="row"> <div className="row">
<button className="btn pri" onClick={onNewGame}>{t("NewGame")}</button> <button className="btn pri" onClick={onNewGame}>{newGameLabel ?? t("NewGame")}</button>
<button className="btn ghost" onClick={() => navigate("/")}>{t("Home")}</button> <button className="btn ghost" onClick={() => navigate("/")}>{t("Home")}</button>
</div> </div>
</div> </div>

View File

@ -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 { 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 { Context } from '../context/context';
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core"; 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 { BoardViewModel } from "@mancala/core";
import notyf from '../util/Notyf'; import notyf from '../util/Notyf';
import swal from 'sweetalert'; import swal from 'sweetalert';
@ -191,6 +191,14 @@ const GamePage: FunctionComponent<{
navigate("/loby") 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) => { const onPitSelect = (index: number, pit: Pit) => {
if (!game || isSpectator || !userKey) { if (!game || isSpectator || !userKey) {
return; return;
@ -386,7 +394,8 @@ const GamePage: FunctionComponent<{
title={resultTitle ?? t("GameEnded")} title={resultTitle ?? t("GameEnded")}
reason={resultReason} reason={resultReason}
score={`${bottomLocatedUser.name} ${board.pits[bottomStore].stoneCount} – ${board.pits[topStore].stoneCount} ${topLocatedUser.name}`} 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)} /> onClose={() => setGameOverClosed(true)} />
)} )}
</div> </div>

View File

@ -1,10 +1,11 @@
import * as React from "react"; import * as React from "react";
import { FunctionComponent, useEffect, useState } from "react"; import { FunctionComponent, useEffect, useState } from "react";
import { LiveGameSummary } from "@mancala/core"; 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 AppHeader from "../components/headerbar/AppHeader";
import PageContainer from "../components/PageContainer"; import PageContainer from "../components/PageContainer";
import { PlusIcon } from "../components/Icons"; import { PlusIcon } from "../components/Icons";
import BotLevelMenu from "../components/BotLevelMenu";
import ActiveGameBanner from "../components/home/ActiveGameBanner"; import ActiveGameBanner from "../components/home/ActiveGameBanner";
import LiveGamesSection from "../components/home/LiveGamesSection"; import LiveGamesSection from "../components/home/LiveGamesSection";
import RecentGamesSection from "../components/home/RecentGamesSection"; import RecentGamesSection from "../components/home/RecentGamesSection";
@ -43,9 +44,15 @@ const Home: FunctionComponent<{
navigate("/loby") navigate("/loby")
}; };
const onBotLevelChosen = (level: BotLevel) => {
if(Util.checkConnectionAndMaybeAlert(context, t)) return;
navigate(`/loby?bot=${level}`)
};
return ( return (
<PageContainer> <PageContainer>
<AppHeader context={context} authUser={authUser}> <AppHeader context={context} authUser={authUser}>
<BotLevelMenu context={context} onChoose={onBotLevelChosen} />
<button className="btn pri ic-m" aria-label={t("NewGame")} onClick={onNewGameClick}> <button className="btn pri ic-m" aria-label={t("NewGame")} onClick={onNewGameClick}>
<PlusIcon /><span className="hide-m">{t("NewGame")}</span> <PlusIcon /><span className="hide-m">{t("NewGame")}</span>
</button> </button>

View File

@ -1,10 +1,11 @@
import { CommonMancalaGame } from 'mancala.js'; import { CommonMancalaGame } from 'mancala.js';
import * as React from 'react'; import * as React from 'react';
import { FunctionComponent, useEffect, useState } 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 AppHeader from '../components/headerbar/AppHeader';
import PageContainer from '../components/PageContainer'; 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 { Context } from '../context/context';
import { Theme } from '@mancala/core'; import { Theme } from '@mancala/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -19,9 +20,17 @@ const LobyPage: FunctionComponent<{
const { t } = useTranslation(); const { t } = useTranslation();
// /loby?bot=<level> 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. // Set when the server refuses a new game because the user has one in progress.
const [activeGameId, setActiveGameId] = useState<string | undefined>(undefined); const [activeGameId, setActiveGameId] = useState<string | undefined>(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 onNewGameRejected = (message: { gameId: string }) => setActiveGameId(message.gameId);
const onGameStart = (message: Object) => { const onGameStart = (message: Object) => {
@ -29,6 +38,10 @@ const LobyPage: FunctionComponent<{
navigate(`/game/${newGame.id}`) navigate(`/game/${newGame.id}`)
} }
const startBotGame = (level: BotLevel) => {
context.rtmt.sendMessage(channel_new_bot_game, { level });
}
const onCancelNewGameClick = () => { const onCancelNewGameClick = () => {
if (context.rtmt.connectionState === "connected") { if (context.rtmt.connectionState === "connected") {
context.rtmt.sendMessage(channel_cancel_new_game, ""); context.rtmt.sendMessage(channel_cancel_new_game, "");
@ -38,8 +51,15 @@ const LobyPage: FunctionComponent<{
useEffect(() => { useEffect(() => {
context.rtmt.addMessageListener(channel_on_game_start, onGameStart); context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
context.rtmt.addMessageListener(channel_on_new_game_rejected, onNewGameRejected); context.rtmt.addMessageListener(channel_on_new_game_rejected, onNewGameRejected);
context.rtmt.sendMessage("new_game", {}); let botOfferTimer: ReturnType<typeof setTimeout> | undefined;
if (botLevel) {
startBotGame(botLevel);
} else {
context.rtmt.sendMessage(channel_new_game, {});
botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS);
}
return () => { return () => {
if (botOfferTimer) clearTimeout(botOfferTimer);
context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); context.rtmt.removeMessageListener(channel_on_game_start, onGameStart);
context.rtmt.removeMessageListener(channel_on_new_game_rejected, onNewGameRejected); context.rtmt.removeMessageListener(channel_on_new_game_rejected, onNewGameRejected);
} }
@ -51,8 +71,14 @@ const LobyPage: FunctionComponent<{
<main className="wrap"> <main className="wrap">
<section className="search panel" aria-live="polite"> <section className="search panel" aria-live="polite">
{!activeGameId && <span className="dot" />} {!activeGameId && <span className="dot" />}
<h1>{activeGameId ? t("GameInProgress") : t("SearchingOpponent")}</h1> <h1>{activeGameId ? t("GameInProgress") : botLevel ? t("PlayWithBot") : t("SearchingOpponent")}</h1>
{!activeGameId && <p>{`${t("PleaseWait")}...`}</p>} {!activeGameId && <p>{`${t("PleaseWait")}...`}</p>}
{!activeGameId && botOffered && (
<div className="offer">
<p>{t("NoOpponentYet")}</p>
<BotLevelPicker context={context} onChoose={startBotGame} />
</div>
)}
<div className="actions"> <div className="actions">
{activeGameId && ( {activeGameId && (
<button className="btn pri" onClick={() => navigate(`/game/${activeGameId}`)}>{t("Continue")}</button> <button className="btn pri" onClick={() => navigate(`/game/${activeGameId}`)}>{t("Continue")}</button>
@ -90,6 +116,14 @@ const LobyPage: FunctionComponent<{
margin: 0; margin: 0;
color: var(--muted); color: var(--muted);
} }
.offer {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid var(--line);
}
.actions { .actions {
display: flex; display: flex;
gap: 10px; gap: 10px;

View File

@ -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 (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 8 }}>
{BOT_LEVELS.map((level) => (
<Pressable key={level} accessibilityRole="button" onPress={() => {
setLastBotLevel(context.storage, level);
onChoose(level);
}}>
<CircularPanel
color={level === lastLevel ? theme.boardColor : theme.background}
style={{ borderWidth: 1, borderColor: theme.boardColor }}
children={<Text style={{ color: theme.textColor }}>{t(getBotLevelTextKey(level))}</Text>} />
</Pressable>
))}
</View>
);
};
export default BotLevelPicker;

View File

@ -3,7 +3,7 @@ import { View, Text, useWindowDimensions, Alert, Pressable } from 'react-native'
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { GameScreenProps } from '../types'; import { GameScreenProps } from '../types';
import { useState } from 'react'; 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 { MancalaGame, Pit } from 'mancala.js';
import { v4 } from 'uuid'; 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'; 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 = () => { const onNewGameClick = () => {
if (Util.checkConnectionAndMaybeAlert(context, t("ConnectionLost"))) return; if (Util.checkConnectionAndMaybeAlert(context, t("ConnectionLost"))) return;
context.storage.deleteString('lastGameId'); 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) => { const onPitSelect = (index: number, pit: Pit) => {
@ -250,7 +252,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
onLeaveGameClick(); onLeaveGameClick();
} }
}}> }}>
<CircularPanel color={context.themeManager.theme?.background} children={<Text style={{ color: context.themeManager.theme.textColor }}>{isError ? t('Home') : (isGameEnded ? t('NewGame') : t('Leave'))}</Text>} /> <CircularPanel color={context.themeManager.theme?.background} children={<Text style={{ color: context.themeManager.theme.textColor }}>{isError ? t('Home') : (isGameEnded ? (!isSpectator && getBotLevel(getOpponentId()) ? t('PlayAgain') : t('NewGame')) : t('Leave'))}</Text>} />
</Pressable> </Pressable>
<Pressable onPress={() => { <Pressable onPress={() => {
navigation.push("Help", { context }) navigation.push("Help", { context })
@ -278,17 +280,20 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
const showBoardView = game && boardViewModel && userKey && true; const showBoardView = game && boardViewModel && userKey && true;
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0"; const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1"; 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 = { const topLocatedUser = {
id: topLocatedUserId, id: topLocatedUserId,
name: "Anonymous", name: topBotName ?? "Anonymous",
isOnline: isUserOnline(topLocatedUserId), isOnline: isUserOnline(topLocatedUserId),
isAnonymous: true isAnonymous: !topBotName
}; };
const bottomLocatedUser = { const bottomLocatedUser = {
id: bottomLocatedUserId, id: bottomLocatedUserId,
name: "Anonymous", name: bottomBotName ?? "Anonymous",
isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected", isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected",
isAnonymous: true isAnonymous: !bottomBotName
}; };
const currentUser = isSpectator ? { const currentUser = isSpectator ? {
id: "2", id: "2",

View File

@ -3,6 +3,7 @@ import { View, Button, Pressable, Text } from 'react-native';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { HomeScreenProps } from '../types'; import { HomeScreenProps } from '../types';
import CircularPanel from '../components/CircularPanel'; import CircularPanel from '../components/CircularPanel';
import BotLevelPicker from '../components/BotLevelPicker';
import { SvgXml } from 'react-native-svg'; import { SvgXml } from 'react-native-svg';
import helpSvg from '../svg/help'; import helpSvg from '../svg/help';
import { getColorByBrightness } from "@mancala/core"; import { getColorByBrightness } from "@mancala/core";
@ -12,6 +13,7 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) {
const { context } = route.params; const { context } = route.params;
const { t } = useTranslation(); const { t } = useTranslation();
const [lastGameId, setLastGameId] = React.useState<string | undefined>(undefined); const [lastGameId, setLastGameId] = React.useState<string | undefined>(undefined);
const [choosingBotLevel, setChoosingBotLevel] = React.useState(false);
const textColorOnAppBar = getColorByBrightness( const textColorOnAppBar = getColorByBrightness(
context.themeManager.theme.appBarBgColor, context.themeManager.theme.appBarBgColor,
@ -44,6 +46,17 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) {
<Pressable onPress={() => navigation.replace('Loby', { context })}> <Pressable onPress={() => navigation.replace('Loby', { context })}>
<CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('NewGame')}</Text>} /> <CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('NewGame')}</Text>} />
</Pressable> </Pressable>
<View style={{ height: 20 }} />
{choosingBotLevel ? (
<View style={{ alignItems: 'center', gap: 10, paddingHorizontal: 16 }}>
<Text style={{ color: context.themeManager.theme.textColor }}>{t('ChooseBotLevel')}</Text>
<BotLevelPicker context={context} onChoose={(botLevel) => navigation.replace('Loby', { context, botLevel })} />
</View>
) : (
<Pressable onPress={() => setChoosingBotLevel(true)}>
<CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('PlayWithBot')}</Text>} />
</Pressable>
)}
{lastGameId && ( {lastGameId && (
<> <>
<View style={{ height: 20 }} /> <View style={{ height: 20 }} />

View File

@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
import { LobyScreenProps } from '../types'; import { LobyScreenProps } from '../types';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { CommonMancalaGame } from 'mancala.js'; 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 CircularPanel from '../components/CircularPanel';
import { getColorByBrightness } from "@mancala/core"; import { getColorByBrightness } from "@mancala/core";
import { SvgXml } from 'react-native-svg'; import { SvgXml } from 'react-native-svg';
@ -12,7 +13,13 @@ import helpSvg from '../svg/help';
export default function LobyScreen({ navigation, route }: LobyScreenProps) { 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 { t } = useTranslation();
const onGameStart = async (message: Object) => { const onGameStart = async (message: Object) => {
@ -32,8 +39,15 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) {
useEffect(() => { useEffect(() => {
context.rtmt.addMessageListener(channel_on_game_start, onGameStart); context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
context.rtmt.sendMessage("new_game", {}); let botOfferTimer: ReturnType<typeof setTimeout> | undefined;
if (botLevel) {
startBotGame(botLevel);
} else {
context.rtmt.sendMessage(channel_new_game, {});
botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS);
}
return () => { return () => {
if (botOfferTimer) clearTimeout(botOfferTimer);
context.rtmt.removeMessageListener(channel_on_game_start, onGameStart); context.rtmt.removeMessageListener(channel_on_game_start, onGameStart);
} }
}, []); }, []);
@ -62,7 +76,13 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) {
return ( return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: context.themeManager.theme?.background }}> <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: context.themeManager.theme?.background }}>
<CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: "#000" }}>{t('SearchingOpponent') + " " + t('PleaseWait') + "..."}</Text>} /> <CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: "#000" }}>{(botLevel ? t('PlayWithBot') : t('SearchingOpponent')) + " " + t('PleaseWait') + "..."}</Text>} />
{botOffered && (
<View style={{ alignItems: 'center', gap: 10, marginTop: 20, paddingHorizontal: 16 }}>
<Text style={{ color: context.themeManager.theme.textColor }}>{t('NoOpponentYet')}</Text>
<BotLevelPicker context={context} onChoose={startBotGame} />
</View>
)}
<View style={{height: 10}}></View> <View style={{height: 10}}></View>
<Pressable onPress={onCancelNewGameClick}> <Pressable onPress={onCancelNewGameClick}>
<CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('Cancel')}</Text>} /> <CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('Cancel')}</Text>} />

View File

@ -1,9 +1,11 @@
import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { BotLevel } from '@mancala/core';
import { Context } from '../context/context'; import { Context } from '../context/context';
export type RootStackParamList = { export type RootStackParamList = {
Home: { context: Context }, 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 } Game: { context: Context, gameId: string, userKey: string }
Help: { context: Context } Help: { context: Context }
}; };

View File

@ -0,0 +1,239 @@
# Game bot: design
See `requirements.md` for what is built; requirement ids (R1.1, …) are referenced below. Builds on `game-clock`
(the clock), `game-history` (saved games, live previews) and `quarkus-backend` (the Java backend and its engine).
## Decisions
| Decision | Reason |
|---|---|
| The bot runs in the backend, in Java | The rules are already in Java (`app.playmancala.engine`) and kept equal to the TypeScript engine by `EngineParityTest`. A bot game is then an ordinary server game: the clock, saving, spectators, previews and restarts work without changes in the clients (R4). A bot on the device would only add offline play, at the cost of a second bot in TypeScript (out of scope). |
| A compact board, `Position` (`int[14]` and whose turn it is), in the engine package | Searching means playing millions of moves; `MancalaGame` builds `Pit` objects, lists and animation steps for each, which is far too slow. `Position` plays a move into an existing array without allocating. It is in `engine`, not `bot`, so other code can use it too, e.g. checking a move before running it (see `TODO.md`, Security). It is tested against `MancalaGame` with the recorded parity games and random games. |
| `MancalaGame` is not rebuilt on `Position` now | `MancalaGame` also produces the game steps clients animate, the history and the JSON the Node backend wrote, and it must stay equal to the TypeScript engine. Rebuilding it is a separate refactor once `Position` has proven itself (out of scope). |
| Negamax with alpha-beta pruning, limited by depth and a node budget, not by time | Mangala is small (6 moves at most per turn), so alpha-beta is strong enough: at depth 12 the bot plays close to perfectly. Limits that do not depend on time make a move depend only on the position, the strength and the seed, so a game can be reproduced for debugging; a slower machine only takes longer. |
| Strength (0–100) sets three things: search depth, how much the bot prefers the best move (softmax temperature) and how often it plays a random move | Depth alone gives few, coarse steps and a predictable bot. With softmax, a weaker bot more often plays a move that is nearly as good, which looks like a human mistake; the random moves make Easy beatable by beginners (R2.3). Interpolating a table gives smooth steps between levels. |
| Each level is a range of strengths; the strength is drawn per game and varies by ±5 per move | R2.2. The ranges have gaps between them so neighbouring levels stay clearly apart (R2.4). |
| Randomness comes from a per-game seed and the move number | The bot's choice of a move is reproducible from the saved game, and a restart does not change how it plays (R4.6). The seed and strength are kept with the live game and the saved game. |
| Searching runs on its own small thread pool; the result goes back to the game loop, which checks that the game is still waiting for that move | The game loop must never wait (R3.5, `AGENTS.md`). The pool has a fixed number of threads, so many bot games share the CPU instead of starving the loop and the WebSocket threads. A result is dropped if the game ended or moved on meanwhile. |
| The bot's waiting time is computed on the server: the previous move's animation (300 ms per step, as `PitAnimator`) plus a random 1–3 seconds | R3.2. Clients do not need to know about bots for this. Searching happens during the wait, so the time it takes is not added to it. |
| Bot ids are `bot:<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;
export function isBotLevel(value: unknown): value is BotLevel;
export function getBotLevelTextKey(level: BotLevel): string; // e.g. BotLevelMedium
export function getLastBotLevel(storage): BotLevel; // Medium until one is chosen (R1.4)
export function setLastBotLevel(storage, level: BotLevel): void;
```
- `getPlayerDisplayName(player, t)` takes `userId` too (every caller has it) and returns e.g. "Bot · Orta" for
bots (R4.1).
- Texts (en, tr): `PlayWithBot` "Play with bot" / "Bota karşı oyna", `NoOpponentYet` "No opponent yet. Play with
the bot?" / "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?", `ChooseBotLevel` "Choose how strong the bot
is" / "Botun ne kadar güçlü olacağını seç", `Bot` "Bot" / "Bot", `BotLevelEasy`,
`BotLevelMedium`, `BotLevelHard`, `BotLevelMaster` "Easy", "Medium", "Hard", "Master" / "Kolay", "Orta", "Zor",
"Usta", `PlayAgain` "Play again" / "Tekrar oyna".
## Backend
### Modules
```
engine/Position.java compact board and the rules on it
bot/BotLevel.java the levels, their wire names and strength ranges
bot/BotSettings.java record (level, strength, seed); kept with a bot game
bot/BotStrength.java strength -> depth, temperature, random move chance
bot/BotSearch.java negamax with alpha-beta; scores of the root moves
bot/BotPolicy.java chooses a move: strength, search, softmax, random moves
bot/BotPlayer.java plays the bot's turns in live games
db/migrations/003_bot.sql bot column
```
`engine` and `bot` (except `BotPlayer`) have no Quarkus or game loop code, so they can be tested and run on their
own.
### `Position`
```java
public final class Position {
// CommonBoard layout: player 1's pits 0-5, bank 6; player 2's pits 7-12, bank 13.
public static Position initial(boolean player1ToMove);
public static Position of(int[] stones, boolean player1ToMove);
public static Position of(MancalaGame game);
public int[] stones(); // a copy
public boolean isPlayer1ToMove();
public int legalPits(int[] into); // pit numbers 0-5 of the mover's non-empty pits; returns the count
// Plays pit 0-5 of the mover into `into` (may be this). Returns true if the mover plays again.
public boolean play(int pit, Position into);
public boolean isOver(); // one side is empty
public int bankDifference(); // mover's bank minus the opponent's
}
```
`play` applies the rules in the engine's order: sowing (one stone goes to the next pit, otherwise the first stone
goes back into the played pit; the opponent's bank is skipped), last stone in an own empty pit, even count in an
opponent's pit, last stone in the own bank (extra turn) or the turn passes, and clearing the board when a side is
empty.
### Search (`BotSearch`)
- Negamax with alpha-beta. A move that gives an extra turn keeps the side to move, so its child's score is not
negated.
- Scores are from the mover's view, in hundredths of a stone. An ended game scores
`±100_000 + 100 × final bank difference`; a game where a bank holds more than 24 stones is decided and scores as
ended. Other leaves: `100 × bank difference`. A term for the stones on each side (`PIT_STONE_WEIGHT`) is there
but set to 0: the levels met R2.4 without it, and stones on a side may still go to either player.
- Move order: extra turns first, then by the stones a move brings to the bank. Each node plays its moves once to
order them, into arrays kept per ply, so searching allocates nothing.
- Iterative deepening up to the depth; each root move is searched with a full window so every root move gets an
exact score (the softmax needs them). If the node budget (3 million) runs out, the scores of the last completed
depth are used; depth 1 always completes.
### Strength (`BotLevel`, `BotStrength`)
| Level | Wire name | Strength |
|---|---|---|
| Easy | `easy` | 5–25 |
| Medium | `medium` | 30–55 |
| Hard | `hard` | 60–80 |
| Master | `master` | 85–100 |
Interpolated linearly between rows (depth rounded):
| Strength | Depth | Temperature (stones) | Random move chance |
|---|---|---|---|
| 0 | 1 | 4 | 35% |
| 25 | 2 | 2 | 15% |
| 50 | 4 | 1 | 5% |
| 75 | 7 | 0.4 | 1% |
| 100 | 16 | 0.05 | 0% |
The first table had depth 12 at strength 100 and already met R2.4. Searching deeper still helped: with best moves
only, depth 16 scored 64% against depth 12 (depth 14: 54%), at about 60 ms per move on average and 1 s at most
without a budget. So the top row is 16, and the node budget keeps the slowest moves near 0.2 s.
`BotTournamentTest` with these values, 200 games per pair (October 2026, 24 cores, one thread):
| Pair | Won | Lost | Drawn | Score | Stronger level's time per move |
|---|---|---|---|---|---|
| Medium vs Easy | 155 | 34 | 11 | 0.80 | < 0.1 ms |
| Hard vs Medium | 158 | 29 | 13 | 0.82 | 0.2 ms |
| Master vs Hard | 177 | 16 | 7 | 0.90 | 20 ms |
| Master vs Easy | 200 | 0 | 0 | 1.00 | 22 ms |
### Choosing a move (`BotPolicy`)
```java
static int chooseMove(Position position, BotSettings bot, int moveNumber); // pit 0-5
```
1. A random source from `bot.seed()` and `moveNumber` (number of moves played in the game).
2. This move's strength: `bot.strength()` ±5, kept within 0–100.
3. With the random move chance: a random legal pit.
4. Otherwise search to the depth and pick a root move with probability ∝ `exp(score / (temperature × 100))`.
5. With a single legal pit, play it without searching.
### Bot games in the game logic (`GameManager`)
- New channel `new_bot_game` with `{ level }`. An unknown level is logged and ignored. A player with a game in
progress gets `on_new_game_rejected` (R1.5). Otherwise `matchMaker.cancel(userId)`, then a game with the player
as player 1 and `bot:<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, Scheduler loop, Executor searchExecutor,
RandomGenerator random) // random: thinking times only; used on the loop
```
Listens to `onTurnStarted`. When it is the bot's turn and no move is being worked out for that game:
1. On the loop: copy the position (`Position.of`), note the move number and compute
`readyAt = now + animationMs(last move) + thinkMs(level)`. `animationMs` is 300 ms per `GAME_STEP_GAME_MOVE` step
of the last move plus 300 ms; before the first move it is 1.5 s, so the game page has opened. `thinkMs` is random: Easy 0.8–1.8 s, Medium 1.0–2.2 s, Hard 1.2–2.6 s, Master
1.4–3.0 s. With less than 30 s on the bot's clock it is 0.3 s (R3.3).
2. On `searchExecutor`: `BotPolicy.chooseMove`. If it throws, the error is logged and the first legal pit is used,
so the game never waits for a bot that failed.
3. Back on the loop (`loop.execute`, then `loop.schedule` for what is left until `readyAt`): if the game is still
live, it is the same `BackendGame`, the move number has not changed and it is the bot's turn, call
`gameManager.playMove(game, botId, pit)`; otherwise drop the result.
Games being worked on are kept in a set by identity, so a game restored with the same id is a new game for it.
`searchExecutor` is a fixed pool of `BOT_SEARCH_THREADS` platform threads (default: half the processors, at least
1), created and closed in `Backend`. Unit tests pass a direct executor and `ManualScheduler`.
### Restore (R4.6)
- `LiveGameSnapshot` gains `bot` (`@JsonInclude(NON_NULL)`); the snapshot stays version 2, since the field is
optional. `fromSnapshot` restores it. A snapshot with a bot player but no `bot` field (not expected) gets the
middle strength of the level and a new seed.
- `restoreLiveGames` calls the turn listeners, so a bot whose turn it was moves.
### Saving (R4.3)
- `GameRecord` gains `bot` (`@JsonInclude(NON_NULL)`): `{ level, strength, seed }`, sent with `GET /game/:id`;
clients do not use it. It allows tuning the levels from real games later.
- Migration `003_bot`: `ALTER TABLE games ADD COLUMN bot jsonb;` (NULL for games between people).
`PostgresGameHistoryRepository` writes and reads it.
### Configuration
`BOT_SEARCH_THREADS` (default: half the processors, at least 1).
## Web app
- `components/BotLevelPicker.tsx`: the four levels as buttons; the last chosen level is highlighted. It is kept with
`getLastBotLevel` / `setLastBotLevel` from core in the app's storage (R1.4).
- Home page: a "Play with bot" button next to "New Game" opens `BotLevelMenu`, a menu of the levels like the account
menu, with the last level checked; choosing a level goes to `/loby?bot=<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; low time shortens the wait; a restored game where
it is the bot's turn gets a move. (The fallback for a failing search has no test: `BotPolicy` is static and cannot
be made to fail from outside.)
- `GameManager`: `new_bot_game` starts a game with the bot and ends the search; it is refused during a game;
unknown levels are ignored; the bot shows as online and gets no messages; the saved record keeps the bot id and
`bot`; leaving and running out of time end the game as usual.
- Postgres repository: the `bot` column is saved and read. Snapshots with and without `bot` restore.
- End to end in Chrome with `GAME_CLOCK_MS=60000`: the offer appears after 10 s in the lobby and starts a game;
"Play with bot" on the home page; the bot moves after the animation; the game ends and shows in recent games and
the replay as "Bot · <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.
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.

View File

@ -0,0 +1,74 @@
# Game bot: requirements
Status: implemented on branch `feature/game-bot` (based on `develop`).
Scope: backend, `packages/core`, web app and mobile app.
## Goals
Players can always get a game: when no other player is looking for one, they can play against a bot. They choose
how strong the bot is, but within a level the bot is not always equally strong, so games against it differ from
each other. A game against the bot is a normal game: it has the clock, can be watched and is saved like any other.
## Terms
- **Bot**: a player run by the server.
- **Level**: what the player chooses: Easy, Medium, Hard or Master (Turkish: Kolay, Orta, Zor, Usta).
- **Strength**: a number from 0 to 100 that sets how well the bot plays. Each level is a range of strengths; the bot
takes a strength from it at the start of each game.
- **Bot game**: a game between a player and the bot.
## Requirements
### R1. Starting a bot game
- R1.1 When a player has been looking for an opponent for 10 seconds without one being found, the lobby offers a
game against the bot with the four levels to choose from. The search goes on while the offer is shown; if an
opponent is found first, the game with them starts as usual.
- R1.2 Choosing a level ends the search and starts a bot game at that level at once.
- R1.3 The home page also has a "Play with bot" button that opens the same choice of levels, without searching for
an opponent first.
- R1.4 The level chosen last is preselected the next time (kept on the device).
- R1.5 A player who has a game in progress cannot start a bot game; they are offered to continue that game, as when
they look for an opponent.
- R1.6 Who moves first is chosen at random, as in other games.
### R2. Levels and strength
- R2.1 There are four levels, from weakest to strongest: Easy, Medium, Hard, Master.
- R2.2 At the start of each game the bot takes a random strength within the level's range. Within a game, it plays
each move with a strength that varies a little around that value.
- R2.3 Easy makes obvious mistakes and can be beaten by a beginner. Master plays close to perfectly and is rarely
beaten.
- R2.4 Each level is clearly stronger than the one below: played against each other by the server, the stronger
of two neighbouring levels wins at least 65% of the games, and Master wins at least 95% against Easy.
- R2.5 Two games at the same level from the same starting position do not play out the same way: in 20 such games
against the same sequence of the opponent's moves, the bot's first five moves are not all the same.
### R3. How the bot plays
- R3.1 The bot plays by the same rules as players; its moves go through the same game logic.
- R3.2 The bot does not move instantly: it waits until the previous move's animation has finished, then about 1 to
3 seconds more, a little longer at the higher levels. An extra turn is played the same way.
- R3.3 The bot has a clock like any player and its waiting counts on it. It never runs out of time.
- R3.4 The bot never leaves a game and is always shown as online.
- R3.5 Working out the bot's moves never delays other games: other players' moves are handled as fast as without
bots, even while many bot games are being played.
### R4. During and after the game
- R4.1 The bot is shown as "Bot" with its level, e.g. "Bot · Medium" / "Bot · Orta", wherever players are shown:
the game page, live game previews, recent and popular games, and replays.
- R4.2 Everything that ends a game works as in other games: a player who leaves loses, a player whose time runs out
loses, a completed game is won by the player with more stones.
- R4.3 Bot games are saved like other games and appear in the player's recent games and can be replayed.
- R4.4 At the end of a bot game, the game over card offers "Play again", which starts a new bot game at the same
level.
- R4.5 Bot games can be watched by others and appear in live game previews like other games.
- R4.6 A bot game survives a backend restart: the bot goes on at the same strength, and if it was its turn, it moves.
### R5. Mobile app
- R5.1 The mobile app has the offer in the lobby (R1.1, R1.2), the button on the home screen (R1.3) and shows the
bot's name with its level (R4.1).
## Out of scope (TODO)
- Playing against the bot without a connection (a bot in the TypeScript engine on the device).
- Ratings or statistics; when they come, bot games should be kept apart from games against people.
- Hints or move analysis for players.
- Choosing an exact strength or who moves first.
- Rebuilding the game engine (`MancalaGame`, `Board`, the rules) on the compact board this feature adds.

View File

@ -0,0 +1,21 @@
# Game bot: tasks
Work through the tasks in order; each ends with its tests passing and a commit. Tick a task when it is committed.
- [x] 1. **Spec**: requirements, design and tasks.
- [x] 2. **Compact board**: `Position` in the engine package; tests against `MancalaGame` with the recorded parity
games and random games.
- [x] 3. **Search and strength**: `BotLevel`, `BotSettings`, `BotStrength`, `BotSearch`, `BotPolicy` with unit
tests; `BotTournamentTest` (tag `calibration`) and its quick version; tune the evaluation weights and the
strength table until R2.4 holds, and write the final values into `design.md`.
- [x] 4. **Bot games in the backend**: `new_bot_game`, `BackendGame.bot`, `playMove` split out of `onGameMove`,
`onTurnStarted`, bot ids in `GameStore`, `PublicIds` and the connection info; `BotPlayer` and its executor in
`Backend`, `BOT_SEARCH_THREADS`; `bot` in snapshots and their restore; unit tests.
- [x] 5. **Saving**: `bot` in `GameRecord`, migration `003_bot`, the repository; integration tests.
- [x] 6. **Core**: `channel_new_bot_game`, `BotLevel`, `BOT_LEVELS`, `BOT_OFFER_DELAY_MS`, `isBotId`,
`getBotLevel`, `getPlayerDisplayName` for bots, texts in English and Turkish; unit tests.
- [x] 7. **Web app**: `BotLevelPicker`, the home page button, the lobby offer and `?bot=`, "Play again" after a bot
game, bot names everywhere players are shown.
- [x] 8. **Mobile app**: lobby offer and `botLevel`, home screen button, bot names; type check and bundle.
- [x] 9. **End to end and docs**: the Chrome tests from `design.md`; `README.md`, `apps/backend/README.md`,
`AGENTS.md` (the specs list, bot ids, the calibration test) and `TODO.md` (the out of scope items).

View File

@ -106,4 +106,13 @@ export const en = {
StoreLabel: "{{name}}'s store, {{count}} stones", StoreLabel: "{{name}}'s store, {{count}} stones",
OpenGame: "Open the game", OpenGame: "Open the game",
Score: "Score", 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",
} }

View File

@ -106,4 +106,13 @@ export const tr = {
StoreLabel: "{{name}} hazinesi, {{count}} taş", StoreLabel: "{{name}} hazinesi, {{count}} taş",
OpenGame: "Oyunu aç", OpenGame: "Oyunu aç",
Score: "Skor", 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",
}; };

View File

@ -0,0 +1,42 @@
// Games against the bot (docs/specs/game-bot). The bot plays with the user id `bot:<level>`.
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);
}

View File

@ -6,3 +6,4 @@ export * from './UserConnectionInfo'
export * from './AuthUser' export * from './AuthUser'
export * from './GameHistory' export * from './GameHistory'
export * from './GameClock' export * from './GameClock'
export * from './Bot'

View File

@ -1,5 +1,7 @@
export const channel_new_game = "new_game" export const channel_new_game = "new_game"
export const channel_cancel_new_game = "cancel_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_on_game_start = "on_game_start"
export const channel_game_move = "game_move" export const channel_game_move = "game_move"
export const channel_on_game_update = "on_game_update" export const channel_on_game_update = "on_game_update"

View File

@ -1,11 +1,15 @@
import { getBotLevel, getBotLevelTextKey } from "../models/Bot";
const GUEST_USERNAME_PREFIX = "guest-"; const GUEST_USERNAME_PREFIX = "guest-";
// Name to show for a player, or undefined if the server sent none (legacy userKey players). // 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( export function getPlayerDisplayName(
player: { name?: string, isGuest?: boolean } | undefined, player: { userId?: string, name?: string, isGuest?: boolean } | undefined,
t: (key: string) => string t: (key: string) => string
): string | undefined { ): string | undefined {
const botLevel = getBotLevel(player?.userId);
if (botLevel) return `${t("Bot")} · ${t(getBotLevelTextKey(botLevel))}`;
if (!player?.name) return undefined; if (!player?.name) return undefined;
if (player.isGuest) { if (player.isGuest) {
const suffix = player.name.startsWith(GUEST_USERNAME_PREFIX) ? player.name.slice(GUEST_USERNAME_PREFIX.length) : player.name; const suffix = player.name.startsWith(GUEST_USERNAME_PREFIX) ? player.name.slice(GUEST_USERNAME_PREFIX.length) : player.name;

View File

@ -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<string, string>();
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<string, string>)[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();
});
});