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>
This commit is contained in:
604
packages/games/src/cockroach-poker/cockroach-poker.test.ts
Normal file
604
packages/games/src/cockroach-poker/cockroach-poker.test.ts
Normal file
@@ -0,0 +1,604 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user