/** Random playouts for 보석 상인 (docs/09 §7): 1,000 seeds plus a few full engine randomPlayout runs. */ import { describe, expect, test } from 'bun:test'; import { SeededRng, randomPlayout, seedN, type PlayerId } from '@bg/engine'; import { COLORS, NOBLES, TIERS, componentCounts, splendor, type SplendorAction, type SplendorOptions, type SplendorState, type Tokens } from './index'; const CONFIGS: Partial[] = [ {}, { targetPoints: 10 }, { allowFewerTokens: false }, { turnSeconds: null, showHints: false }, { turnSeconds: 30, targetPoints: 10, allowFewerTokens: false }, ]; function conserved(state: unknown): void { const s = state as SplendorState; const n = s.order.length <= 2 ? 4 : s.order.length === 3 ? 5 : 7; const c = componentCounts(s); const want: Tokens = { W: n, U: n, G: n, R: n, K: n, gold: 5 }; for (const k of [...COLORS, 'gold'] as const) if (c.tokens[k] !== want[k]) throw new Error(`token ${k}: ${c.tokens[k]} != ${want[k]}`); if (c.cards !== 90) throw new Error(`cards: ${c.cards}`); if (c.nobles !== s.order.length + 1) throw new Error(`nobles: ${c.nobles}`); for (const p of s.order) { const ps = s.players[p]!; if (ps.reserved.length > 3) throw new Error('more than 3 reservations'); for (const k of [...COLORS, 'gold'] as const) if (ps.tokens[k] < 0) throw new Error('negative tokens'); const total = Object.values(ps.tokens).reduce((a, b) => a + b, 0); if (total > 10 && !(s.phase === 'discard' && s.order[s.current] === p)) throw new Error(`${p} holds ${total} tokens outside the discard phase`); } for (const t of TIERS) if (s.board[t].length !== 4) throw new Error('board must keep 4 slots'); } const ID_RE = /"(?:[123][WUGRK]\d\d|N\d\d)"/g; /** Who may see each hidden id: '*' = nobody, otherwise the owner of a blind reservation. */ function hiddenOwners(s: SplendorState): Map { const m = new Map(); for (const t of TIERS) for (const id of s.decks[t]) m.set(id, '*'); for (const r of s.removed) if (r.blind) m.set(r.cardId, '*'); const seen = new Set([...s.nobles, ...s.order.flatMap((p) => s.players[p]!.nobles)]); for (const n of NOBLES) if (!seen.has(n.id)) m.set(n.id, '*'); if (s.phase !== 'finished') for (const p of s.order) for (const r of s.players[p]!.reserved) if (r.blind) m.set(r.cardId, p); return m; } function run(seed: number): { state: SplendorState; steps: number } { const options = splendor.optionsSchema.parse(CONFIGS[seed % CONFIGS.length]); const players = ['P0', 'P1', 'P2', 'P3'].slice(0, 2 + (seed % 3)); const rng = SeededRng.fromSeed(seedN(seed)); const chooser = SeededRng.fromSeed(seedN(seed + 700_000)); let now = 1_700_000_000_000; let s = splendor.setup({ players, options, rng, now }); const concedeAt = seed % 13 === 0 ? 30 + (seed % 40) : -1; for (let step = 0; step < 2000; step++) { if (step % 10 === 0) { const json = JSON.stringify(s); if (JSON.stringify(JSON.parse(json)) !== json) throw new Error('not JSON round-trippable'); } conserved(s); const owners = hiddenOwners(s); for (const viewer of [...players, null]) { const ids = JSON.stringify(splendor.view(s, viewer)).match(ID_RE) ?? []; for (const q of ids) { const o = owners.get(q.slice(1, -1)); if (o && o !== viewer) throw new Error(`seed ${seed} step ${step}: ${viewer} sees ${q} (${o})`); } } if (splendor.result(s)) return { state: s, steps: step }; const waiting = splendor.activePlayers(s); if (waiting.length !== 1) throw new Error('exactly one player should be waited for'); for (const p of waiting) { const v = splendor.validate(s, p, splendor.onTimeout(s, p)); if (!v.ok) throw new Error(`seed ${seed}: onTimeout illegal: ${v.reason}`); } let actor = waiting[0]!; let a: SplendorAction; if (step === concedeAt) { const inGame = players.filter((p) => !s.conceded.includes(p)); actor = inGame[chooser.int(inGame.length)]!; a = splendor.onLeave!(s, actor)!; } else { const legal = splendor.legalActions!(s, actor); a = legal.length && chooser.int(8) !== 0 ? legal[chooser.int(legal.length)]! : splendor.onTimeout(s, actor); } const v = splendor.validate(s, actor, a); if (!v.ok) throw new Error(`seed ${seed}: generated action rejected: ${v.reason} ${JSON.stringify(a)}`); now += 1000; const t0 = performance.now(); const out = splendor.apply(s, actor, a, { rng, now }); const ms = performance.now() - t0; if (ms > 20) throw new Error(`apply took ${ms.toFixed(1)}ms`); const after = hiddenOwners(out.state); for (const q of JSON.stringify(out.events).match(ID_RE) ?? []) if (after.has(q.slice(1, -1))) throw new Error(`seed ${seed}: event leaks ${q}`); s = out.state; } throw new Error(`seed ${seed}: not finished within 2000 steps`); } describe('random playouts (docs/09 §7)', () => { test('engine randomPlayout (round trip, checkNoLeak via secretsOf, invariants, determinism)', () => { for (let i = 0; i < 8; i++) { const options = splendor.optionsSchema.parse(CONFIGS[i % CONFIGS.length]); const players = ['P0', 'P1', 'P2', 'P3'].slice(0, 2 + (i % 3)); const a = randomPlayout(splendor, { players, options, seed: seedN(5000 + i), maxSteps: 2000, invariant: conserved, maxApplyMs: 20 }); expect(a.finished).toBe(true); if (i < 2) { const b = randomPlayout(splendor, { players, options, seed: seedN(5000 + i), maxSteps: 2000, maxApplyMs: 20 }); expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState)); } } }, 60_000); test('1,000 seeded playouts: conservation, no leaks in views or events, onTimeout always legal, every game ends', () => { const ends: Record = {}; let maxSteps = 0; for (let seed = 1; seed <= 1000; seed++) { const { state, steps } = run(seed); maxSteps = Math.max(maxSteps, steps); const res = splendor.result(state)!; expect(res.ranking.flat().sort()).toEqual([...state.order].sort()); expect(res.summary.length).toBeGreaterThan(0); const k = state.endReason ?? 'none'; ends[k] = (ends[k] ?? 0) + 1; if (seed % 100 === 0) expect(JSON.stringify(run(seed).state)).toBe(JSON.stringify(state)); if (state.endReason === 'target') { // Everyone in the game had the same number of turns (the round was played to its end). const inGame = state.order.filter((p: PlayerId) => !state.conceded.includes(p)); const turns = new Set(inGame.map((p) => state.turnsTaken[p])); if (!state.conceded.length) expect(turns.size).toBe(1); } } expect(ends.target ?? 0).toBeGreaterThan(900); expect(ends.resign ?? 0).toBeGreaterThan(0); expect(maxSteps).toBeLessThan(2000); }, 60_000); });