diff --git a/assets/scripts/core/bot/BasicBot.ts b/assets/scripts/core/bot/BasicBot.ts new file mode 100644 index 0000000..e1ed3f1 --- /dev/null +++ b/assets/scripts/core/bot/BasicBot.ts @@ -0,0 +1,119 @@ +import { Card } from '../cards/Card'; +import { Action, ActionType, AttackAction, DefendAction } from '../rules/Action'; +import { Phase } from '../rules/GameState'; +import { Bot } from './Bot'; +import { isTrump, PlayerView } from './PlayerView'; + +/** + * The bot described in GAMEPLAY.md, implemented literally. + * + * - Opening a turn: the cheapest non-trump; a trump only if that is all it has. + * - Adding to an attack: every eligible non-trump it holds. Trumps go in only + * when the deck is empty and its hand is nothing but trumps. + * - Defending: the cheapest card that does the job, non-trumps first, and a + * take only when nothing in hand can beat the attack. + * + * Deliberately simple and entirely deterministic — it is the baseline a smarter + * opponent will be measured against, and the workhorse of the soak tests. + * + * Two points GAMEPLAY.md leaves open, decided here: cards are added one per + * decision, cheapest first (the rules core serialises additions anyway), and + * when several attacks are unbeaten the lowest slot is answered first. + */ +export class BasicBot implements Bot { + readonly name: string; + + constructor(name = 'basic') { + this.name = name; + } + + decide(view: PlayerView): Action { + if (!view.onMove || view.legalActions.length === 0) { + throw new Error(`${this.name} was asked to move out of turn`); + } + + switch (view.phase) { + case Phase.Attack: + return this._openTurn(view); + case Phase.Reinforce: + return this._reinforce(view); + case Phase.Defence: + return this._defend(view); + default: + return view.legalActions[0]; + } + } + + /** Cheapest non-trump, or the cheapest trump when the hand is all trumps. */ + private _openTurn(view: PlayerView): Action { + const attacks = view.legalActions.filter(isAttack); + return bestBy(attacks, (a) => cost(view, a.card)); + } + + private _reinforce(view: PlayerView): Action { + const attacks = view.legalActions.filter(isAttack); + const nonTrump = attacks.filter((a) => !isTrump(view, a.card)); + + if (nonTrump.length > 0) { + return bestBy(nonTrump, (a) => a.card.rank); + } + + // Throwing trumps away is only worth it at the very end of a game, when + // the deck is exhausted and there is nothing else left to throw. + const holdingOnlyTrumps = view.hand.every((card) => isTrump(view, card)); + if (attacks.length > 0 && view.deckRemaining === 0 && holdingOnlyTrumps) { + return bestBy(attacks, (a) => a.card.rank); + } + + return pass(view); + } + + private _defend(view: PlayerView): Action { + const defences = view.legalActions.filter(isDefend); + if (defences.length === 0) { + return take(view); + } + const firstSlot = Math.min(...defences.map((d) => d.slot)); + const forSlot = defences.filter((d) => d.slot === firstSlot); + return bestBy(forSlot, (d) => cost(view, d.card)); + } +} + +/** Ranks a card for disposal: non-trumps first, then by value. */ +function cost(view: PlayerView, card: Card): number { + return (isTrump(view, card) ? 100 : 0) + card.rank; +} + +function isAttack(action: Action): action is AttackAction { + return action.type === ActionType.Attack; +} + +function isDefend(action: Action): action is DefendAction { + return action.type === ActionType.Defend; +} + +function pass(view: PlayerView): Action { + const action = view.legalActions.find((a) => a.type === ActionType.Pass); + if (!action) throw new Error('Expected passing to be legal'); + return action; +} + +function take(view: PlayerView): Action { + const action = view.legalActions.find((a) => a.type === ActionType.Take); + if (!action) throw new Error('Expected taking to be legal'); + return action; +} + +/** Lowest score wins; ties break on card id so the choice is reproducible. */ +function bestBy(items: readonly T[], score: (item: T) => number): T { + let best = items[0]; + let bestScore = score(best); + for (const item of items.slice(1)) { + const s = score(item); + if (s < bestScore || (s === bestScore && item.card.id < best.card.id)) { + best = item; + bestScore = s; + } + } + return best; +} diff --git a/assets/scripts/core/bot/BasicBot.ts.meta b/assets/scripts/core/bot/BasicBot.ts.meta new file mode 100644 index 0000000..86718ee --- /dev/null +++ b/assets/scripts/core/bot/BasicBot.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "f5c8ab8d-419e-4ea8-a96a-64a2cdb24da0", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/core/bot/Bot.ts b/assets/scripts/core/bot/Bot.ts new file mode 100644 index 0000000..05268c2 --- /dev/null +++ b/assets/scripts/core/bot/Bot.ts @@ -0,0 +1,15 @@ +import { Action } from '../rules/Action'; +import { PlayerView } from './PlayerView'; + +/** + * A decision maker for one seat. + * + * Synchronous and pure: given the same view, a bot returns the same action. + * Any pacing — the pause that makes a bot feel like it is thinking — belongs to + * the presentation layer, not here. + */ +export interface Bot { + readonly name: string; + /** Must return one of `view.legalActions`. */ + decide(view: PlayerView): Action; +} diff --git a/assets/scripts/core/bot/Bot.ts.meta b/assets/scripts/core/bot/Bot.ts.meta new file mode 100644 index 0000000..7c5547c --- /dev/null +++ b/assets/scripts/core/bot/Bot.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "516a24e0-b9d2-4c08-9940-7c2d27db8cd2", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/core/bot/PlayerView.ts b/assets/scripts/core/bot/PlayerView.ts new file mode 100644 index 0000000..066d7c5 --- /dev/null +++ b/assets/scripts/core/bot/PlayerView.ts @@ -0,0 +1,78 @@ +import { Card } from '../cards/Card'; +import { Suit } from '../cards/Suit'; +import { Action } from '../rules/Action'; +import { GameState, Phase, PlayerId, TableSlot } from '../rules/GameState'; +import { currentActor, legalActions, seatOf } from '../rules/DurakRules'; + +/** What one player can see about another. */ +export interface OpponentView { + readonly id: PlayerId; + readonly handSize: number; + readonly out: boolean; +} + +/** + * Everything a participant may legitimately know, and nothing else. + * + * Bots are handed one of these rather than the `GameState`, which makes + * cheating structurally impossible instead of a matter of discipline. It is + * also, not coincidentally, the shape a network protocol would send to a client. + */ +export interface PlayerView { + readonly me: PlayerId; + readonly hand: readonly Card[]; + /** Opponents in clockwise order, starting with the seat after `me`. */ + readonly opponents: readonly OpponentView[]; + readonly table: readonly TableSlot[]; + readonly trumpSuit: Suit; + /** The face-up card that set the trump. Visible all game, even once drawn. */ + readonly trumpCard: Card; + readonly deckRemaining: number; + /** True while the trump card is still the bottom card of the draw pile. */ + readonly trumpCardStillInDeck: boolean; + readonly discardCount: number; + readonly phase: Phase; + readonly turnNumber: number; + readonly attacker: PlayerId; + readonly defender: PlayerId; + readonly defenderTook: boolean; + /** True when the game is waiting on this player. */ + readonly onMove: boolean; + /** Everything this player may do right now. Empty unless `onMove`. */ + readonly legalActions: readonly Action[]; +} + +export function projectView(state: GameState, player: PlayerId): PlayerView { + const seat = seatOf(state, player); + const count = state.players.length; + + const opponents: OpponentView[] = []; + for (let i = 1; i < count; i++) { + const other = state.players[(seat + i) % count]; + opponents.push({ id: other.id, handSize: other.hand.length, out: other.out }); + } + + return { + me: player, + hand: state.players[seat].hand.slice(), + opponents, + table: state.table.map((s) => ({ attack: s.attack, defence: s.defence })), + trumpSuit: state.trumpSuit, + trumpCard: state.trumpCard, + deckRemaining: state.deck.length, + trumpCardStillInDeck: state.deck.length > 0, + discardCount: state.discard.length, + phase: state.phase, + turnNumber: state.turnNumber, + attacker: state.players[state.attacker].id, + defender: state.players[state.defender].id, + defenderTook: state.defenderTook, + onMove: currentActor(state) === player, + legalActions: legalActions(state, player), + }; +} + +/** Convenience: is this card a trump, from a view's point of view. */ +export function isTrump(view: PlayerView, card: Card): boolean { + return card.suit === view.trumpSuit; +} diff --git a/assets/scripts/core/bot/PlayerView.ts.meta b/assets/scripts/core/bot/PlayerView.ts.meta new file mode 100644 index 0000000..9989b0e --- /dev/null +++ b/assets/scripts/core/bot/PlayerView.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "cc518671-becb-4644-96da-2a26cee93102", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/core/match.meta b/assets/scripts/core/match.meta new file mode 100644 index 0000000..1179338 --- /dev/null +++ b/assets/scripts/core/match.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "23c6c112-6468-4a31-acb9-ed860cafb8f8", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/core/match/Match.ts b/assets/scripts/core/match/Match.ts new file mode 100644 index 0000000..8362b11 --- /dev/null +++ b/assets/scripts/core/match/Match.ts @@ -0,0 +1,84 @@ +import { Action } from '../rules/Action'; +import { GameEvent } from '../rules/GameEvent'; +import { GameState, Phase, PlayerId } from '../rules/GameState'; +import { apply, createGame, currentActor, GameOptions } from '../rules/DurakRules'; +import { PlayerView, projectView } from '../bot/PlayerView'; + +export type MatchListener = (event: GameEvent) => void; + +/** + * One match in progress. + * + * A thin stateful shell over the pure reducer: it remembers the current state, + * hands out player views, and broadcasts events. Still free of any engine + * dependency — the game screen holds one of these, and so does the headless + * soak test. + */ +export class Match { + private _state: GameState; + private _listeners: MatchListener[] = []; + private readonly _history: GameEvent[] = []; + + constructor(options: GameOptions) { + const { state, events } = createGame(options); + this._state = state; + this._history.push(...events); + } + + /** The full state. The UI must not show a player anything they cannot see — use `viewFor`. */ + get state(): GameState { + return this._state; + } + + /** Every event since the deal, oldest first. */ + get history(): readonly GameEvent[] { + return this._history; + } + + get finished(): boolean { + return this._state.phase === Phase.Finished; + } + + /** The fool, or null while the game runs and on the rare mutual finish. */ + get loser(): PlayerId | null { + return this._state.loser; + } + + get currentActor(): PlayerId | null { + return currentActor(this._state); + } + + viewFor(player: PlayerId): PlayerView { + return projectView(this._state, player); + } + + /** + * Applies an action and broadcasts what happened. Throws `IllegalActionError` + * if the action is not permitted, leaving the match untouched. + */ + perform(action: Action): GameEvent[] { + const result = apply(this._state, action); + this._state = result.state; + this._history.push(...result.events); + for (const event of result.events) { + for (const listener of this._listeners) { + listener(event); + } + } + return result.events; + } + + /** Subscribes to the event stream. Returns a function that unsubscribes. */ + subscribe(listener: MatchListener): () => void { + this._listeners.push(listener); + return () => { + const i = this._listeners.indexOf(listener); + if (i >= 0) this._listeners.splice(i, 1); + }; + } + + /** Drops every listener. Call when tearing a match screen down. */ + clearListeners(): void { + this._listeners.length = 0; + } +} diff --git a/assets/scripts/core/match/Match.ts.meta b/assets/scripts/core/match/Match.ts.meta new file mode 100644 index 0000000..38afe21 --- /dev/null +++ b/assets/scripts/core/match/Match.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "f362f5f4-1067-40b0-95e1-2793381e372a", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/core/match/runMatch.ts b/assets/scripts/core/match/runMatch.ts new file mode 100644 index 0000000..06b956e --- /dev/null +++ b/assets/scripts/core/match/runMatch.ts @@ -0,0 +1,56 @@ +import { Bot } from '../bot/Bot'; +import { describeAction } from '../rules/Action'; +import { describeEvent } from '../rules/GameEvent'; +import { PlayerId } from '../rules/GameState'; +import { Match } from './Match'; + +export interface RunOptions { + /** Safety net: a match this long means the rules have a cycle in them. */ + maxActions?: number; + /** Collects a human-readable trace, for debugging a bad seed. */ + trace?: string[]; +} + +export interface RunResult { + loser: PlayerId | null; + actions: number; + turns: number; +} + +/** + * Plays a match out between bots, with no engine and no clock. The soak test + * leans on this; so does anyone reproducing a bug from a seed. + * + * Stops early and returns if it reaches a seat with no bot — that is how the + * same driver can fast-forward a match that a human is also playing. + */ +export function runMatch(match: Match, bots: Record, options: RunOptions = {}): RunResult { + const maxActions = options.maxActions ?? 2000; + let actions = 0; + + while (!match.finished) { + const actor = match.currentActor; + if (actor === null) break; + + const bot = bots[actor]; + if (!bot) break; + + const action = bot.decide(match.viewFor(actor)); + if (options.trace) { + options.trace.push(describeAction(action)); + } + const events = match.perform(action); + if (options.trace) { + for (const event of events) { + options.trace.push(` ${describeEvent(event)}`); + } + } + + actions += 1; + if (actions > maxActions) { + throw new Error(`Match did not finish within ${maxActions} actions`); + } + } + + return { loser: match.loser, actions, turns: match.state.turnNumber }; +} diff --git a/assets/scripts/core/match/runMatch.ts.meta b/assets/scripts/core/match/runMatch.ts.meta new file mode 100644 index 0000000..c72c3b1 --- /dev/null +++ b/assets/scripts/core/match/runMatch.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "43fb0ffe-2e16-4efb-9542-6d3dccf0ae10", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 53ebca9..da102cf 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -44,11 +44,11 @@ progress. The Cocos layer only renders state and turns taps into actions. - [x] End conditions and loser ("fool") determination - [x] Unit tests covering every rule clause, including the add-card limits -### M2 — Bot and headless match runner `[ ]` -- [ ] `PlayerView` projection — a bot sees only what a player legitimately sees -- [ ] `BasicBot` exactly per GAMEPLAY.md "Basic bot" -- [ ] `MatchRunner` driving a full headless match -- [ ] Soak test: thousands of seeded self-play matches finish with no illegal state +### M2 — Bot and headless match runner `[x]` +- [x] `PlayerView` projection — a bot sees only what a player legitimately sees +- [x] `BasicBot` exactly per GAMEPLAY.md "Basic bot" +- [x] `MatchRunner` driving a full headless match +- [x] Soak test: thousands of seeded self-play matches finish with no illegal state ### M3 — Savegame module `[ ]` - [ ] `ISaveSection` contract, `SaveManager` registry, JSON keyed by section name diff --git a/docs/TECHNICAL.md b/docs/TECHNICAL.md index 5a10199..080bd81 100644 --- a/docs/TECHNICAL.md +++ b/docs/TECHNICAL.md @@ -45,8 +45,9 @@ assets/ core/ PURE TS — no engine imports cards/ Suit, Rank, Card, Deck rules/ GameState, actions, events, the reducer - bot/ PlayerView projection, BasicBot - util/ seeded RNG, shuffle, assertions + bot/ PlayerView projection, Bot contract, BasicBot + match/ Match (stateful shell), runMatch (headless driver) + util/ seeded RNG, shuffle modules/ save/ SaveManager, ISaveSection, StatsSection, storage app/ Bootstrap, ServiceLocator, SceneRouter @@ -54,7 +55,10 @@ assets/ lobby/ lobby screen controller game/ match screen: hand, table, deck, animations common/ reusable widgets, safe-area root -tests/ vitest specs against assets/scripts/core +tests/ + core/ vitest specs against assets/scripts/core + support/ position fixtures and the state invariant checker + tools/ measuring tools, not part of the suite docs/ specs, this file, plan, decisions local/ scratch art storage; contents get consumed extensions/cocos-code-mode/ editor tool server @@ -83,9 +87,16 @@ apply(state: GameState, action: Action): { state: GameState; events: GameEvent[] ## Bots -A bot is a function `(view: PlayerView) => Action`. `PlayerView` is a projection -of `GameState` containing only legitimately visible information. `BasicBot` -implements the policy in [GAMEPLAY.md](GAMEPLAY.md) verbatim. +A bot is `decide(view: PlayerView) => Action`. `PlayerView` is a projection of +`GameState` containing only legitimately visible information — own hand, table, +trump, opponents' hand *sizes*, deck and discard counts — plus the list of legal +actions. `BasicBot` implements the policy in [GAMEPLAY.md](GAMEPLAY.md). + +`Match` wraps the reducer with the current state and an event subscription; the +game screen and the headless `runMatch` driver both hold one. As a reference +point, 5000 `BasicBot` self-play matches split roughly 43/43/14 between the two +seats and draws, with the opening attacker losing about 37% of the time — +`npm run stats:bot` reprints these. ## Savegame @@ -114,7 +125,16 @@ Keys are explicit string constants, not class names — release builds minify. npm test ``` -Runs vitest over `tests/` against `assets/scripts/core`. No editor required. +Runs vitest over `tests/core` against `assets/scripts/core`. No editor required. +The soak specs play several thousand complete matches and re-check every state +invariant after every single action, so a rule regression surfaces at the move +that caused it. + +```bash +npm run typecheck # strict tsc over core/ and the tests +npm run check:core-purity # fails if core/ ever imports the engine +npm run stats:bot # BasicBot self-play distribution, not a test +``` ### Driving the editor diff --git a/package.json b/package.json index 61fd42a..f8228cc 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc -p tsconfig.test.json", - "check:core-purity": "node scripts/check-core-purity.mjs" + "check:core-purity": "node scripts/check-core-purity.mjs", + "stats:bot": "vitest run --config vitest.stats.mts" } } diff --git a/tests/core/bot/BasicBot.spec.ts b/tests/core/bot/BasicBot.spec.ts new file mode 100644 index 0000000..6eba8da --- /dev/null +++ b/tests/core/bot/BasicBot.spec.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; +import { ActionType } from '@core/rules/Action'; +import { Phase } from '@core/rules/GameState'; +import { projectView } from '@core/bot/PlayerView'; +import { BasicBot } from '@core/bot/BasicBot'; +import { makeState } from '../../support/fixtures'; + +const YOU = 'you'; +const BOT = 'bot'; + +const bot = new BasicBot(); + +function decideFor(state: Parameters[0], player: string) { + return bot.decide(projectView(state, player)); +} + +describe('BasicBot opening a turn', () => { + it('leads with its cheapest non-trump', () => { + const state = makeState({ + hands: { [BOT]: '9C 6D KH 7S', [YOU]: 'AH AD' }, + trump: 'S', + deck: '10C 10D', + attacker: BOT, + }); + + const action = decideFor(state, BOT); + expect(action.type).toBe(ActionType.Attack); + expect(action).toMatchObject({ card: expect.objectContaining({ code: '6♦' }) }); + }); + + it('keeps its trumps back even when they are cheaper', () => { + const state = makeState({ + hands: { [BOT]: '6S 9C', [YOU]: 'AH AD' }, + trump: 'S', + deck: '10C 10D', + attacker: BOT, + }); + + expect(decideFor(state, BOT)).toMatchObject({ + card: expect.objectContaining({ code: '9♣' }), + }); + }); + + it('leads with its cheapest trump when that is all it holds', () => { + const state = makeState({ + hands: { [BOT]: 'KS 7S', [YOU]: 'AH AD' }, + trump: 'S', + deck: '10C 10D', + attacker: BOT, + }); + + expect(decideFor(state, BOT)).toMatchObject({ + card: expect.objectContaining({ code: '7♠' }), + }); + }); +}); + +describe('BasicBot defending', () => { + it('beats with the cheapest card that works', () => { + const state = makeState({ + hands: { [YOU]: 'AC', [BOT]: '10D 8D QD 6S' }, + trump: 'S', + deck: '10C 10H', + attacker: YOU, + table: [{ attack: '7D' }], + phase: Phase.Defence, + }); + + expect(decideFor(state, BOT)).toMatchObject({ + type: ActionType.Defend, + card: expect.objectContaining({ code: '8♦' }), + }); + }); + + it('prefers any non-trump over a cheap trump', () => { + const state = makeState({ + hands: { [YOU]: 'AC', [BOT]: 'AD 6S' }, + trump: 'S', + deck: '10C 10H', + attacker: YOU, + table: [{ attack: '7D' }], + phase: Phase.Defence, + }); + + expect(decideFor(state, BOT)).toMatchObject({ + card: expect.objectContaining({ code: 'A♦' }), + }); + }); + + it('spends a trump when nothing else beats the attack', () => { + const state = makeState({ + hands: { [YOU]: 'AC', [BOT]: '6D KS 7S' }, + trump: 'S', + deck: '10C 10H', + attacker: YOU, + table: [{ attack: '7D' }], + phase: Phase.Defence, + }); + + expect(decideFor(state, BOT)).toMatchObject({ + card: expect.objectContaining({ code: '7♠' }), + }); + }); + + it('takes when it cannot beat the attack', () => { + const state = makeState({ + hands: { [YOU]: 'AC', [BOT]: '6D 6H' }, + trump: 'S', + deck: '10C 10H', + attacker: YOU, + table: [{ attack: '7D' }], + phase: Phase.Defence, + }); + + expect(decideFor(state, BOT).type).toBe(ActionType.Take); + }); +}); + +describe('BasicBot adding to an attack', () => { + it('throws in every eligible non-trump, cheapest first', () => { + const state = makeState({ + hands: { [BOT]: '7H 7C KD', [YOU]: 'AH AD AC' }, + trump: 'S', + deck: '10C 10D', + attacker: BOT, + table: [{ attack: '7D', defence: '9D' }], + phase: Phase.Reinforce, + }); + + expect(decideFor(state, BOT)).toMatchObject({ + type: ActionType.Attack, + card: expect.objectContaining({ code: '7♣' }), + }); + }); + + it('holds its trumps back while the deck still has cards', () => { + const state = makeState({ + hands: { [BOT]: '7S KD', [YOU]: 'AH AD AC' }, + trump: 'S', + deck: '10C 10D', + attacker: BOT, + table: [{ attack: '7D', defence: '9D' }], + phase: Phase.Reinforce, + }); + + expect(decideFor(state, BOT).type).toBe(ActionType.Pass); + }); + + it('holds its trumps back at the end if it still has other cards', () => { + const state = makeState({ + hands: { [BOT]: '7S KD', [YOU]: 'AH AD AC' }, + trump: 'S', + attacker: BOT, + table: [{ attack: '7D', defence: '9D' }], + phase: Phase.Reinforce, + }); + + expect(state.deck).toHaveLength(0); + expect(decideFor(state, BOT).type).toBe(ActionType.Pass); + }); + + it('throws in a trump once the deck is empty and trumps are all it has', () => { + const state = makeState({ + hands: { [BOT]: '7S KS', [YOU]: 'AH AD AC' }, + trump: 'S', + attacker: BOT, + table: [{ attack: '7D', defence: '9D' }], + phase: Phase.Reinforce, + }); + + expect(decideFor(state, BOT)).toMatchObject({ + type: ActionType.Attack, + card: expect.objectContaining({ code: '7♠' }), + }); + }); +}); + +describe('BasicBot contract', () => { + it('refuses to move out of turn', () => { + const state = makeState({ + hands: { [YOU]: '6C', [BOT]: '8C' }, + trump: 'S', + deck: '10C', + attacker: YOU, + }); + expect(() => decideFor(state, BOT)).toThrow(/out of turn/); + }); +}); diff --git a/tests/core/match/soak.spec.ts b/tests/core/match/soak.spec.ts new file mode 100644 index 0000000..0e9834d --- /dev/null +++ b/tests/core/match/soak.spec.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import { BasicBot } from '@core/bot/BasicBot'; +import { Match } from '@core/match/Match'; +import { runMatch } from '@core/match/runMatch'; +import { legalActions } from '@core/rules/DurakRules'; +import { PlayerId } from '@core/rules/GameState'; +import { Rng } from '@core/util/Rng'; +import { assertInvariants } from '../../support/invariants'; + +/** Plays one seeded match, checking every reachable state along the way. */ +function playChecked(players: PlayerId[], seed: number, pick?: (n: number, rng: Rng) => number) { + const match = new Match({ players, seed }); + const bots = Object.fromEntries(players.map((p) => [p, new BasicBot(p)])); + const rng = new Rng(seed ^ 0x5f3759df); + + assertInvariants(match.state); + + let actions = 0; + while (!match.finished) { + const actor = match.currentActor; + expect(actor).not.toBeNull(); + + const options = legalActions(match.state, actor as PlayerId); + expect(options.length).toBeGreaterThan(0); + + const action = pick + ? options[pick(options.length, rng)] + : bots[actor as PlayerId].decide(match.viewFor(actor as PlayerId)); + + match.perform(action); + assertInvariants(match.state); + + if (++actions > 4000) { + throw new Error(`Match ${seed} will not end`); + } + } + + return { match, actions }; +} + +describe('soak: two basic bots', () => { + it('finishes 2000 seeded matches with every invariant intact', () => { + const outcomes = new Map(); + + for (let seed = 0; seed < 2000; seed++) { + const { match } = playChecked(['you', 'bot'], seed); + const key = match.loser ?? 'draw'; + outcomes.set(key, (outcomes.get(key) ?? 0) + 1); + } + + // Every match must have produced a verdict. + const total = [...outcomes.values()].reduce((a, b) => a + b, 0); + expect(total).toBe(2000); + + // Two identical bots: neither seat should be losing almost every game. + // A wide band — this is a smoke check on the rules, not a balance claim. + for (const seat of ['you', 'bot']) { + expect(outcomes.get(seat) ?? 0).toBeGreaterThan(2000 * 0.2); + } + }); + + it('finishes three-player matches too', () => { + for (let seed = 0; seed < 300; seed++) { + const { match } = playChecked(['a', 'b', 'c'], seed); + expect(match.finished).toBe(true); + if (match.loser !== null) { + expect(['a', 'b', 'c']).toContain(match.loser); + } + } + }); +}); + +describe('soak: random legal play', () => { + it('survives 1000 matches of arbitrary legal moves', () => { + for (let seed = 0; seed < 1000; seed++) { + const { match } = playChecked(['you', 'bot'], seed, (n, rng) => rng.nextInt(n)); + expect(match.finished).toBe(true); + } + }); + + it('survives random play at a crowded table', () => { + for (let seed = 0; seed < 200; seed++) { + const { match } = playChecked(['a', 'b', 'c', 'd'], seed, (n, rng) => rng.nextInt(n)); + expect(match.finished).toBe(true); + } + }); +}); + +describe('match determinism', () => { + it('replays a seed move for move', () => { + const first: string[] = []; + const second: string[] = []; + + for (const trace of [first, second]) { + const match = new Match({ players: ['you', 'bot'], seed: 31337 }); + runMatch(match, { you: new BasicBot('you'), bot: new BasicBot('bot') }, { trace }); + } + + expect(first).toEqual(second); + expect(first.length).toBeGreaterThan(10); + }); + + it('reports the same loser for the same seed', () => { + const play = () => { + const match = new Match({ players: ['you', 'bot'], seed: 555 }); + return runMatch(match, { you: new BasicBot('you'), bot: new BasicBot('bot') }); + }; + expect(play()).toEqual(play()); + }); +}); + +describe('runMatch', () => { + it('stops at a seat it has no bot for', () => { + const match = new Match({ players: ['you', 'bot'], seed: 8 }); + const result = runMatch(match, { bot: new BasicBot('bot') }); + + expect(match.finished).toBe(false); + expect(match.currentActor).toBe('you'); + expect(result.loser).toBeNull(); + }); + + it('refuses to loop forever', () => { + const match = new Match({ players: ['you', 'bot'], seed: 8 }); + expect(() => runMatch( + match, + { you: new BasicBot('you'), bot: new BasicBot('bot') }, + { maxActions: 3 }, + )).toThrow(/did not finish/); + }); +}); diff --git a/tests/support/invariants.ts b/tests/support/invariants.ts new file mode 100644 index 0000000..977c779 --- /dev/null +++ b/tests/support/invariants.ts @@ -0,0 +1,108 @@ +import { Card } from '@core/cards/Card'; +import { GameState, Phase } from '@core/rules/GameState'; +import { attackSeats, currentActor, legalActions, maxTableSlots, unbeatenSlots } from '@core/rules/DurakRules'; + +/** + * Everything that must be true of any reachable state. Called after every single + * action in the soak test, so a rule bug is caught at the move that caused it + * rather than a hundred moves later. + * + * Throws with a description of the first violation. + */ +export function assertInvariants(state: GameState): void { + const problems: string[] = []; + + // Cards are conserved: all 36, each exactly once, somewhere. + const seen: Card[] = [ + ...state.deck, + ...state.discard, + ...state.players.flatMap((p) => p.hand), + ]; + for (const slot of state.table) { + seen.push(slot.attack); + if (slot.defence) seen.push(slot.defence); + } + if (seen.length !== 36) { + problems.push(`${seen.length} cards in play, expected 36`); + } + if (new Set(seen).size !== seen.length) { + problems.push('the same card exists in two places'); + } + + // The trump card is the bottom of the deck until it is drawn. + if (state.deck.length > 0 && state.deck[state.deck.length - 1] !== state.trumpCard) { + problems.push('the trump card is not at the bottom of the deck'); + } + if (state.trumpSuit !== state.trumpCard.suit) { + problems.push('the trump suit does not match the trump card'); + } + + // Table shape. + if (state.table.length > maxTableSlots(state)) { + problems.push(`${state.table.length} attacking cards exceeds the limit`); + } + for (const slot of state.table) { + if (slot.defence && slot.defence === slot.attack) { + problems.push('a card beat itself'); + } + } + + // Seats. + if (state.attacker === state.defender) { + problems.push('the attacker is also the defender'); + } + if (state.phase !== Phase.Finished) { + if (state.players[state.attacker].out) problems.push('the attacker has left the game'); + if (state.players[state.defender].out) problems.push('the defender has left the game'); + } + + // A player who is out holds nothing; a player who holds nothing while the + // deck still has cards should have refilled. + for (const player of state.players) { + if (player.out && player.hand.length > 0) { + problems.push(`${player.id} is out but still holds cards`); + } + if (!player.out && player.hand.length === 0 && state.phase === Phase.Attack) { + problems.push(`${player.id} is at the table with an empty hand`); + } + } + + // Phase consistency. + switch (state.phase) { + case Phase.Attack: + if (state.table.length !== 0) problems.push('a turn opened with cards already down'); + if (state.defenderTook) problems.push('a turn opened mid-take'); + break; + case Phase.Defence: + if (unbeatenSlots(state).length === 0) problems.push('defending with nothing to beat'); + if (state.defenderTook) problems.push('defending after declaring a take'); + break; + case Phase.Reinforce: + if (!state.defenderTook && unbeatenSlots(state).length > 0) { + problems.push('adding cards while an attack is unanswered'); + } + if (attackSeats(state).every((s) => state.passed[s])) { + problems.push('every attacker has passed but the turn is still open'); + } + break; + case Phase.Finished: + if (state.players.filter((p) => !p.out).length > 1) { + problems.push('the game ended with more than one player left'); + } + break; + } + + // Whoever is on move must have something to do. + const actor = currentActor(state); + if (state.phase !== Phase.Finished) { + if (actor === null) { + problems.push('nobody is on move in an unfinished game'); + } else if (legalActions(state, actor).length === 0) { + problems.push(`${actor} is on move with no legal action`); + } + } + + if (problems.length > 0) { + throw new Error(`Broken state: ${problems.join('; ')}`); + } +} diff --git a/tests/tools/botStats.stats.ts b/tests/tools/botStats.stats.ts new file mode 100644 index 0000000..9a67ed5 --- /dev/null +++ b/tests/tools/botStats.stats.ts @@ -0,0 +1,45 @@ +/** + * Not part of the suite — a measuring tool. Run with `npm run stats:bot`. + * + * Plays a large number of seeded matches between two BasicBots and prints how + * they came out. Useful for sanity-checking a rules change (a sudden jump in + * draws or in the first-attacker advantage means something moved) and as the + * baseline any smarter bot has to beat. + */ +import { describe, it } from 'vitest'; +import { BasicBot } from '@core/bot/BasicBot'; +import { Match } from '@core/match/Match'; +import { runMatch } from '@core/match/runMatch'; + +const MATCHES = 5000; + +describe('BasicBot vs BasicBot', () => { + it(`plays ${MATCHES} matches`, () => { + const tally: Record = { you: 0, bot: 0, draw: 0 }; + let firstAttackerLost = 0; + let actions = 0; + let turns = 0; + + for (let seed = 0; seed < MATCHES; seed++) { + const match = new Match({ players: ['you', 'bot'], seed }); + const firstAttacker = match.state.players[match.state.attacker].id; + const result = runMatch(match, { you: new BasicBot('you'), bot: new BasicBot('bot') }); + + tally[result.loser ?? 'draw'] += 1; + if (result.loser === firstAttacker) firstAttackerLost += 1; + actions += result.actions; + turns += result.turns; + } + + const pct = (n: number) => `${((100 * n) / MATCHES).toFixed(1)}%`; + console.log([ + `matches ${MATCHES}`, + `seat 'you' lost ${tally.you} (${pct(tally.you)})`, + `seat 'bot' lost ${tally.bot} (${pct(tally.bot)})`, + `draws ${tally.draw} (${pct(tally.draw)})`, + `first attacker lost ${pct(firstAttackerLost)}`, + `average actions ${(actions / MATCHES).toFixed(1)}`, + `average turns ${(turns / MATCHES).toFixed(1)}`, + ].join('\n')); + }); +}); diff --git a/vitest.stats.mts b/vitest.stats.mts new file mode 100644 index 0000000..9951334 --- /dev/null +++ b/vitest.stats.mts @@ -0,0 +1,16 @@ +// Config for the measuring tools under tests/tools, which are deliberately not +// part of the suite: `npm run stats:bot`. +import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; + +export default defineConfig({ + resolve: { + alias: { + '@core': resolve(__dirname, 'assets/scripts/core'), + }, + }, + test: { + include: ['tests/tools/**/*.stats.ts'], + environment: 'node', + }, +});