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