Merge branch 'feature/game-bot' into develop
This commit is contained in:
commit
a6a309b78d
13
AGENTS.md
13
AGENTS.md
@ -23,7 +23,8 @@ Online Mancala (the Turkish *Mangala* rules) with a web app, an Android/iOS app
|
||||
|
||||
Specs that exist: `keycloak-auth` (login, guests, tokens), `game-history` (saved games, live games, replay),
|
||||
`game-clock` (5 minutes per player, ending games on time), `web-redesign` (the web app's look: themes, board,
|
||||
pages) and `quarkus-backend` (the backend's move from Node to Java and Quarkus).
|
||||
pages), `quarkus-backend` (the backend's move from Node to Java and Quarkus) and `game-bot` (playing against a bot
|
||||
at four levels).
|
||||
|
||||
## Running locally
|
||||
|
||||
@ -44,13 +45,15 @@ unless `useLocalServer` is set in `apps/mobile/src/const/config.ts`.
|
||||
|---|---|---|
|
||||
| Engine | `cd packages/mancala.js && npx jest` | After changing the rules, change the Java port too and record the parity games again (`apps/backend/README.md`) |
|
||||
| Core | `cd packages/core && npx jest` | |
|
||||
| Backend | `cd apps/backend && ./mvnw test` | Needs a JDK 25 and Docker: Postgres and Valkey run with Quarkus Dev Services |
|
||||
| Backend | `cd apps/backend && ./mvnw test` | Needs a JDK 25 and Docker: Postgres and Valkey run with Quarkus Dev Services. The bot's calibration tournament is tagged `calibration` and skipped; see `apps/backend/README.md` |
|
||||
| Keycloak extension | `cd apps/keycloak/extension && mvn test` | Needs Docker; starts Keycloak with the real realm file |
|
||||
| Web app | `cd apps/frontend && yarn build` | There is no `tsconfig.json`, so nothing type-checks it; use a temporary one when needed (with `"types": ["styled-jsx", "react", "react-dom"]`). 1 type error (`react-dom/client`) is known |
|
||||
| Mobile | `cd apps/mobile && npx tsc --noEmit -p .` and `npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output /tmp/x.bundle` | 11 type errors are known and older than recent work |
|
||||
|
||||
There is no automated end-to-end suite. Changes to the game flow were checked by driving Chrome with
|
||||
`playwright-core` (installed outside the repo, `executablePath: /usr/bin/google-chrome`) against the local stack.
|
||||
`playwright-core` (installed outside the repo, e.g. `npm install playwright-core@1.48.2` in a scratch folder;
|
||||
`executablePath: /usr/bin/google-chrome`) against the local stack. A live game is `GET /game/:id` on the backend,
|
||||
which helps to check what the server holds.
|
||||
|
||||
## How we work
|
||||
|
||||
@ -92,6 +95,10 @@ There is no automated end-to-end suite. Changes to the game flow were checked by
|
||||
left with `GameClocks.getClockTimes`, never store a ticking value. `GAME_CLOCK_MS=40000` makes games short for
|
||||
manual tests.
|
||||
- `Rtmt.listenOnClientConnectionChange` adds a listener (several are allowed).
|
||||
- The bot plays as `bot:<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
|
||||
direct executors); only storage, HTTP and WebSocket tests are `@QuarkusTest`s. `FakeTokenVerifier` replaces
|
||||
Keycloak in all `@QuarkusTest`s.
|
||||
|
||||
11
TODO.md
11
TODO.md
@ -54,6 +54,17 @@ See `docs/specs/game-clock`.
|
||||
- [ ] Clocks in the home page's live game previews and in the ongoing-game banner
|
||||
- [ ] Try the mobile clocks on a device; only the type check and the bundle were run
|
||||
|
||||
## Game bot
|
||||
|
||||
See `docs/specs/game-bot`.
|
||||
|
||||
- [ ] Play against the bot without a connection (a bot in the TypeScript engine on the device)
|
||||
- [ ] Keep bot games apart once there are statistics or ratings
|
||||
- [ ] Hints or move analysis for players, with the same search
|
||||
- [ ] Rebuild `MancalaGame` on `Position`, or use `Position` to check moves before running them (see Security)
|
||||
- [ ] Tune the levels from real games (`games.bot` holds each game's level, strength and seed)
|
||||
- [ ] Try the bot on a mobile device; only the type check and the bundle were run
|
||||
|
||||
## Web redesign
|
||||
|
||||
See `docs/specs/web-redesign`.
|
||||
|
||||
@ -21,7 +21,8 @@ The Docker image is built from the repo root: `docker build -f apps/backend/Dock
|
||||
|
||||
| Package | What |
|
||||
|---|---|
|
||||
| `engine` | The game rules, a port of `packages/mancala.js` |
|
||||
| `engine` | The game rules, a port of `packages/mancala.js`; `Position`, a compact board for searching moves |
|
||||
| `bot` | The bot: levels, strength, the search and `BotPlayer`, which plays its turns in live games |
|
||||
| `model` | The JSON sent to clients and stored, and the shared `ObjectMapper` |
|
||||
| `loop` | `GameLoop`, the single thread that owns all live game state |
|
||||
| `auth` | Keycloak token verification |
|
||||
@ -93,6 +94,24 @@ backend is down is not charged. The design is in `docs/specs/game-clock`.
|
||||
|---|---|---|
|
||||
| `GAME_CLOCK_MS` | `300000` | Time each player has for a game; a short value such as `40000` helps when testing by hand |
|
||||
|
||||
## Bot
|
||||
|
||||
Players can play against a bot at four levels (`new_bot_game` with `{ level }`); the lobby offers it after 10 seconds
|
||||
without an opponent. Each level is a range of strengths, and the bot draws one per game, so games at the same level
|
||||
differ. It plays as `bot:<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 |
|
||||
|---|---|---|
|
||||
| `GET /game/:id` | – | A live game, or `{ status: "finished", record }` |
|
||||
|
||||
@ -17,6 +17,8 @@
|
||||
<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. -->
|
||||
<debug>5006</debug>
|
||||
<!-- Slow bot calibration games; run them with -Dgroups=calibration -DexcludedGroups=none. -->
|
||||
<excludedGroups>calibration</excludedGroups>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package app.playmancala;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.SplittableRandom;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
@ -12,6 +13,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import app.playmancala.auth.TokenVerifier;
|
||||
import app.playmancala.bot.BotPlayer;
|
||||
import app.playmancala.game.GameClockScheduler;
|
||||
import app.playmancala.game.GameManager;
|
||||
import app.playmancala.game.GameRecorder;
|
||||
@ -55,6 +57,10 @@ public class Backend {
|
||||
@ConfigProperty(name = "mancala.game-clock-ms")
|
||||
long gameClockMs;
|
||||
|
||||
// 0: half the processors.
|
||||
@ConfigProperty(name = "mancala.bot-search-threads")
|
||||
int botSearchThreads;
|
||||
|
||||
private GameLoop loop;
|
||||
// Valkey writes, in order.
|
||||
private ExecutorService storeWriter;
|
||||
@ -62,6 +68,8 @@ public class Backend {
|
||||
private ScheduledExecutorService recorderIo;
|
||||
// Token checks.
|
||||
private ExecutorService verifier;
|
||||
// Working out the bot's moves.
|
||||
private ExecutorService botSearch;
|
||||
|
||||
private RtmtServer rtmt;
|
||||
private GameStore gameStore;
|
||||
@ -76,6 +84,10 @@ public class Backend {
|
||||
storeWriter = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("valkey-writer").daemon().factory());
|
||||
recorderIo = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform().name("game-recorder").daemon().factory());
|
||||
verifier = Executors.newVirtualThreadPerTaskExecutor();
|
||||
int searchThreads = botSearchThreads > 0 ? botSearchThreads
|
||||
: Math.max(1, Runtime.getRuntime().availableProcessors() / 2);
|
||||
botSearch = Executors.newFixedThreadPool(searchThreads,
|
||||
Thread.ofPlatform().name("bot-search-", 0).daemon().factory());
|
||||
|
||||
ValkeyLiveGameStore liveGameStore = new ValkeyLiveGameStore(redis);
|
||||
PostgresGameHistoryRepository repository = new PostgresGameHistoryRepository(dataSource);
|
||||
@ -90,6 +102,7 @@ public class Backend {
|
||||
gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, storeWriter,
|
||||
loop::now, gameClockMs);
|
||||
liveGames = new LiveGames(rtmt, gameStore, gameManager, loop);
|
||||
new BotPlayer(gameManager, gameStore, loop, botSearch, new SplittableRandom());
|
||||
clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop,
|
||||
GameClockScheduler.CLOCK_CHECK_INTERVAL_MS);
|
||||
|
||||
@ -112,6 +125,7 @@ public class Backend {
|
||||
storeWriter.shutdown();
|
||||
recorderIo.shutdownNow();
|
||||
verifier.shutdownNow();
|
||||
botSearch.shutdownNow();
|
||||
}
|
||||
|
||||
private void migrate() {
|
||||
|
||||
80
apps/backend/src/main/java/app/playmancala/bot/BotLevel.java
Normal file
80
apps/backend/src/main/java/app/playmancala/bot/BotLevel.java
Normal 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;
|
||||
}
|
||||
}
|
||||
106
apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java
Normal file
106
apps/backend/src/main/java/app/playmancala/bot/BotPlayer.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
140
apps/backend/src/main/java/app/playmancala/bot/BotSearch.java
Normal file
140
apps/backend/src/main/java/app/playmancala/bot/BotSearch.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
176
apps/backend/src/main/java/app/playmancala/engine/Position.java
Normal file
176
apps/backend/src/main/java/app/playmancala/engine/Position.java
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import app.playmancala.bot.BotSettings;
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
import app.playmancala.model.GameClock;
|
||||
import app.playmancala.model.GameUsersConnectionInfo;
|
||||
@ -27,10 +28,12 @@ public class BackendGame {
|
||||
public GameClock clock;
|
||||
// The mover's clock after each move, aligned with mancalaGame.history; null for moves made before the game clock.
|
||||
public final List<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,
|
||||
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.mancalaGame = mancalaGame;
|
||||
this.gameUsersConnectionInfo = gameUsersConnectionInfo;
|
||||
@ -41,6 +44,11 @@ public class BackendGame {
|
||||
this.moveTimes = new ArrayList<>(moveTimes);
|
||||
this.clock = clock;
|
||||
this.moveRemainingMs = new ArrayList<>(moveRemainingMs);
|
||||
this.bot = bot;
|
||||
}
|
||||
|
||||
public boolean isBotTurn() {
|
||||
return bot != null && bot.botId().equals(mancalaGame.getTurnPlayerId());
|
||||
}
|
||||
|
||||
public PlayerSummary player(String userId) {
|
||||
|
||||
@ -7,12 +7,16 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.IntSupplier;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import app.playmancala.bot.BotLevel;
|
||||
import app.playmancala.bot.BotSettings;
|
||||
import app.playmancala.engine.CommonMancalaGame;
|
||||
import app.playmancala.engine.GameState;
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
@ -35,6 +39,8 @@ import app.playmancala.rtmt.Rtmt;
|
||||
// Writes to Valkey go through `storeExecutor`, saving finished games through the recorder; neither blocks the loop.
|
||||
public class GameManager {
|
||||
private static final Logger LOG = Logger.getLogger(GameManager.class);
|
||||
// Clients show the bot by its id ("Bot · Medium"); the name is for anything that only shows names.
|
||||
static final String BOT_NAME = "Bot";
|
||||
|
||||
private final GameStore gameStore;
|
||||
private final Rtmt rtmt;
|
||||
@ -46,6 +52,7 @@ public class GameManager {
|
||||
// Time each player has for a new game.
|
||||
private final long gameClockMs;
|
||||
private final List<Runnable> liveGamesListeners = new ArrayList<>();
|
||||
private final List<Consumer<BackendGame>> turnListeners = new ArrayList<>();
|
||||
|
||||
public GameManager(GameStore gameStore, Rtmt rtmt, MatchMaker matchMaker, LiveGameStore liveGameStore,
|
||||
GameRecorder recorder, Executor storeExecutor, LongSupplier clock, long gameClockMs) {
|
||||
@ -67,6 +74,12 @@ public class GameManager {
|
||||
liveGamesListeners.add(listener);
|
||||
}
|
||||
|
||||
// Called whenever a player is to move: when a game starts or is restored, and after a move that did not end it,
|
||||
// including one that gives an extra turn.
|
||||
public void onTurnStarted(Consumer<BackendGame> listener) {
|
||||
turnListeners.add(listener);
|
||||
}
|
||||
|
||||
private void listenRtmtMessages() {
|
||||
rtmt.listenMessage(Channels.NEW_GAME, (userId, message) -> {
|
||||
BackendGame activeGame = gameStore.getGameByUser(userId);
|
||||
@ -76,6 +89,7 @@ public class GameManager {
|
||||
}
|
||||
matchMaker.join(userId);
|
||||
});
|
||||
rtmt.listenMessage(Channels.NEW_BOT_GAME, this::onNewBotGame);
|
||||
rtmt.listenMessage(Channels.CANCEL_NEW_GAME, (userId, message) -> matchMaker.cancel(userId));
|
||||
rtmt.listenMessage(Channels.GAME_MOVE, this::onGameMove);
|
||||
rtmt.listenMessage(Channels.LEAVE_GAME, (userId, message) -> onPlayerLeave(userId));
|
||||
@ -87,12 +101,44 @@ public class GameManager {
|
||||
});
|
||||
}
|
||||
|
||||
private void onNewBotGame(String userId, JsonNode message) {
|
||||
BotLevel level = BotLevel.fromWireName(message.path("level").asText(null));
|
||||
if (level == null) {
|
||||
LOG.debugf("(GameManager) Unknown bot level: %s", message);
|
||||
return;
|
||||
}
|
||||
BackendGame activeGame = gameStore.getGameByUser(userId);
|
||||
if (activeGame != null) {
|
||||
rtmt.sendMessage(userId, Channels.ON_NEW_GAME_REJECTED, Map.of("gameId", activeGame.id));
|
||||
return;
|
||||
}
|
||||
matchMaker.cancel(userId);
|
||||
BotSettings bot = BotSettings.draw(level, ThreadLocalRandom.current());
|
||||
startGame(createMancalaGame(userId, bot.botId(), bot));
|
||||
}
|
||||
|
||||
private void onGameMove(String userId, JsonNode gameMove) {
|
||||
BackendGame game = gameStore.getGameByUser(userId);
|
||||
if (game == null) {
|
||||
LOG.debug("Game not found!");
|
||||
return;
|
||||
}
|
||||
playMove(game, userId, () -> {
|
||||
JsonNode index = gameMove.path("index");
|
||||
if (!index.canConvertToInt() || !index.isIntegralNumber()) {
|
||||
throw new IllegalStateException("Invalid move: " + gameMove);
|
||||
}
|
||||
return index.asInt();
|
||||
});
|
||||
}
|
||||
|
||||
// Plays a player's pit (0-5) in a live game; the bot's moves come here too.
|
||||
public void playMove(BackendGame game, String userId, int pitIndex) {
|
||||
playMove(game, userId, () -> pitIndex);
|
||||
}
|
||||
|
||||
// The pit is read after the clock check, so an invalid move after the time has run out still ends the game on time.
|
||||
private void playMove(BackendGame game, String userId, IntSupplier pitIndex) {
|
||||
MancalaGame mancalaGame = game.mancalaGame;
|
||||
try {
|
||||
long now = clock.getAsLong();
|
||||
@ -101,13 +147,10 @@ public class GameManager {
|
||||
endGameOnTime(game);
|
||||
return;
|
||||
}
|
||||
JsonNode index = gameMove.path("index");
|
||||
if (!index.canConvertToInt() || !index.isIntegralNumber()) {
|
||||
throw new IllegalStateException("Invalid move: " + gameMove);
|
||||
}
|
||||
int index = pitIndex.getAsInt();
|
||||
int movesBefore = mancalaGame.getHistory().size();
|
||||
Seat moverSeat = seatOf(mancalaGame, userId);
|
||||
mancalaGame.moveByPlayerPit(userId, index.asInt());
|
||||
mancalaGame.moveByPlayerPit(userId, index);
|
||||
if (mancalaGame.getHistory().size() > movesBefore && moverSeat != null) {
|
||||
game.moveTimes.add(now);
|
||||
GameClocks.ChargedMove charged = GameClocks.chargeMove(game.clock, moverSeat, now);
|
||||
@ -121,6 +164,7 @@ public class GameManager {
|
||||
} else {
|
||||
saveLiveGame(game);
|
||||
notifyLiveGamesChanged();
|
||||
notifyTurnStarted(game);
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
onGameError(game, e);
|
||||
@ -226,6 +270,7 @@ public class GameManager {
|
||||
if (restored > 0) {
|
||||
LOG.infof("(GameManager) Restored %d live game(s)", restored);
|
||||
notifyLiveGamesChanged();
|
||||
gameStore.getAll().forEach(this::notifyTurnStarted);
|
||||
}
|
||||
return restored;
|
||||
}
|
||||
@ -256,17 +301,27 @@ public class GameManager {
|
||||
}
|
||||
|
||||
private void sendMessageToPlayersAndSpectators(BackendGame game, String channel, Object message) {
|
||||
rtmt.sendMessage(game.mancalaGame.getPlayer1Id(), channel, message);
|
||||
rtmt.sendMessage(game.mancalaGame.getPlayer2Id(), channel, message);
|
||||
sendMessageToPlayer(game.mancalaGame.getPlayer1Id(), channel, message);
|
||||
sendMessageToPlayer(game.mancalaGame.getPlayer2Id(), channel, message);
|
||||
List.copyOf(game.spectatorIds).forEach(spectatorId -> rtmt.sendMessage(spectatorId, channel, message));
|
||||
}
|
||||
|
||||
// The bot has no connection to send to.
|
||||
private void sendMessageToPlayer(String playerId, String channel, Object message) {
|
||||
if (!BotLevel.isBotId(playerId)) rtmt.sendMessage(playerId, channel, message);
|
||||
}
|
||||
|
||||
public ClientGame toClientGame(BackendGame game) {
|
||||
return new ClientGame(game.id, game.mancalaGame, game.gameUsersConnectionInfo, game.startedAt,
|
||||
List.copyOf(game.moveTimes), game.spectatorIds.size(), game.clock, clock.getAsLong());
|
||||
}
|
||||
|
||||
public BackendGame createMancalaGame(String userId1, String userId2) {
|
||||
return createMancalaGame(userId1, userId2, null);
|
||||
}
|
||||
|
||||
// `bot` is set for a game against the bot, which plays as one of the two users.
|
||||
public BackendGame createMancalaGame(String userId1, String userId2, BotSettings bot) {
|
||||
MancalaGame mancalaGame = new CommonMancalaGame(UUID.randomUUID().toString(), userId1, userId2);
|
||||
mancalaGame.setTurnPlayerId(ThreadLocalRandom.current().nextBoolean() ? userId1 : userId2);
|
||||
long now = clock.getAsLong();
|
||||
@ -276,7 +331,7 @@ public class GameManager {
|
||||
players.put(userId1, player1);
|
||||
players.put(userId2, player2);
|
||||
BackendGame game = new BackendGame(mancalaGame, getGameUsersConnectionInfo(player1, player2), List.of(), 0,
|
||||
players, now, List.of(), GameClocks.start(gameClockMs, now), List.of());
|
||||
players, now, List.of(), GameClocks.start(gameClockMs, now), List.of(), bot);
|
||||
gameStore.set(game.id, game);
|
||||
gameStore.setGameByUser(userId1, game);
|
||||
gameStore.setGameByUser(userId2, game);
|
||||
@ -284,6 +339,7 @@ public class GameManager {
|
||||
}
|
||||
|
||||
private PlayerSummary getPlayerInfo(String userId) {
|
||||
if (BotLevel.isBotId(userId)) return new PlayerSummary(userId, BOT_NAME, false);
|
||||
AuthUser user = rtmt.getUser(userId);
|
||||
return new PlayerSummary(userId, user != null ? user.name() : null, user == null || user.isGuest());
|
||||
}
|
||||
@ -295,8 +351,13 @@ public class GameManager {
|
||||
|
||||
private GameUsersConnectionInfo getGameUsersConnectionInfo(PlayerSummary player1, PlayerSummary player2) {
|
||||
return new GameUsersConnectionInfo(
|
||||
UserConnectionInfo.of(player1, rtmt.isClientOnline(player1.userId())),
|
||||
UserConnectionInfo.of(player2, rtmt.isClientOnline(player2.userId())));
|
||||
UserConnectionInfo.of(player1, isOnline(player1.userId())),
|
||||
UserConnectionInfo.of(player2, isOnline(player2.userId())));
|
||||
}
|
||||
|
||||
// The bot is always there.
|
||||
private boolean isOnline(String userId) {
|
||||
return BotLevel.isBotId(userId) || rtmt.isClientOnline(userId);
|
||||
}
|
||||
|
||||
public void startGame(BackendGame game) {
|
||||
@ -304,6 +365,7 @@ public class GameManager {
|
||||
sendUserConnectionInfo(game);
|
||||
saveLiveGame(game);
|
||||
notifyLiveGamesChanged();
|
||||
notifyTurnStarted(game);
|
||||
}
|
||||
|
||||
public void sendUserConnectionInfo(BackendGame game) {
|
||||
@ -337,6 +399,10 @@ public class GameManager {
|
||||
liveGamesListeners.forEach(Runnable::run);
|
||||
}
|
||||
|
||||
private void notifyTurnStarted(BackendGame game) {
|
||||
turnListeners.forEach(listener -> listener.accept(game));
|
||||
}
|
||||
|
||||
private void listenUserConnectionChange() {
|
||||
rtmt.listenOnClientConnectionChange((clientId, isOnline) -> {
|
||||
BackendGame game = gameStore.getGameByUser(clientId);
|
||||
|
||||
@ -5,12 +5,15 @@ import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import app.playmancala.bot.BotLevel;
|
||||
import app.playmancala.bot.BotSettings;
|
||||
import app.playmancala.engine.CommonMancalaGame;
|
||||
import app.playmancala.engine.GameState;
|
||||
import app.playmancala.engine.MancalaGame;
|
||||
@ -53,7 +56,9 @@ public final class GameSerialization {
|
||||
int peakSpectators,
|
||||
List<String> allSpectatorIds,
|
||||
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) {
|
||||
@ -69,7 +74,7 @@ public final class GameSerialization {
|
||||
mancalaGame.getTurnPlayerId(), mancalaGame.getState(), boxed(mancalaGame.getBoard().getStoneArray()),
|
||||
List.copyOf(mancalaGame.getHistory()), Map.copyOf(game.players), game.startedAt,
|
||||
List.copyOf(game.moveTimes), game.peakSpectators, List.copyOf(game.allSpectatorIds), game.clock,
|
||||
Collections.unmodifiableList(new ArrayList<>(game.moveRemainingMs)));
|
||||
Collections.unmodifiableList(new ArrayList<>(game.moveRemainingMs)), game.bot);
|
||||
}
|
||||
|
||||
// The snapshot in the JSON, or null if it is not one this backend can restore.
|
||||
@ -112,7 +117,16 @@ public final class GameSerialization {
|
||||
return new BackendGame(mancalaGame,
|
||||
new GameUsersConnectionInfo(UserConnectionInfo.of(player1, false), UserConnectionInfo.of(player2, false)),
|
||||
orEmpty(snapshot.allSpectatorIds()), snapshot.peakSpectators(), players, snapshot.startedAt(),
|
||||
orEmpty(snapshot.moveTimes()), clock, moveRemainingMs);
|
||||
orEmpty(snapshot.moveTimes()), clock, moveRemainingMs, restoreBot(snapshot));
|
||||
}
|
||||
|
||||
// The snapshot's bot settings. Every bot game is saved with them; should they be missing, the bot plays on at
|
||||
// the middle of its level.
|
||||
private static BotSettings restoreBot(LiveGameSnapshot snapshot) {
|
||||
if (snapshot.bot() != null) return snapshot.bot();
|
||||
BotLevel level = BotLevel.fromBotId(snapshot.player2Id());
|
||||
if (level == null) level = BotLevel.fromBotId(snapshot.player1Id());
|
||||
return level == null ? null : new BotSettings(level, level.middleStrength(), ThreadLocalRandom.current().nextLong());
|
||||
}
|
||||
|
||||
// Null when the game ended without a winner and was not completed, e.g. both players left.
|
||||
@ -138,7 +152,7 @@ public final class GameSerialization {
|
||||
publicSummary(game, mancalaGame.getPlayer2Id()), boxed(mancalaGame.getBoard().getStoneArray()),
|
||||
getResult(game, ending), ending.endReason(),
|
||||
ending.leftUserId() == null ? null : PublicIds.publicUserId(ending.leftUserId()), game.startedAt, endedAt,
|
||||
moves.size(), game.peakSpectators, game.clock.initialMs(), moves, game.allSpectatorIds.size());
|
||||
moves.size(), game.peakSpectators, game.clock.initialMs(), moves, game.allSpectatorIds.size(), game.bot);
|
||||
}
|
||||
|
||||
// The player with a public id, for anything saved or listed.
|
||||
|
||||
@ -6,6 +6,8 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import app.playmancala.bot.BotLevel;
|
||||
|
||||
// Live games in memory, the source of truth for them; Valkey only holds a copy. Only touched on the game loop.
|
||||
public class GameStore {
|
||||
// In the order games started.
|
||||
@ -28,7 +30,9 @@ public class GameStore {
|
||||
games.remove(id);
|
||||
}
|
||||
|
||||
// Bots play many games at once, so they are not mapped to one.
|
||||
public void setGameByUser(String userId, BackendGame game) {
|
||||
if (BotLevel.isBotId(userId)) return;
|
||||
gameIdsByUser.put(userId, game.id);
|
||||
}
|
||||
|
||||
|
||||
@ -6,9 +6,10 @@ import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
|
||||
import app.playmancala.auth.KeycloakTokenVerifier;
|
||||
import app.playmancala.bot.BotLevel;
|
||||
|
||||
// Ids of legacy userKey users are the secret they log in with, so anything saved or listed publicly uses a hash
|
||||
// instead. Keycloak ids are not secret and stay as they are.
|
||||
// instead. Keycloak ids and bot ids are not secret and stay as they are.
|
||||
public final class PublicIds {
|
||||
private static final String LEGACY_PUBLIC_ID_PREFIX = "legacy:";
|
||||
|
||||
@ -16,7 +17,8 @@ public final class PublicIds {
|
||||
}
|
||||
|
||||
public static String publicUserId(String userId) {
|
||||
if (userId.startsWith(KeycloakTokenVerifier.KEYCLOAK_USER_ID_PREFIX) || userId.startsWith(LEGACY_PUBLIC_ID_PREFIX)) {
|
||||
if (userId.startsWith(KeycloakTokenVerifier.KEYCLOAK_USER_ID_PREFIX) || userId.startsWith(LEGACY_PUBLIC_ID_PREFIX)
|
||||
|| BotLevel.isBotId(userId)) {
|
||||
return userId;
|
||||
}
|
||||
try {
|
||||
|
||||
@ -23,7 +23,7 @@ public final class Migrations {
|
||||
private static final long MIGRATION_LOCK_ID = 727274;
|
||||
|
||||
// Applied in this order; never change a migration that has been released, add a new one instead.
|
||||
public static final List<String> VERSIONS = List.of("001_games", "002_clock");
|
||||
public static final List<String> VERSIONS = List.of("001_games", "002_clock", "003_bot");
|
||||
|
||||
private Migrations() {
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import javax.sql.DataSource;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
import app.playmancala.bot.BotSettings;
|
||||
import app.playmancala.model.EndReason;
|
||||
import app.playmancala.model.FinishedGameSummary;
|
||||
import app.playmancala.model.GameRecord;
|
||||
@ -58,9 +59,9 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
|
||||
int inserted;
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO games (id, player1_id, player2_id, result, end_reason, left_user_id, started_at, ended_at,
|
||||
move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms)
|
||||
move_count, final_board, moves, peak_spectators, total_spectators, clock_initial_ms, bot)
|
||||
VALUES (?::uuid, ?, ?, ?, ?, ?, to_timestamp(? / 1000.0), to_timestamp(? / 1000.0),
|
||||
?, ?::jsonb, ?::jsonb, ?, ?, ?)
|
||||
?, ?::jsonb, ?::jsonb, ?, ?, ?, ?::jsonb)
|
||||
ON CONFLICT (id) DO NOTHING""")) {
|
||||
statement.setString(1, record.id());
|
||||
statement.setString(2, record.player1().userId());
|
||||
@ -77,6 +78,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
|
||||
statement.setInt(13, record.totalSpectators());
|
||||
if (record.clockInitialMs() == null) statement.setNull(14, Types.INTEGER);
|
||||
else statement.setInt(14, Math.toIntExact(record.clockInitialMs()));
|
||||
statement.setString(15, record.bot() == null ? null : Json.write(record.bot()));
|
||||
inserted = statement.executeUpdate();
|
||||
}
|
||||
if (inserted == 0) return null;
|
||||
@ -103,7 +105,7 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
|
||||
if (!UUID_PATTERN.matcher(id).matches()) return null;
|
||||
return query(connection -> {
|
||||
try (PreparedStatement statement = connection.prepareStatement(
|
||||
"SELECT " + SUMMARY_COLUMNS + ", g.moves, g.total_spectators " + FROM_GAMES + " WHERE g.id = ?::uuid")) {
|
||||
"SELECT " + SUMMARY_COLUMNS + ", g.moves, g.total_spectators, g.bot " + FROM_GAMES + " WHERE g.id = ?::uuid")) {
|
||||
statement.setString(1, id);
|
||||
try (ResultSet row = statement.executeQuery()) {
|
||||
if (!row.next()) return null;
|
||||
@ -111,7 +113,8 @@ public class PostgresGameHistoryRepository implements GameHistoryRepository {
|
||||
return new GameRecord(summary.id(), summary.player1(), summary.player2(), summary.board(),
|
||||
summary.result(), summary.endReason(), summary.leftUserId(), summary.startedAt(),
|
||||
summary.endedAt(), summary.moveCount(), summary.peakSpectators(), summary.clockInitialMs(),
|
||||
Json.MAPPER.readValue(row.getString("moves"), MOVES), row.getInt("total_spectators"));
|
||||
Json.MAPPER.readValue(row.getString("moves"), MOVES), row.getInt("total_spectators"),
|
||||
row.getString("bot") == null ? null : Json.MAPPER.readValue(row.getString("bot"), BotSettings.class));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -4,6 +4,8 @@ package app.playmancala.model;
|
||||
public final class Channels {
|
||||
public static final String NEW_GAME = "new_game";
|
||||
public static final String CANCEL_NEW_GAME = "cancel_new_game";
|
||||
// client -> server: { level }, starts a game against the bot at that level (docs/specs/game-bot).
|
||||
public static final String NEW_BOT_GAME = "new_bot_game";
|
||||
public static final String ON_GAME_START = "on_game_start";
|
||||
public static final String GAME_MOVE = "game_move";
|
||||
public static final String ON_GAME_UPDATE = "on_game_update";
|
||||
|
||||
@ -5,10 +5,12 @@ import java.util.List;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||
|
||||
import app.playmancala.bot.BotSettings;
|
||||
|
||||
// A saved, finished game: a FinishedGameSummary plus its moves and the number of distinct spectators. Every id in it
|
||||
// is a public id.
|
||||
// is a public id. `bot` is how the bot played, in games against the bot.
|
||||
@JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt",
|
||||
"moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators" })
|
||||
"moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators", "bot" })
|
||||
public record GameRecord(
|
||||
String id,
|
||||
PlayerSummary player1,
|
||||
@ -23,7 +25,16 @@ public record GameRecord(
|
||||
int peakSpectators,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs,
|
||||
List<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() {
|
||||
return new FinishedGameSummary(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt,
|
||||
|
||||
@ -23,6 +23,9 @@ mancala.auth.legacy-userkey-auth=${LEGACY_USERKEY_AUTH:true}
|
||||
# Time each player has for a whole game (docs/specs/game-clock).
|
||||
mancala.game-clock-ms=${GAME_CLOCK_MS:300000}
|
||||
|
||||
# Threads working out the bot's moves (docs/specs/game-bot); 0 is half the processors.
|
||||
mancala.bot-search-threads=${BOT_SEARCH_THREADS:0}
|
||||
|
||||
# Postgres. Tests start one with Dev Services. The backend starts without it; migrations are retried.
|
||||
quarkus.datasource.db-kind=postgresql
|
||||
%dev,prod.quarkus.datasource.jdbc.url=${DATABASE_JDBC_URL:jdbc:postgresql://localhost:5433/mancala}
|
||||
|
||||
@ -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;
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -19,6 +19,8 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import app.playmancala.bot.BotLevel;
|
||||
import app.playmancala.bot.BotSettings;
|
||||
import app.playmancala.engine.GameStep;
|
||||
import app.playmancala.model.EndReason;
|
||||
import app.playmancala.model.FinishedGameSummary;
|
||||
@ -88,7 +90,7 @@ class PostgresGameHistoryRepositoryTest {
|
||||
ResultSet rows = statement.executeQuery("SELECT version FROM schema_migrations ORDER BY version")) {
|
||||
while (rows.next()) versions.add(rows.getString(1));
|
||||
}
|
||||
assertEquals(List.of("001_games", "002_clock"), versions);
|
||||
assertEquals(List.of("001_games", "002_clock", "003_bot"), versions);
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -115,6 +117,20 @@ class PostgresGameHistoryRepositoryTest {
|
||||
assertFalse(saved.at("/moves/0").has("remainingMs"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsHowTheBotPlayed() {
|
||||
GameRecord game = record(ALICE, new PlayerSummary("bot:hard", "Bot", false));
|
||||
GameRecord botGame = new GameRecord(game.id(), game.player1(), game.player2(), game.board(), game.result(),
|
||||
game.endReason(), null, game.startedAt(), game.endedAt(), game.moveCount(), game.peakSpectators(), null,
|
||||
game.moves(), game.totalSpectators(), new BotSettings(BotLevel.HARD, 72, -5_123_456_789_012L));
|
||||
repository.save(botGame);
|
||||
assertEquals(Json.tree(botGame), Json.tree(repository.get(game.id())));
|
||||
assertEquals("bot:hard", repository.getRecentForUser(ALICE.userId(), 1).getFirst().player2().userId());
|
||||
GameRecord betweenPeople = record();
|
||||
repository.save(betweenPeople);
|
||||
assertFalse(Json.tree(repository.get(betweenPeople.id())).has("bot"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsLeftUserIdForGamesAPlayerLeft() {
|
||||
GameRecord game = record(ALICE, BOB, GameResult.PLAYER2, EndReason.LEFT, ALICE.userId(), 0, 0, null, null);
|
||||
|
||||
36
apps/frontend/src/components/BotLevelMenu.tsx
Normal file
36
apps/frontend/src/components/BotLevelMenu.tsx
Normal 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;
|
||||
34
apps/frontend/src/components/BotLevelPicker.tsx
Normal file
34
apps/frontend/src/components/BotLevelPicker.tsx
Normal 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;
|
||||
@ -67,3 +67,7 @@ export const PlayIcon = ({ size = 22 }: IconProps) => (
|
||||
export const PauseIcon = ({ size = 22 }: IconProps) => (
|
||||
<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>
|
||||
);
|
||||
|
||||
@ -9,9 +9,11 @@ const GameOverCard: FunctionComponent<{
|
||||
title: string,
|
||||
reason?: string,
|
||||
score: string,
|
||||
// "New Game" unless given, e.g. "Play again" after a game against the bot.
|
||||
newGameLabel?: string,
|
||||
onNewGame: () => void,
|
||||
onClose: () => void,
|
||||
}> = ({ title, reason, score, onNewGame, onClose }) => {
|
||||
}> = ({ title, reason, score, newGameLabel, onNewGame, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
@ -22,7 +24,7 @@ const GameOverCard: FunctionComponent<{
|
||||
{reason && <div className="oreason">{reason}</div>}
|
||||
<div className="oscore">{score}</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -12,7 +12,7 @@ import { LeaveIcon, PlusIcon } from '../components/Icons';
|
||||
import { GameRecord, channel_on_game_update, channel_on_game_crashed, channel_on_game_end, channel_on_game_user_leave, channel_on_user_connection_change, channel_leave_game, channel_game_move, channel_listen_game_events, channel_unlisten_game_events, FinishedGameSummary, GameEndMessage, getBoardAfterMove, getSeat, PlayedMove } from '@mancala/core';
|
||||
import { Context } from '../context/context';
|
||||
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
||||
import { Theme, getPlayerDisplayName } from '@mancala/core';
|
||||
import { Theme, getBotLevel, getPlayerDisplayName } from '@mancala/core';
|
||||
import { BoardViewModel } from "@mancala/core";
|
||||
import notyf from '../util/Notyf';
|
||||
import swal from 'sweetalert';
|
||||
@ -191,6 +191,14 @@ const GamePage: FunctionComponent<{
|
||||
navigate("/loby")
|
||||
};
|
||||
|
||||
// In a game against the bot, the game over card starts another one at the same level.
|
||||
const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId());
|
||||
|
||||
const onPlayAgainClick = () => {
|
||||
if (Util.checkConnectionAndMaybeAlert(context, t)) return;
|
||||
navigate(`/loby?bot=${opponentBotLevel}`)
|
||||
};
|
||||
|
||||
const onPitSelect = (index: number, pit: Pit) => {
|
||||
if (!game || isSpectator || !userKey) {
|
||||
return;
|
||||
@ -386,7 +394,8 @@ const GamePage: FunctionComponent<{
|
||||
title={resultTitle ?? t("GameEnded")}
|
||||
reason={resultReason}
|
||||
score={`${bottomLocatedUser.name} ${board.pits[bottomStore].stoneCount} – ${board.pits[topStore].stoneCount} ${topLocatedUser.name}`}
|
||||
onNewGame={onNewGameClick}
|
||||
newGameLabel={opponentBotLevel ? t("PlayAgain") : undefined}
|
||||
onNewGame={opponentBotLevel ? onPlayAgainClick : onNewGameClick}
|
||||
onClose={() => setGameOverClosed(true)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { FunctionComponent, useEffect, useState } from "react";
|
||||
import { LiveGameSummary } from "@mancala/core";
|
||||
import { AuthUser, Theme } from "@mancala/core";
|
||||
import { AuthUser, BotLevel, Theme } from "@mancala/core";
|
||||
import AppHeader from "../components/headerbar/AppHeader";
|
||||
import PageContainer from "../components/PageContainer";
|
||||
import { PlusIcon } from "../components/Icons";
|
||||
import BotLevelMenu from "../components/BotLevelMenu";
|
||||
import ActiveGameBanner from "../components/home/ActiveGameBanner";
|
||||
import LiveGamesSection from "../components/home/LiveGamesSection";
|
||||
import RecentGamesSection from "../components/home/RecentGamesSection";
|
||||
@ -43,9 +44,15 @@ const Home: FunctionComponent<{
|
||||
navigate("/loby")
|
||||
};
|
||||
|
||||
const onBotLevelChosen = (level: BotLevel) => {
|
||||
if(Util.checkConnectionAndMaybeAlert(context, t)) return;
|
||||
navigate(`/loby?bot=${level}`)
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<AppHeader context={context} authUser={authUser}>
|
||||
<BotLevelMenu context={context} onChoose={onBotLevelChosen} />
|
||||
<button className="btn pri ic-m" aria-label={t("NewGame")} onClick={onNewGameClick}>
|
||||
<PlusIcon /><span className="hide-m">{t("NewGame")}</span>
|
||||
</button>
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { CommonMancalaGame } from 'mancala.js';
|
||||
import * as React from 'react';
|
||||
import { FunctionComponent, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import AppHeader from '../components/headerbar/AppHeader';
|
||||
import PageContainer from '../components/PageContainer';
|
||||
import { channel_cancel_new_game, channel_on_game_start, channel_on_new_game_rejected } from '@mancala/core';
|
||||
import { BOT_OFFER_DELAY_MS, BotLevel, channel_cancel_new_game, channel_new_bot_game, channel_new_game, channel_on_game_start, channel_on_new_game_rejected, isBotLevel } from '@mancala/core';
|
||||
import BotLevelPicker from '../components/BotLevelPicker';
|
||||
import { Context } from '../context/context';
|
||||
import { Theme } from '@mancala/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@ -19,9 +20,17 @@ const LobyPage: FunctionComponent<{
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// /loby?bot=<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.
|
||||
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 onGameStart = (message: Object) => {
|
||||
@ -29,6 +38,10 @@ const LobyPage: FunctionComponent<{
|
||||
navigate(`/game/${newGame.id}`)
|
||||
}
|
||||
|
||||
const startBotGame = (level: BotLevel) => {
|
||||
context.rtmt.sendMessage(channel_new_bot_game, { level });
|
||||
}
|
||||
|
||||
const onCancelNewGameClick = () => {
|
||||
if (context.rtmt.connectionState === "connected") {
|
||||
context.rtmt.sendMessage(channel_cancel_new_game, "");
|
||||
@ -38,8 +51,15 @@ const LobyPage: FunctionComponent<{
|
||||
useEffect(() => {
|
||||
context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
|
||||
context.rtmt.addMessageListener(channel_on_new_game_rejected, onNewGameRejected);
|
||||
context.rtmt.sendMessage("new_game", {});
|
||||
let botOfferTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (botLevel) {
|
||||
startBotGame(botLevel);
|
||||
} else {
|
||||
context.rtmt.sendMessage(channel_new_game, {});
|
||||
botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS);
|
||||
}
|
||||
return () => {
|
||||
if (botOfferTimer) clearTimeout(botOfferTimer);
|
||||
context.rtmt.removeMessageListener(channel_on_game_start, onGameStart);
|
||||
context.rtmt.removeMessageListener(channel_on_new_game_rejected, onNewGameRejected);
|
||||
}
|
||||
@ -51,8 +71,14 @@ const LobyPage: FunctionComponent<{
|
||||
<main className="wrap">
|
||||
<section className="search panel" aria-live="polite">
|
||||
{!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 && botOffered && (
|
||||
<div className="offer">
|
||||
<p>{t("NoOpponentYet")}</p>
|
||||
<BotLevelPicker context={context} onChoose={startBotGame} />
|
||||
</div>
|
||||
)}
|
||||
<div className="actions">
|
||||
{activeGameId && (
|
||||
<button className="btn pri" onClick={() => navigate(`/game/${activeGameId}`)}>{t("Continue")}</button>
|
||||
@ -90,6 +116,14 @@ const LobyPage: FunctionComponent<{
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.offer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
31
apps/mobile/src/components/BotLevelPicker.tsx
Normal file
31
apps/mobile/src/components/BotLevelPicker.tsx
Normal 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;
|
||||
@ -3,7 +3,7 @@ import { View, Text, useWindowDimensions, Alert, Pressable } from 'react-native'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GameScreenProps } from '../types';
|
||||
import { useState } from 'react';
|
||||
import { Game, GameUsersConnectionInfo, GameMove, LoadingState, BoardViewModel, PitAnimator, getColorByBrightness, GameEndMessage, getSeat } from "@mancala/core";
|
||||
import { Game, GameUsersConnectionInfo, GameMove, LoadingState, BoardViewModel, PitAnimator, getColorByBrightness, GameEndMessage, getSeat, getBotLevel, getPlayerDisplayName } from "@mancala/core";
|
||||
import { MancalaGame, Pit } from 'mancala.js';
|
||||
import { v4 } from 'uuid';
|
||||
import { channel_on_game_update, channel_on_game_crashed, channel_on_game_user_leave, channel_on_user_connection_change, channel_listen_game_events, channel_unlisten_game_events, channel_leave_game, channel_game_move, channel_on_game_end } from '@mancala/core';
|
||||
@ -159,10 +159,12 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
|
||||
});
|
||||
};
|
||||
|
||||
// In a game against the bot, a new game is another one at the same level.
|
||||
const onNewGameClick = () => {
|
||||
if (Util.checkConnectionAndMaybeAlert(context, t("ConnectionLost"))) return;
|
||||
context.storage.deleteString('lastGameId');
|
||||
navigation.replace("Loby", { context })
|
||||
const opponentBotLevel = isSpectator ? undefined : getBotLevel(getOpponentId());
|
||||
navigation.replace("Loby", { context, botLevel: opponentBotLevel })
|
||||
};
|
||||
|
||||
const onPitSelect = (index: number, pit: Pit) => {
|
||||
@ -250,7 +252,7 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
|
||||
onLeaveGameClick();
|
||||
}
|
||||
}}>
|
||||
<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 onPress={() => {
|
||||
navigation.push("Help", { context })
|
||||
@ -278,17 +280,20 @@ export function GameScreen({ navigation, route }: GameScreenProps) {
|
||||
const showBoardView = game && boardViewModel && userKey && true;
|
||||
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
|
||||
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1";
|
||||
// Players have no names on mobile yet (legacy users), except the bot.
|
||||
const topBotName = getPlayerDisplayName({ userId: topLocatedUserId }, t);
|
||||
const bottomBotName = getPlayerDisplayName({ userId: bottomLocatedUserId }, t);
|
||||
const topLocatedUser = {
|
||||
id: topLocatedUserId,
|
||||
name: "Anonymous",
|
||||
name: topBotName ?? "Anonymous",
|
||||
isOnline: isUserOnline(topLocatedUserId),
|
||||
isAnonymous: true
|
||||
isAnonymous: !topBotName
|
||||
};
|
||||
const bottomLocatedUser = {
|
||||
id: bottomLocatedUserId,
|
||||
name: "Anonymous",
|
||||
name: bottomBotName ?? "Anonymous",
|
||||
isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected",
|
||||
isAnonymous: true
|
||||
isAnonymous: !bottomBotName
|
||||
};
|
||||
const currentUser = isSpectator ? {
|
||||
id: "2",
|
||||
|
||||
@ -3,6 +3,7 @@ import { View, Button, Pressable, Text } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HomeScreenProps } from '../types';
|
||||
import CircularPanel from '../components/CircularPanel';
|
||||
import BotLevelPicker from '../components/BotLevelPicker';
|
||||
import { SvgXml } from 'react-native-svg';
|
||||
import helpSvg from '../svg/help';
|
||||
import { getColorByBrightness } from "@mancala/core";
|
||||
@ -12,6 +13,7 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) {
|
||||
const { context } = route.params;
|
||||
const { t } = useTranslation();
|
||||
const [lastGameId, setLastGameId] = React.useState<string | undefined>(undefined);
|
||||
const [choosingBotLevel, setChoosingBotLevel] = React.useState(false);
|
||||
|
||||
const textColorOnAppBar = getColorByBrightness(
|
||||
context.themeManager.theme.appBarBgColor,
|
||||
@ -44,6 +46,17 @@ export function HomeScreen({ navigation, route }: HomeScreenProps) {
|
||||
<Pressable onPress={() => navigation.replace('Loby', { context })}>
|
||||
<CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('NewGame')}</Text>} />
|
||||
</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 && (
|
||||
<>
|
||||
<View style={{ height: 20 }} />
|
||||
|
||||
@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { LobyScreenProps } from '../types';
|
||||
import { useEffect } from 'react';
|
||||
import { CommonMancalaGame } from 'mancala.js';
|
||||
import { channel_cancel_new_game, channel_on_game_start } from '@mancala/core';
|
||||
import { BOT_OFFER_DELAY_MS, BotLevel, channel_cancel_new_game, channel_new_bot_game, channel_new_game, channel_on_game_start } from '@mancala/core';
|
||||
import BotLevelPicker from '../components/BotLevelPicker';
|
||||
import CircularPanel from '../components/CircularPanel';
|
||||
import { getColorByBrightness } from "@mancala/core";
|
||||
import { SvgXml } from 'react-native-svg';
|
||||
@ -12,7 +13,13 @@ import helpSvg from '../svg/help';
|
||||
|
||||
export default function LobyScreen({ navigation, route }: LobyScreenProps) {
|
||||
|
||||
const { context } = route.params;
|
||||
const { context, botLevel } = route.params;
|
||||
// Shown when no opponent has been found for a while.
|
||||
const [botOffered, setBotOffered] = React.useState(false);
|
||||
|
||||
const startBotGame = (level: BotLevel) => {
|
||||
context.rtmt.sendMessage(channel_new_bot_game, { level });
|
||||
}
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onGameStart = async (message: Object) => {
|
||||
@ -32,8 +39,15 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) {
|
||||
|
||||
useEffect(() => {
|
||||
context.rtmt.addMessageListener(channel_on_game_start, onGameStart);
|
||||
context.rtmt.sendMessage("new_game", {});
|
||||
let botOfferTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (botLevel) {
|
||||
startBotGame(botLevel);
|
||||
} else {
|
||||
context.rtmt.sendMessage(channel_new_game, {});
|
||||
botOfferTimer = setTimeout(() => setBotOffered(true), BOT_OFFER_DELAY_MS);
|
||||
}
|
||||
return () => {
|
||||
if (botOfferTimer) clearTimeout(botOfferTimer);
|
||||
context.rtmt.removeMessageListener(channel_on_game_start, onGameStart);
|
||||
}
|
||||
}, []);
|
||||
@ -62,7 +76,13 @@ export default function LobyScreen({ navigation, route }: LobyScreenProps) {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Pressable onPress={onCancelNewGameClick}>
|
||||
<CircularPanel color={context.themeManager.theme?.boardColor} children={<Text style={{ color: context.themeManager.theme.textColor }}>{t('Cancel')}</Text>} />
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { BotLevel } from '@mancala/core';
|
||||
import { Context } from '../context/context';
|
||||
|
||||
export type RootStackParamList = {
|
||||
Home: { context: Context },
|
||||
Loby: { context: Context },
|
||||
// botLevel: start a game against the bot at once instead of searching for an opponent.
|
||||
Loby: { context: Context, botLevel?: BotLevel },
|
||||
Game: { context: Context, gameId: string, userKey: string }
|
||||
Help: { context: Context }
|
||||
};
|
||||
|
||||
239
docs/specs/game-bot/design.md
Normal file
239
docs/specs/game-bot/design.md
Normal 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.
|
||||
74
docs/specs/game-bot/requirements.md
Normal file
74
docs/specs/game-bot/requirements.md
Normal 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.
|
||||
21
docs/specs/game-bot/tasks.md
Normal file
21
docs/specs/game-bot/tasks.md
Normal 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).
|
||||
@ -106,4 +106,13 @@ export const en = {
|
||||
StoreLabel: "{{name}}'s store, {{count}} stones",
|
||||
OpenGame: "Open the game",
|
||||
Score: "Score",
|
||||
PlayWithBot: "Play with bot",
|
||||
NoOpponentYet: "No opponent yet. Play with the bot?",
|
||||
ChooseBotLevel: "Choose how strong the bot is",
|
||||
Bot: "Bot",
|
||||
BotLevelEasy: "Easy",
|
||||
BotLevelMedium: "Medium",
|
||||
BotLevelHard: "Hard",
|
||||
BotLevelMaster: "Master",
|
||||
PlayAgain: "Play again",
|
||||
}
|
||||
@ -106,4 +106,13 @@ export const tr = {
|
||||
StoreLabel: "{{name}} hazinesi, {{count}} taş",
|
||||
OpenGame: "Oyunu aç",
|
||||
Score: "Skor",
|
||||
PlayWithBot: "Bota karşı oyna",
|
||||
NoOpponentYet: "Henüz rakip bulunamadı. Bota karşı oynamak ister misin?",
|
||||
ChooseBotLevel: "Botun ne kadar güçlü olacağını seç",
|
||||
Bot: "Bot",
|
||||
BotLevelEasy: "Kolay",
|
||||
BotLevelMedium: "Orta",
|
||||
BotLevelHard: "Zor",
|
||||
BotLevelMaster: "Usta",
|
||||
PlayAgain: "Tekrar oyna",
|
||||
};
|
||||
42
packages/core/src/models/Bot.ts
Normal file
42
packages/core/src/models/Bot.ts
Normal 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);
|
||||
}
|
||||
@ -6,3 +6,4 @@ export * from './UserConnectionInfo'
|
||||
export * from './AuthUser'
|
||||
export * from './GameHistory'
|
||||
export * from './GameClock'
|
||||
export * from './Bot'
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
export const channel_new_game = "new_game"
|
||||
export const channel_cancel_new_game = "cancel_new_game"
|
||||
// client -> server: { level: BotLevel }, starts a game against the bot; ends a search for an opponent.
|
||||
export const channel_new_bot_game = "new_bot_game"
|
||||
export const channel_on_game_start = "on_game_start"
|
||||
export const channel_game_move = "game_move"
|
||||
export const channel_on_game_update = "on_game_update"
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import { getBotLevel, getBotLevelTextKey } from "../models/Bot";
|
||||
|
||||
const GUEST_USERNAME_PREFIX = "guest-";
|
||||
|
||||
// Name to show for a player, or undefined if the server sent none (legacy userKey players).
|
||||
// Guests show as e.g. "Guest 4F2A" instead of their generated username.
|
||||
// Guests show as e.g. "Guest 4F2A" instead of their generated username, the bot as e.g. "Bot · Medium".
|
||||
export function getPlayerDisplayName(
|
||||
player: { name?: string, isGuest?: boolean } | undefined,
|
||||
player: { userId?: string, name?: string, isGuest?: boolean } | undefined,
|
||||
t: (key: string) => string
|
||||
): string | undefined {
|
||||
const botLevel = getBotLevel(player?.userId);
|
||||
if (botLevel) return `${t("Bot")} · ${t(getBotLevelTextKey(botLevel))}`;
|
||||
if (!player?.name) return undefined;
|
||||
if (player.isGuest) {
|
||||
const suffix = player.name.startsWith(GUEST_USERNAME_PREFIX) ? player.name.slice(GUEST_USERNAME_PREFIX.length) : player.name;
|
||||
|
||||
55
packages/core/tests/models/Bot.test.ts
Normal file
55
packages/core/tests/models/Bot.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user