Files
joke-app/packages/games/src/cockroach-poker/cockroach-poker.test.ts
EJClaw 3e414e62ca M7: 바퀴벌레 포커 규칙·화면·테스트
- 규칙 모듈(packages/games/src/cockroach-poker): 64장(8종×8) 셔플·좌석순 분배, 2인 10장 비공개 제거,
  offer/call/peek/pass 흐름, seen 규칙, 2인 판정 전용(twoPlayerPass 하우스 룰은 1회 되돌려 주기),
  같은 종류 loseCount장(자동: 2인 5, 3~6인 4)·손패 0장 시작자·eightKindsRule 패배,
  시간 초과 자동 행동(§8.5)과 3연속 시간 초과 시 자리 비움(5초), 나가면 그 사람 패배,
  빠른 말(say, 라운드당 3번), view는 남의 손패·미확인자에게 건네는 카드·제거 카드를 숨김
- 테스트: §10 시나리오 전부, randomPlayout 200판 + 빠른 루프 800판(카드 보존·유출·onTimeout 합법·재생 결정성)
- 화면(apps/web/src/games/cockroach-poker): 자체 SVG 벌레 8종, 앞마당 종류별 배지(경고/위험),
  건네기 3단계(카드→사람→주장), 판정 시트(진짜!/거짓말!/몰래 보고 넘기기), 넘긴 순서·본 사람 표시
- 등록: GAMES, CATALOG, 웹 registry, GAME_ICON / E2E 플랜(PC 방장 + 휴대폰 2명, 끝까지 진행)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:46:30 +09:00

605 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<CockroachOptions> = {}, 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<string, Kind[]>, opts: Partial<CockroachOptions> = {}, fronts: Record<string, Kind[]> = {}, starter?: string): CockroachState {
const players = Object.keys(hands);
const s = setup(opts, players);
const next: Record<Kind, number> = Object.fromEntries(KINDS.map((k) => [k, 0])) as Record<Kind, number>;
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<number, number[]> = { 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<string, { meta(): { title?: string } | undefined }> }).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);
});