import { describe, expect, test } from 'bun:test'; import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine'; import { KINDS, allCardIds, autoAction, cockroachPoker as game, countKinds, handSizes, kindOf, type CockroachAction, type CockroachOptions, type CockroachState, type CockroachView, type Kind, } from './index'; // ---------------------------------------------------------------- helpers let clock = 1_000; const rng = () => SeededRng.fromSeed([7, 7, 7, 7]); function setup(opts: Partial = {}, players = ['A', 'B', 'C', 'D'], seed = 1): CockroachState { return game.setup({ players, options: game.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(seed)), now: 0 }); } /** * A game in progress with the given hands / fronts (as kinds; ids are assigned in order) and `starter` to offer. * Unused cards are dropped from play (tests only care about the listed ones). */ function rigged(hands: Record, opts: Partial = {}, fronts: Record = {}, starter?: string): CockroachState { const players = Object.keys(hands); const s = setup(opts, players); const next: Record = Object.fromEntries(KINDS.map((k) => [k, 0])) as Record; const take = (k: Kind) => `${k}-${next[k]++}`; for (const p of players) { s.hands[p] = hands[p]!.map(take); s.fronts[p] = (fronts[p] ?? []).map(take); } s.removed = []; s.round = { no: 1, starter: starter ?? players[0]!, card: null, holder: null, lastPasser: null, claim: null, seen: [], peeked: false, claims: [] }; return s; } function actE(s: CockroachState, p: string, a: CockroachAction): { state: CockroachState; events: GameEvent[] } { const v = game.validate(s, p, a); if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`); return game.apply(s, p, a, { rng: rng(), now: (clock += 1000) }); } const act = (s: CockroachState, p: string, a: CockroachAction) => actE(s, p, a).state; const reject = (s: CockroachState, p: string, a: CockroachAction) => { const v = game.validate(s, p, a); return v.ok ? null : v.reason; }; const cardOf = (s: CockroachState, p: string, k: Kind) => s.hands[p]!.find((id) => kindOf(id) === k)!; const offer = (s: CockroachState, from: string, k: Kind, to: string, claim: Kind) => act(s, from, { type: 'offer', cardId: cardOf(s, from, k), to, claim }); const frontKinds = (s: CockroachState, p: string) => s.fronts[p]!.map(kindOf); /** Every card exists exactly once across hands, fronts, the removed pile and the passed card. */ function checkInvariants(st: unknown, total = 64): void { const s = st as CockroachState; const all = [...Object.values(s.hands).flat(), ...Object.values(s.fronts).flat(), ...s.removed, ...(s.round.card ? [s.round.card] : [])]; if (all.length !== total || new Set(all).size !== total) throw new Error(`cards not conserved: ${all.length}/${total}`); if (s.final === null) { for (const p of s.order) { const c = countKinds(s.fronts[p]!); if (KINDS.some((k) => c[k] >= s.loseCount)) throw new Error(`${p} should have lost`); } const r = s.round; if ((r.card === null) !== (r.holder === null)) throw new Error('card/holder mismatch'); if (r.holder !== null && !r.seen.includes(r.starter)) throw new Error('starter not in seen'); } } /** Structural hidden-info check for one view (beyond the id-substring leak test). */ function checkViewShape(s: CockroachState, v: CockroachView, viewer: string | null): void { const over = s.final !== null; for (const p of s.order) expect(v.handCounts[p]).toBe(s.hands[p]!.length); if (viewer === null) expect(v.myHand).toBeNull(); const json = JSON.stringify(v); if (!over) { expect(v.revealed).toBeNull(); for (const id of s.removed) if (json.includes(JSON.stringify(id))) throw new Error(`removed card ${id} leaks`); for (const p of s.order) if (p !== viewer) for (const id of s.hands[p]!) if (json.includes(JSON.stringify(id))) throw new Error(`hand card ${id} of ${p} leaks to ${viewer}`); const knows = viewer !== null && s.round.seen.includes(viewer); if (s.round.card) { if (knows) expect(v.card).toEqual({ known: true, id: s.round.card, kind: kindOf(s.round.card) }); else { expect(v.card).toEqual({ known: false }); if (json.includes(JSON.stringify(s.round.card))) throw new Error(`passed card leaks to ${viewer}`); } } } } // ---------------------------------------------------------------- setup describe('셋업 (§4)', () => { test('인원별 손패 장수가 표와 일치(첫 플레이어부터), 2인은 10장 제거 후 27장씩, 카드 총합 보존', () => { const table: Record = { 2: [27, 27], 3: [22, 21, 21], 4: [16, 16, 16, 16], 5: [13, 13, 13, 13, 12], 6: [11, 11, 11, 11, 10, 10] }; for (let n = 2; n <= 6; n++) { expect(handSizes(n)).toEqual(table[n]!); for (let seed = 1; seed <= 10; seed++) { const players = Array.from({ length: n }, (_, i) => `P${i}`); const s = setup({}, players, seed); const fi = players.indexOf(s.round.starter); const sizes = players.map((_, k) => s.hands[players[(fi + k) % n]!]!.length); expect(sizes).toEqual(table[n]!); expect(s.removed.length).toBe(n === 2 ? 10 : 0); expect(s.loseCount).toBe(n === 2 ? 5 : 4); checkInvariants(s); for (const p of players) expect(s.fronts[p]).toEqual([]); } } expect(allCardIds().length).toBe(64); for (const k of KINDS) expect(allCardIds().filter((id) => kindOf(id) === k).length).toBe(8); }); test('같은 시드 = 같은 셋업; 첫 시작자는 시드에 따라 달라짐', () => { expect(JSON.stringify(setup({}, ['A', 'B', 'C'], 5))).toBe(JSON.stringify(setup({}, ['A', 'B', 'C'], 5))); const starters = new Set(Array.from({ length: 30 }, (_, i) => setup({}, ['A', 'B', 'C'], i + 1).round.starter)); expect(starters.size).toBe(3); }); test('loseCount 옵션: 자동(2인 5, 3~6인 4) 또는 지정값', () => { expect(setup({ loseCount: 3 }, ['A', 'B']).loseCount).toBe(3); expect(setup({ loseCount: 5 }, ['A', 'B', 'C']).loseCount).toBe(5); expect(setup({}, ['A', 'B', 'C', 'D', 'E', 'F']).loseCount).toBe(4); }); }); // ---------------------------------------------------------------- rules describe('판정 (§5.2, §5.3)', () => { test('진실 주장에 "진짜" → 전달자 앞마당; 받은 사람이 다음 시작자', () => { let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }); s = offer(s, 'A', 'rat', 'B', 'rat'); const out = actE(s, 'B', { type: 'call', call: 'true' }); s = out.state; expect(frontKinds(s, 'A')).toEqual(['rat']); expect(frontKinds(s, 'B')).toEqual([]); expect(s.round.starter).toBe('A'); expect(s.round.no).toBe(2); expect(s.lastReveal).toMatchObject({ kind: 'rat', claim: 'rat', call: 'true', caller: 'B', correct: true, takenBy: 'A' }); expect(out.events.find((e) => e.type === 'reveal')).toMatchObject({ kind: 'rat', correct: true, takenBy: 'A' }); checkInvariants(s, 4); }); test('거짓 주장에 "거짓" → 전달자; 거짓 주장에 "진짜" → 수신자; 진실 주장에 "거짓" → 수신자', () => { let s = rigged({ A: ['rat', 'rat'], B: ['bat', 'bat'], C: ['toad'] }); s = offer(s, 'A', 'rat', 'B', 'fly'); s = act(s, 'B', { type: 'call', call: 'false' }); expect(frontKinds(s, 'A')).toEqual(['rat']); expect(s.round.starter).toBe('A'); s = offer(s, 'A', 'rat', 'C', 'fly'); s = act(s, 'C', { type: 'call', call: 'true' }); expect(frontKinds(s, 'C')).toEqual(['rat']); expect(s.round.starter).toBe('C'); s = offer(s, 'C', 'toad', 'B', 'toad'); s = act(s, 'B', { type: 'call', call: 'false' }); expect(frontKinds(s, 'B')).toEqual(['toad']); expect(s.round.starter).toBe('B'); }); test('§5.4 예시: A가 거미를 "파리"로 B에게 → B가 보고 C에게 "거미"(진실) → C "거짓" → C 앞마당, C가 다음 시작자', () => { let s = rigged({ A: ['spider', 'rat'], B: ['bat'], C: ['toad'], D: ['fly'] }); s = offer(s, 'A', 'spider', 'B', 'fly'); s = act(s, 'B', { type: 'peek' }); expect(s.round.seen).toEqual(['A', 'B']); expect(game.view(s, 'B').card).toEqual({ known: true, id: 'spider-0', kind: 'spider' }); s = act(s, 'B', { type: 'pass', to: 'C', claim: 'spider' }); expect([s.round.holder, s.round.lastPasser, s.round.claim]).toEqual(['C', 'B', 'spider']); const alt = s; s = act(s, 'C', { type: 'call', call: 'false' }); expect(frontKinds(s, 'C')).toEqual(['spider']); expect(s.round.starter).toBe('C'); // C could instead peek and pass only to D; D then has nobody left and must call. let t = act(alt, 'C', { type: 'peek' }); expect(game.view(t, 'C').my!.passTargets).toEqual(['D']); expect(reject(t, 'C', { type: 'pass', to: 'A', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.'); t = act(t, 'C', { type: 'pass', to: 'D', claim: 'bat' }); expect(reject(t, 'D', { type: 'peek' })).toContain('넘길 사람이 없어요'); expect(game.view(t, 'D').my).toMatchObject({ canCall: true, canPeek: false }); expect(game.view(t, 'D').my!.peekBlocked).toContain('넘길 사람이 없어요'); t = act(t, 'D', { type: 'call', call: 'false' }); // claim "bat", card spider → lie → D correct → C takes it expect(frontKinds(t, 'C')).toEqual(['spider']); }); }); describe('떠넘기기 거부 (§5.2, §8.2)', () => { test('seen에 있는 사람(시작자 포함)에게 pass 거부, 본인에게도 거부', () => { let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'], D: ['fly'] }); s = offer(s, 'A', 'rat', 'B', 'rat'); s = act(s, 'B', { type: 'peek' }); expect(reject(s, 'B', { type: 'pass', to: 'A', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.'); expect(reject(s, 'B', { type: 'pass', to: 'B', claim: 'rat' })).toBe('나에게는 넘길 수 없어요.'); s = act(s, 'B', { type: 'pass', to: 'C', claim: 'rat' }); s = act(s, 'C', { type: 'peek' }); expect(reject(s, 'C', { type: 'pass', to: 'B', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.'); expect(reject(s, 'C', { type: 'pass', to: 'A', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.'); expect(reject(s, 'C', { type: 'pass', to: 'D', claim: 'rat' })).toBeNull(); }); test('나머지 전원이 본 상태의 holder는 peek 거부, call만 가능', () => { let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] }); s = offer(s, 'A', 'rat', 'B', 'rat'); s = act(s, 'B', { type: 'peek' }); s = act(s, 'B', { type: 'pass', to: 'C', claim: 'fly' }); expect(reject(s, 'C', { type: 'peek' })).toBe('모두 이 카드를 이미 봐서 넘길 사람이 없어요. 진짜인지 거짓인지 골라 주세요.'); expect(game.legalActions!(s, 'C')).toEqual([ { type: 'call', call: 'true' }, { type: 'call', call: 'false' }, ]); }); test('peek 후 call 거부; peek 전 pass 거부; 두 번 peek 거부', () => { let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] }); s = offer(s, 'A', 'rat', 'B', 'rat'); expect(reject(s, 'B', { type: 'pass', to: 'C', claim: 'rat' })).toBe('먼저 카드를 몰래 봐야 넘길 수 있어요.'); s = act(s, 'B', { type: 'peek' }); expect(reject(s, 'B', { type: 'call', call: 'true' })).toBe('카드를 이미 봤으니 다른 사람에게 넘겨야 해요.'); expect(reject(s, 'B', { type: 'peek' })).toBe('이미 카드를 봤어요.'); expect(game.legalActions!(s, 'B').every((a) => a.type === 'pass')).toBe(true); }); test('차례가 아닌 사람·잘못된 offer 거부', () => { let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] }); expect(reject(s, 'B', { type: 'offer', cardId: 'bat-0', to: 'C', claim: 'bat' })).toBe('지금은 내 차례가 아니에요.'); expect(reject(s, 'A', { type: 'offer', cardId: 'bat-0', to: 'C', claim: 'bat' })).toBe('내 손에 없는 카드예요.'); expect(reject(s, 'A', { type: 'offer', cardId: 'rat-0', to: 'A', claim: 'bat' })).toBe('나에게는 건넬 수 없어요.'); expect(reject(s, 'A', { type: 'offer', cardId: 'rat-0', to: 'Z', claim: 'bat' })).toBe('이 게임의 플레이어가 아니에요.'); expect(reject(s, 'A', { type: 'call', call: 'true' })).toBe('지금은 내가 판정할 차례가 아니에요.'); expect(reject(s, 'Z', { type: 'peek' })).toBe('이 게임의 플레이어가 아니에요.'); s = offer(s, 'A', 'rat', 'B', 'rat'); expect(reject(s, 'A', { type: 'offer', cardId: 'rat-0', to: 'C', claim: 'bat' })).toBe('지금은 카드를 건넬 때가 아니에요.'); expect(reject(s, 'C', { type: 'call', call: 'true' })).toBe('지금은 내가 판정할 차례가 아니에요.'); expect(reject(s, 'C', { type: 'timeout' })).toBe('시간 초과 대상이 아니에요.'); }); }); describe('2인 규칙 (§5.2, §6)', () => { test('peek 거부(항상 판정), 같은 종류 5장에서 패배, 4장에서는 계속', () => { let s = rigged({ A: ['rat', 'rat', 'bat'], B: ['fly', 'fly'] }, {}, { A: ['rat', 'rat', 'rat'] }); expect(s.loseCount).toBe(5); s = offer(s, 'A', 'rat', 'B', 'rat'); expect(reject(s, 'B', { type: 'peek' })).toBe('2명이 할 때는 몰래 보고 넘기기를 할 수 없어요. 진짜인지 거짓인지 골라 주세요.'); expect(game.view(s, 'B').my).toMatchObject({ canPeek: false, canCall: true }); s = act(s, 'B', { type: 'call', call: 'true' }); // A takes 4th rat → continues expect(frontKinds(s, 'A').filter((k) => k === 'rat').length).toBe(4); expect(s.final).toBeNull(); s = offer(s, 'A', 'rat', 'B', 'bat'); s = act(s, 'B', { type: 'call', call: 'false' }); // 5th rat → A loses expect(s.final).toMatchObject({ loser: 'A', reason: 'four', losingKind: 'rat' }); expect(game.result(s)).toMatchObject({ ranking: [['B'], ['A']], scores: { A: 0, B: 1 }, reason: 'normal' }); }); test('twoPlayerPass: 한 번만 보고 되돌려 줄 수 있고, 되돌려 받은 사람은 판정만', () => { let s = rigged({ A: ['rat', 'bat'], B: ['fly'] }, { twoPlayerPass: true }); s = offer(s, 'A', 'rat', 'B', 'bat'); expect(game.view(s, 'B').my!.canPeek).toBe(true); s = act(s, 'B', { type: 'peek' }); expect(game.view(s, 'B').my!.passTargets).toEqual(['A']); s = act(s, 'B', { type: 'pass', to: 'A', claim: 'rat' }); expect(reject(s, 'A', { type: 'peek' })).toContain('되돌려 받은 카드'); s = act(s, 'A', { type: 'call', call: 'true' }); expect(frontKinds(s, 'B')).toEqual(['rat']); expect(s.round.starter).toBe('B'); }); }); describe('패배 조건 (§6)', () => { test('같은 종류 4장 도달 즉시 종료, 다른 종류 4장(합계)은 무관', () => { let s = rigged({ A: ['rat', 'rat'], B: ['fly'], C: ['toad'] }, {}, { B: ['bat', 'fly', 'toad'], C: ['rat', 'rat', 'rat'] }); s = offer(s, 'A', 'rat', 'B', 'rat'); s = act(s, 'B', { type: 'call', call: 'false' }); // wrong → B takes rat: 4 different kinds expect(s.final).toBeNull(); expect(s.fronts.B!.length).toBe(4); s = offer(s, 'B', 'fly', 'C', 'rat'); const out = actE(s, 'C', { type: 'call', call: 'true' }); // wrong → C takes fly: still 3 rats + 1 fly expect(out.state.final).toBeNull(); s = offer(out.state, 'C', 'toad', 'A', 'toad'); s = act(s, 'A', { type: 'call', call: 'false' }); // wrong → A takes toad s = offer(s, 'A', 'rat', 'C', 'bat'); const end = actE(s, 'C', { type: 'call', call: 'true' }); // wrong → C takes 4th rat expect(end.state.final).toMatchObject({ loser: 'C', reason: 'four', losingKind: 'rat' }); expect(end.events.some((e) => e.type === 'gameEnded')).toBe(true); expect(game.activePlayers(end.state)).toEqual([]); expect(game.deadline!(end.state)).toBeNull(); const res = game.result(end.state)!; expect(res.ranking).toEqual([['A', 'B'], ['C']]); expect(res.summary).toContain('쥐 4장'); // Detailed placings: A (max 1, total 2) before B (max 1, total 4); loser last. expect(end.state.final!.placings).toEqual([['A'], ['B'], ['C']]); expect(reject(end.state, 'A', { type: 'offer', cardId: 'rat-0', to: 'B', claim: 'rat' })).toBe('게임이 이미 끝났어요.'); }); test('loseCount=3 옵션', () => { let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }, { loseCount: 3 }, { B: ['rat', 'rat'] }); s = offer(s, 'A', 'rat', 'B', 'fly'); s = act(s, 'B', { type: 'call', call: 'true' }); expect(s.final).toMatchObject({ loser: 'B', reason: 'four', losingKind: 'rat' }); }); test('다음 시작자의 손패가 0장 → 즉시 패배(reason emptyHand)', () => { let s = rigged({ A: ['rat', 'fly'], B: [], C: ['toad'] }); s = offer(s, 'A', 'rat', 'B', 'rat'); s = act(s, 'B', { type: 'call', call: 'false' }); // wrong → B takes it and must start with 0 cards expect(s.final).toMatchObject({ loser: 'B', reason: 'emptyHand' }); expect(game.result(s)!.ranking[1]).toEqual(['B']); }); test('손패 0장인 사람에게 건네기 허용, 그가 떠넘기기/판정 정상 동작', () => { let s = rigged({ A: ['rat', 'fly'], B: [], C: ['toad'], D: ['bat'] }); expect(game.view(s, 'A').my!.offerTargets).toEqual(['B', 'C', 'D']); s = offer(s, 'A', 'rat', 'B', 'bat'); s = act(s, 'B', { type: 'peek' }); s = act(s, 'B', { type: 'pass', to: 'C', claim: 'rat' }); s = act(s, 'C', { type: 'call', call: 'true' }); // true claim → C right → B takes it, then B starts with 0 cards expect(frontKinds(s, 'B')).toEqual(['rat']); expect(s.final).toMatchObject({ loser: 'B', reason: 'emptyHand' }); // Calling correctly with an empty hand is fine: the passer takes the card. let t = rigged({ A: ['rat', 'fly'], B: [], C: ['toad'] }); t = offer(t, 'A', 'rat', 'B', 'rat'); t = act(t, 'B', { type: 'call', call: 'true' }); expect(t.final).toBeNull(); expect(t.round.starter).toBe('A'); }); test('eightKindsRule: 8종이 모이면 패배; 끄면 계속', () => { const fr = { B: ['cockroach', 'bat', 'fly', 'toad', 'scorpion', 'spider', 'stinkbug'] as Kind[] }; for (const on of [true, false]) { let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }, { eightKindsRule: on }, fr); s = offer(s, 'A', 'rat', 'B', 'fly'); s = act(s, 'B', { type: 'call', call: 'true' }); if (on) expect(s.final).toMatchObject({ loser: 'B', reason: 'eight' }); else expect(s.final).toBeNull(); } }); }); // ---------------------------------------------------------------- timers describe('시간 초과 (§8.5)', () => { test('시작자 무응답 → 자동 offer(다음 좌석, 손패 1장, 무작위 주장)', () => { const s = rigged({ A: ['rat', 'fly', 'bat'], B: ['bat'], C: ['toad'] }); expect(game.onTimeout(s, 'A')).toEqual({ type: 'timeout' }); expect(game.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false); const out = actE(s, 'A', { type: 'timeout' }); const t = out.state; expect(t.round.holder).toBe('B'); expect(t.round.claims[0]).toMatchObject({ from: 'A', to: 'B', auto: true }); expect(t.hands.A!.length).toBe(2); expect(out.events.map((e) => e.type)).toEqual(['timeout', 'offer']); expect(t.timeoutsInRow.A).toBe(1); }); test('holder 무응답 → 자동 call; peek 후 무응답 → 자동 pass(seen 밖 첫 좌석, 실제 종류로)', () => { let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'], D: ['fly'] }); s = offer(s, 'A', 'rat', 'B', 'fly'); const called = act(s, 'B', { type: 'timeout' }); expect(called.lastReveal).toMatchObject({ caller: 'B', auto: true }); expect(called.round.no).toBe(2); s = act(s, 'B', { type: 'peek' }); const passed = actE(s, 'B', { type: 'timeout' }); expect(passed.state.round).toMatchObject({ holder: 'C', lastPasser: 'B', claim: 'rat', peeked: false }); expect(passed.state.round.claims[1]).toMatchObject({ from: 'B', to: 'C', claim: 'rat', auto: true }); // C peeks; first unseen seat clockwise from C is D. let t = act(passed.state, 'C', { type: 'peek' }); t = act(t, 'C', { type: 'timeout' }); expect(t.round.holder).toBe('D'); expect(autoAction(t, 'D', rng()).type).toBe('call'); }); test('3번 연속 시간 초과 → 자리 비움(마감 5초), 직접 행동하면 해제', () => { const t0 = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }, { turnSeconds: 30 }); let t = act({ ...t0, timeoutsInRow: { A: 1, B: 0, C: 0 } }, 'A', { type: 'timeout' }); expect([t.away.A, t.timeoutsInRow.A]).toEqual([false, 2]); const third = actE({ ...t0, timeoutsInRow: { A: 2, B: 0, C: 0 } }, 'A', { type: 'timeout' }); expect(third.state.away.A).toBe(true); expect(third.events.map((e) => e.type)).toEqual(['timeout', 'away', 'offer']); t = third.state; expect(game.view(t, null).away.A).toBe(true); const w = { ...t0, away: { A: true, B: false, C: false } }; expect(game.deadline!(w)! - w.turnStartedAt).toBe(5000); expect(game.view(w, 'A').turnMs).toBe(5000); const back = act(w, 'A', { type: 'offer', cardId: 'rat-0', to: 'B', claim: 'rat' }); expect(back.away.A).toBe(false); expect(back.timeoutsInRow.A).toBe(0); expect(game.deadline!(back)! - back.turnStartedAt).toBe(30_000); }); test('모두 무응답이어도 게임이 끝남; 매 상태에서 onTimeout이 합법', () => { for (let seed = 1; seed <= 40; seed++) { const n = 2 + (seed % 5); const players = Array.from({ length: n }, (_, i) => `P${i}`); let s = setup({ twoPlayerPass: seed % 2 === 0 }, players, seed); const r = SeededRng.fromSeed(seedN(seed)); let step = 0; for (; step < 2000 && !s.final; step++) { const p = game.timeoutPlayers!(s)[0]!; expect(game.validate(s, p, game.onTimeout(s, p))).toEqual({ ok: true }); s = game.apply(s, p, game.onTimeout(s, p), { rng: r, now: step }).state; checkInvariants(s); } expect(s.final).not.toBeNull(); } }); }); describe('나가기·빠른 말', () => { test('나가면 그 사람이 패배하고 나머지 모두 승리(reason resign)', () => { let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] }); s = offer(s, 'A', 'rat', 'B', 'rat'); expect(game.onLeave!(s, 'C')).toEqual({ type: 'leave' }); expect(game.onLeave!(s, 'Z')).toBeNull(); s = act(s, 'C', { type: 'leave' }); expect(s.final).toMatchObject({ loser: 'C', reason: 'left' }); expect(game.result(s)).toMatchObject({ ranking: [['A', 'B'], ['C']], reason: 'resign' }); expect(game.onLeave!(s, 'A')).toBeNull(); expect(game.actionSchema.safeParse({ type: 'leave' }).success).toBe(false); }); test('빠른 말: 라운드당 3번, 옵션을 끄면 거부, 차례 시계는 그대로', () => { let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }); const started = s.turnStartedAt; for (let i = 0; i < 3; i++) s = act(s, 'C', { type: 'say', phrase: 'doubt' }); expect(s.turnStartedAt).toBe(started); expect(reject(s, 'C', { type: 'say', phrase: 'doubt' })).toContain('3번까지'); expect(game.view(s, 'C').said.length).toBe(3); expect(game.view(s, 'C').my!.canSay).toBe(false); s = offer(s, 'A', 'rat', 'B', 'rat'); s = act(s, 'B', { type: 'call', call: 'true' }); // next round resets the count expect(reject(s, 'C', { type: 'say', phrase: 'really' })).toBeNull(); const off = rigged({ A: ['rat'], B: ['bat'] }, { quickChat: false }); expect(reject(off, 'A', { type: 'say', phrase: 'really' })).toBe('이 방은 빠른 말을 쓰지 않아요.'); }); }); // ---------------------------------------------------------------- info describe('비공개 정보 (§8.3)', () => { test('미확인 holder·관전자·다른 플레이어 view에 건네는 카드가 없고, 본 사람에게만 보임', () => { let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'], D: ['fly'] }); s = offer(s, 'A', 'rat', 'B', 'fly'); expect(game.view(s, 'A').card).toEqual({ known: true, id: 'rat-0', kind: 'rat' }); for (const v of ['B', 'C', 'D', null]) { expect(game.view(s, v).card).toEqual({ known: false }); expect(JSON.stringify(game.view(s, v))).not.toContain('"rat-0"'); } s = act(s, 'B', { type: 'peek' }); expect(game.view(s, 'B').card).toEqual({ known: true, id: 'rat-0', kind: 'rat' }); expect(game.view(s, 'C').card).toEqual({ known: false }); expect(game.view(s, null).round.seen).toEqual(['A', 'B']); expect(game.view(s, 'C').myHand).toEqual([{ kind: 'toad', ids: ['toad-0'] }]); expect(JSON.stringify(game.view(s, 'C'))).not.toContain('"fly-0"'); expect(game.view(s, null).myHand).toBeNull(); expect(game.view(s, null).my).toBeNull(); }); test('2인 제거 카드는 진행 중 비공개, 종료 후 전체 손패와 함께 공개', () => { let s = setup({}, ['A', 'B'], 3); for (const v of ['A', 'B', null]) { const j = JSON.stringify(game.view(s, v)); for (const id of s.removed) expect(j).not.toContain(JSON.stringify(id)); expect(game.view(s, v).removedCount).toBe(10); } s = act(s, 'A', { type: 'leave' }); const v = game.view(s, null); expect(v.revealed!.removed).toEqual(s.removed); expect(v.revealed!.hands.B).toEqual(s.hands.B!); expect(game.secretsOf!(s, 'A')).toEqual([]); }); test('claimHistory=off: 주장 기록은 숨기고 현재 주장만', () => { let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] }, { claimHistory: false }); s = offer(s, 'A', 'rat', 'B', 'fly'); const v = game.view(s, 'C'); expect(v.round.claims).toEqual([]); expect(v.round.claim).toBe('fly'); expect(game.view(rigged({ A: ['rat'], B: ['bat'] }), 'A').round.claims).toEqual([]); }); test('무작위 진행 중 모든 view가 비공개 정보를 숨기고 이벤트에도 미공개 카드가 없음', () => { for (let seed = 1; seed <= 150; seed++) { const n = 2 + (seed % 5); const players = Array.from({ length: n }, (_, i) => `P${i}`); const r = SeededRng.fromSeed(seedN(seed)); const chooser = SeededRng.fromSeed(seedN(seed + 77)); let s = setup({ twoPlayerPass: seed % 3 === 0, claimHistory: seed % 4 !== 0 }, players, seed); for (let step = 0; step < 600 && !s.final; step++) { for (const viewer of [...players, null]) checkViewShape(s, game.view(s, viewer), viewer); const actor = game.activePlayers(s)[0]!; const legal = game.legalActions!(s, actor); const a = chooser.int(5) === 0 ? game.onTimeout(s, actor) : legal[chooser.int(legal.length)]!; const out = game.apply(s, actor, a, { rng: r, now: step * 1000 }); if (!out.state.final) { const hidden = [...Object.values(out.state.hands).flat(), ...out.state.removed, ...(out.state.round.card ? [out.state.round.card] : [])]; for (const e of out.events) { const ej = JSON.stringify(e); for (const id of hidden) if (ej.includes(JSON.stringify(id))) throw new Error(`event ${e.type} leaks ${id}`); } } s = out.state; } expect(s.final).not.toBeNull(); } }, 30_000); }); // ---------------------------------------------------------------- schema describe('옵션·스키마', () => { test('모든 옵션에 한국어 메타; 기본값이 문서와 같음', () => { const shape = (game.optionsSchema as unknown as { shape: Record }).shape; for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy(); expect(game.defaultOptions).toEqual({ loseCount: 'auto', turnSeconds: 30, claimHistory: true, quickChat: true, eightKindsRule: false, twoPlayerPass: false }); expect(game.optionsSchema.safeParse({ loseCount: 6 }).success).toBe(false); expect(game.optionsSchema.safeParse({ turnSeconds: 20 }).success).toBe(false); }); test('클라이언트 행동만 스키마에 있음', () => { expect(game.actionSchema.safeParse({ type: 'offer', cardId: 'rat-0', to: 'B', claim: 'rat' }).success).toBe(true); expect(game.actionSchema.safeParse({ type: 'offer', cardId: 'rat-0', to: 'B', claim: 'dragon' }).success).toBe(false); expect(game.actionSchema.safeParse({ type: 'call', call: true }).success).toBe(false); expect(game.actionSchema.safeParse({ type: 'peek' }).success).toBe(true); expect(game.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false); }); }); // ---------------------------------------------------------------- properties function cfg(i: number): { players: string[]; options: CockroachOptions } { return { players: Array.from({ length: 2 + (i % 5) }, (_, k) => `P${k}`), options: game.optionsSchema.parse({ loseCount: (['auto', 3, 4, 5] as const)[i % 4], claimHistory: i % 3 !== 0, eightKindsRule: i % 7 === 0, twoPlayerPass: i % 2 === 0, turnSeconds: ([15, 30, 60] as const)[i % 3], }), }; } function invariant(st: unknown): void { checkInvariants(st); const s = st as CockroachState; for (const p of game.timeoutPlayers!(s)) { const v = game.validate(s, p, game.onTimeout(s, p)); if (!v.ok) throw new Error(`onTimeout illegal: ${v.reason}`); const c = game.validate(s, p, autoAction(s, p, rng())); if (!c.ok) throw new Error(`auto action illegal: ${c.reason}`); } } describe('무작위 대국 (docs/09 §7)', () => { test('engine randomPlayout, seeds 1–200: 끝남, 카드 보존, 유출 없음, onTimeout 합법, 결정성', () => { for (let i = 1; i <= 200; i++) { const { players, options } = cfg(i); const r = randomPlayout(game, { players, options, seed: seedN(i), maxSteps: 2000, invariant, maxApplyMs: 50 }); expect(r.finished).toBe(true); const res = game.result(r.finalState as CockroachState)!; expect(res.ranking.flat().sort()).toEqual([...players].sort()); expect(res.ranking[1]!.length).toBe(1); if (i % 40 === 1) { const b = randomPlayout(game, { players, options, seed: seedN(i), maxSteps: 2000, maxApplyMs: 50 }); expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(r.finalState)); expect(b.trace).toEqual(r.trace); } } }, 60_000); test('fast loop, seeds 201–1000 (+ timeouts): 끝남, 카드 보존, 상태 JSON 왕복, 같은 로그 재생 = 같은 결과', () => { let maxSteps = 0; for (let i = 201; i <= 1000; i++) { const { players, options } = cfg(i); const r = SeededRng.fromSeed(seedN(i)); const chooser = SeededRng.fromSeed(seedN(i + 5000)); let s = game.setup({ players, options, rng: r, now: 0 }); const log: { actor: string; action: CockroachAction }[] = []; let step = 0; for (; step < 2000 && !s.final; step++) { const actor = game.activePlayers(s)[0]!; const legal = game.legalActions!(s, actor); const a = chooser.int(10) === 0 ? game.onTimeout(s, actor) : legal[chooser.int(legal.length)]!; const v = game.validate(s, actor, a); if (!v.ok) throw new Error(`seed ${i}: ${v.reason}`); s = game.apply(s, actor, a, { rng: r, now: step * 1000 }).state; log.push({ actor, action: a }); checkInvariants(s); } maxSteps = Math.max(maxSteps, step); expect(s.final).not.toBeNull(); expect(JSON.parse(JSON.stringify(s))).toEqual(s); if (i % 50 === 0) { // Replay: same seed + same action log → identical state. const r2 = SeededRng.fromSeed(seedN(i)); let t = game.setup({ players, options, rng: r2, now: 0 }); log.forEach(({ actor, action }, k) => (t = game.apply(t, actor, action, { rng: r2, now: k * 1000 }).state)); expect(JSON.stringify(t)).toBe(JSON.stringify(s)); } } expect(maxSteps).toBeLessThan(2000); }, 60_000); });