Merge branch 'm7/cockroach-poker' into codex/owner/grp_discord_joke-app

This commit is contained in:
EJClaw
2026-10-05 14:56:12 +09:00
8 changed files with 2422 additions and 0 deletions

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

View File

@@ -0,0 +1,650 @@
/**
* 바퀴벌레 포커 (docs/games/cockroach-poker.md).
*
* Secret-id design: card ids ('rat-3', ...) encode the kind, so an id only ever appears in a view when the
* viewer may know that card (own hand, face-up fronts, the passed card for players in `seen`, everything
* after the game ends). Other hands are sent as counts; the passed card as `{ known: false }`.
*/
import { z } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
export const KINDS = ['cockroach', 'bat', 'fly', 'toad', 'rat', 'scorpion', 'spider', 'stinkbug'] as const;
export type Kind = (typeof KINDS)[number];
export const KIND_KO: Record<Kind, string> = {
cockroach: '바퀴벌레',
bat: '박쥐',
fly: '파리',
toad: '두꺼비',
rat: '쥐',
scorpion: '전갈',
spider: '거미',
stinkbug: '노린재',
};
export const PER_KIND = 8;
export const REMOVED_2P = 10;
export const PHRASES = ['really', 'doubt', 'hmm', 'trust'] as const;
export type Phrase = (typeof PHRASES)[number];
export const PHRASE_KO: Record<Phrase, string> = {
really: '정말이야!',
doubt: '의심스러운데?',
hmm: '음… 고민되네',
trust: '믿어 볼게',
};
/** Quick-chat phrases per player per round (keeps the log small). */
export const SAY_PER_ROUND = 3;
const AWAY_AFTER = 3;
const AWAY_MS = 5000;
const SAID_KEEP = 6;
// ---------------------------------------------------------------- options
export const cockroachOptionsSchema = z.object({
loseCount: z
.union([z.literal('auto'), z.literal(3), z.literal(4), z.literal(5)])
.default('auto')
.meta({ title: '같은 벌레가 몇 장 모이면 패배', labels: { auto: '자동(2명 5장, 3~6명 4장)', 3: '3장(짧은 게임)', 4: '4장', 5: '5장' } }),
turnSeconds: z
.union([z.literal(15), z.literal(30), z.literal(60)])
.default(30)
.meta({ title: '건네기·판정 제한 시간', labels: { 15: '15초', 30: '30초', 60: '60초' } }),
claimHistory: z.boolean().default(true).meta({ title: '이번 라운드 주장 기록 보이기' }),
quickChat: z.boolean().default(true).meta({ title: '빠른 말 버튼("정말이야!", "의심스러운데?")' }),
eightKindsRule: z.boolean().default(false).meta({ title: '앞마당에 8종을 모두 모아도 패배(하우스 룰)' }),
twoPlayerPass: z.boolean().default(false).meta({ title: '2명일 때도 몰래 보고 되돌려 주기 허용(하우스 룰)' }),
});
export type CockroachOptions = z.infer<typeof cockroachOptionsSchema>;
// ---------------------------------------------------------------- actions
const kindSchema = z.enum(KINDS);
export const cockroachActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('offer'), cardId: z.string().max(32), to: z.string().max(64), claim: kindSchema }),
z.object({ type: z.literal('call'), call: z.enum(['true', 'false']) }),
z.object({ type: z.literal('peek') }),
z.object({ type: z.literal('pass'), to: z.string().max(64), claim: kindSchema }),
z.object({ type: z.literal('say'), phrase: z.enum(PHRASES) }),
]);
export type CockroachClientAction = z.infer<typeof cockroachActionSchema>;
/** Client actions plus system-only actions (timer / leaving), which are not in the schema. */
export type CockroachAction = CockroachClientAction | { type: 'timeout' } | { type: 'leave' };
type TurnAction = Extract<CockroachClientAction, { type: 'offer' | 'call' | 'peek' | 'pass' }>;
// ---------------------------------------------------------------- state
export type LossReason = 'four' | 'emptyHand' | 'eight' | 'left';
export interface ClaimEntry {
from: PlayerId;
to: PlayerId;
claim: Kind;
auto: boolean;
}
export interface CockroachRound {
no: number;
starter: PlayerId;
/** Card being passed (null while the starter is choosing). */
card: string | null;
/** Current receiver who must decide (null while the starter is choosing). */
holder: PlayerId | null;
lastPasser: PlayerId | null;
claim: Kind | null;
/** Players who know the passed card: the starter plus every holder who peeked. */
seen: PlayerId[];
/** The holder has looked at the card and must pass it on. */
peeked: boolean;
claims: ClaimEntry[];
}
export interface LastReveal {
card: string;
kind: Kind;
claim: Kind;
call: 'true' | 'false';
caller: PlayerId;
passer: PlayerId;
correct: boolean;
takenBy: PlayerId;
auto: boolean;
round: number;
}
export interface CockroachFinal {
loser: PlayerId;
reason: LossReason;
losingKind: Kind | null;
/** Detailed placings (§6): winners by fewest same-kind cards, then fewest front cards; loser last. */
placings: PlayerId[][];
fronts: Record<PlayerId, Record<Kind, number>>;
summary: string;
}
export interface Said {
seq: number;
player: PlayerId;
phrase: Phrase;
}
export interface CockroachState {
options: CockroachOptions;
loseCount: number;
order: PlayerId[];
hands: Record<PlayerId, string[]>;
fronts: Record<PlayerId, string[]>;
/** 2-player game: 10 cards removed unseen (secret until the end). */
removed: string[];
round: CockroachRound;
lastReveal: LastReveal | null;
timeoutsInRow: Record<PlayerId, number>;
away: Record<PlayerId, boolean>;
said: Said[];
sayCount: Record<PlayerId, number>;
turnStartedAt: number;
seq: number;
final: CockroachFinal | null;
}
// ---------------------------------------------------------------- view
export type DangerLevel = 'ok' | 'warn' | 'danger';
export interface FrontView {
counts: Record<Kind, number>;
/** Kinds with at least one card, most first (ties in KINDS order). */
kinds: Kind[];
level: Record<Kind, DangerLevel>;
total: number;
/** Distinct kinds (for the 8-kinds house rule). */
distinct: number;
}
export interface CockroachView {
options: CockroachOptions;
loseCount: number;
me: PlayerId | null;
order: PlayerId[];
phase: 'offer' | 'respond' | 'finished';
handCounts: Record<PlayerId, number>;
fronts: Record<PlayerId, FrontView>;
/** Viewer's hand ids grouped by kind (null for spectators). */
myHand: { kind: Kind; ids: string[] }[] | null;
round: {
no: number;
starter: PlayerId;
holder: PlayerId | null;
lastPasser: PlayerId | null;
claim: Kind | null;
seen: PlayerId[];
peeked: boolean;
/** Empty when claimHistory is off. */
claims: ClaimEntry[];
passes: number;
};
/** The passed card: id/kind only for players who have seen it. */
card: { known: true; id: string; kind: Kind } | { known: false } | null;
/** What the viewer can do right now (null for spectators / when it is not the viewer's move). */
my: {
canOffer: boolean;
offerTargets: PlayerId[];
canCall: boolean;
canPeek: boolean;
peekBlocked: string | null;
canPass: boolean;
passTargets: PlayerId[];
canSay: boolean;
} | null;
lastReveal: LastReveal | null;
away: Record<PlayerId, boolean>;
said: Said[];
removedCount: number;
turnStartedAt: number;
turnMs: number;
seq: number;
final: CockroachFinal | null;
/** After the game: every hand and the removed cards. */
revealed: { hands: Record<PlayerId, string[]>; removed: string[] } | null;
}
// ---------------------------------------------------------------- cards
export const kindOf = (id: string): Kind => {
const k = id.slice(0, id.lastIndexOf('-')) as Kind;
if (!KINDS.includes(k)) throw new Error(`cockroach-poker: bad card id ${id}`);
return k;
};
export const allCardIds = (): string[] => KINDS.flatMap((k) => Array.from({ length: PER_KIND }, (_, i) => `${k}-${i}`));
const isKind = (k: unknown): k is Kind => typeof k === 'string' && (KINDS as readonly string[]).includes(k);
export function loseCountFor(o: CockroachOptions, players: number): number {
return o.loseCount === 'auto' ? (players === 2 ? 5 : 4) : o.loseCount;
}
/** Hand sizes dealt one at a time from the first player (§4 table). */
export function handSizes(players: number): number[] {
const total = players === 2 ? KINDS.length * PER_KIND - REMOVED_2P : KINDS.length * PER_KIND;
return Array.from({ length: players }, (_, i) => Math.floor(total / players) + (i < total % players ? 1 : 0));
}
export function countKinds(ids: readonly string[]): Record<Kind, number> {
const out = Object.fromEntries(KINDS.map((k) => [k, 0])) as Record<Kind, number>;
for (const id of ids) out[kindOf(id)] += 1;
return out;
}
// ---------------------------------------------------------------- helpers
const finished = (s: CockroachState) => s.final !== null;
/** Seats clockwise starting after `p`. */
function after(s: CockroachState, p: PlayerId): PlayerId[] {
const i = s.order.indexOf(p);
return s.order.map((_, k) => s.order[(i + 1 + k) % s.order.length]!).filter((x) => x !== p);
}
function waiting(s: CockroachState): PlayerId[] {
if (finished(s)) return [];
return [s.round.holder ?? s.round.starter];
}
function passTargets(s: CockroachState, holder: PlayerId): PlayerId[] {
const r = s.round;
if (s.order.length === 2) return s.options.twoPlayerPass && r.claims.length === 1 ? after(s, holder) : [];
return after(s, holder).filter((p) => !r.seen.includes(p));
}
/** Korean reason why the current holder may not peek, or null when peeking is allowed. */
function peekBlock(s: CockroachState): string | null {
const r = s.round;
if (r.peeked) return '이미 카드를 봤어요.';
if (s.order.length === 2) {
if (!s.options.twoPlayerPass) return '2명이 할 때는 몰래 보고 넘기기를 할 수 없어요. 진짜인지 거짓인지 골라 주세요.';
if (r.claims.length > 1) return '되돌려 받은 카드는 다시 넘길 수 없어요. 진짜인지 거짓인지 골라 주세요.';
return null;
}
return passTargets(s, r.holder!).length === 0 ? '모두 이 카드를 이미 봐서 넘길 사람이 없어요. 진짜인지 거짓인지 골라 주세요.' : null;
}
function turnMs(s: CockroachState): number {
const p = waiting(s)[0];
return p && s.away[p] ? AWAY_MS : s.options.turnSeconds * 1000;
}
function newRound(s: CockroachState, starter: PlayerId): void {
s.round = { no: s.round.no + 1, starter, card: null, holder: null, lastPasser: null, claim: null, seen: [], peeked: false, claims: [] };
s.sayCount = Object.fromEntries(s.order.map((p) => [p, 0]));
}
function placings(s: CockroachState, loser: PlayerId): PlayerId[][] {
const key = (p: PlayerId) => {
const c = countKinds(s.fronts[p]!);
return [Math.max(...Object.values(c)), s.fronts[p]!.length] as const;
};
const winners = s.order.filter((p) => p !== loser).sort((a, b) => {
const [ma, ta] = key(a);
const [mb, tb] = key(b);
return ma - mb || ta - tb;
});
const groups: PlayerId[][] = [];
for (const p of winners) {
const last = groups[groups.length - 1];
if (last && key(last[0]!)[0] === key(p)[0] && key(last[0]!)[1] === key(p)[1]) last.push(p);
else groups.push([p]);
}
groups.push([loser]);
return groups;
}
function finish(s: CockroachState, loser: PlayerId, reason: LossReason, losingKind: Kind | null, events: GameEvent[]): void {
let summary: string;
switch (reason) {
case 'four':
summary = `앞마당에 ${KIND_KO[losingKind!]} ${s.loseCount}장이 모였어요! 그 사람이 지고, 나머지 모두 이겼어요`;
break;
case 'eight':
summary = '앞마당에 벌레 8종이 모두 모였어요! 그 사람이 지고, 나머지 모두 이겼어요';
break;
case 'emptyHand':
summary = '카드를 건넬 차례인데 손에 카드가 없어서 졌어요. 나머지 모두 이겼어요';
break;
case 'left':
summary = '한 사람이 게임을 나가서 끝났어요. 나머지 모두 이겼어요';
break;
}
s.final = {
loser,
reason,
losingKind,
placings: placings(s, loser),
fronts: Object.fromEntries(s.order.map((p) => [p, countKinds(s.fronts[p]!)])),
summary,
};
events.push({ type: 'gameEnded', loser, reason, ...(losingKind ? { kind: losingKind } : {}) });
}
/** The concrete move a timeout stands for (§8.5). Uses the RNG for the starter / unpeeked holder. */
export function autoAction(s: CockroachState, player: PlayerId, rng: Rng): TurnAction {
const r = s.round;
if (r.holder === null) {
const hand = s.hands[player]!;
return { type: 'offer', cardId: hand[rng.int(hand.length)]!, to: after(s, player)[0]!, claim: KINDS[rng.int(KINDS.length)]! };
}
if (!r.peeked) return { type: 'call', call: rng.int(2) === 0 ? 'true' : 'false' };
return { type: 'pass', to: passTargets(s, player)[0]!, claim: kindOf(r.card!) };
}
function validateTurn(s: CockroachState, actor: PlayerId, a: TurnAction): Validation {
const r = s.round;
switch (a.type) {
case 'offer': {
if (r.holder !== null) return fail('지금은 카드를 건넬 때가 아니에요.');
if (r.starter !== actor) return fail('지금은 내 차례가 아니에요.');
if (!s.hands[actor]!.includes(a.cardId)) return fail('내 손에 없는 카드예요.');
if (a.to === actor) return fail('나에게는 건넬 수 없어요.');
if (!s.order.includes(a.to)) return fail('이 게임의 플레이어가 아니에요.');
return isKind(a.claim) ? ok : fail('벌레 종류를 골라 주세요.');
}
case 'call':
if (r.holder !== actor) return fail('지금은 내가 판정할 차례가 아니에요.');
if (r.peeked) return fail('카드를 이미 봤으니 다른 사람에게 넘겨야 해요.');
return a.call === 'true' || a.call === 'false' ? ok : fail('진짜인지 거짓인지 골라 주세요.');
case 'peek': {
if (r.holder !== actor) return fail('지금은 내가 카드를 받을 차례가 아니에요.');
const why = peekBlock(s);
return why ? fail(why) : ok;
}
case 'pass': {
if (r.holder !== actor) return fail('지금은 내가 카드를 넘길 차례가 아니에요.');
if (!r.peeked) return fail('먼저 카드를 몰래 봐야 넘길 수 있어요.');
if (a.to === actor) return fail('나에게는 넘길 수 없어요.');
if (!s.order.includes(a.to)) return fail('이 게임의 플레이어가 아니에요.');
if (!passTargets(s, actor).includes(a.to)) return fail('이 카드를 이미 본 사람에게는 넘길 수 없어요.');
return isKind(a.claim) ? ok : fail('벌레 종류를 골라 주세요.');
}
}
}
function applyTurn(s: CockroachState, actor: PlayerId, a: TurnAction, events: GameEvent[], auto: boolean): void {
const r = s.round;
switch (a.type) {
case 'offer': {
const hand = s.hands[actor]!;
hand.splice(hand.indexOf(a.cardId), 1);
r.card = a.cardId;
r.holder = a.to;
r.lastPasser = actor;
r.claim = a.claim;
r.seen = [actor];
r.peeked = false;
r.claims.push({ from: actor, to: a.to, claim: a.claim, auto });
events.push({ type: 'offer', from: actor, to: a.to, claim: a.claim, auto });
break;
}
case 'peek':
if (!r.seen.includes(actor)) r.seen.push(actor);
r.peeked = true;
events.push({ type: 'peek', player: actor });
break;
case 'pass':
r.lastPasser = actor;
r.holder = a.to;
r.claim = a.claim;
r.peeked = false;
r.claims.push({ from: actor, to: a.to, claim: a.claim, auto });
events.push({ type: 'pass', from: actor, to: a.to, claim: a.claim, auto });
break;
case 'call': {
const card = r.card!;
const kind = kindOf(card);
const truth = kind === r.claim;
const correct = (a.call === 'true') === truth;
const takenBy = correct ? r.lastPasser! : actor;
s.fronts[takenBy]!.push(card);
r.card = null;
r.holder = null;
s.lastReveal = { card, kind, claim: r.claim!, call: a.call, caller: actor, passer: r.lastPasser!, correct, takenBy, auto, round: r.no };
events.push({ type: 'reveal', card, kind, claim: r.claim, call: a.call, caller: actor, correct, takenBy, auto });
const counts = countKinds(s.fronts[takenBy]!);
if (counts[kind] >= s.loseCount) finish(s, takenBy, 'four', kind, events);
else if (s.options.eightKindsRule && KINDS.every((k) => counts[k] > 0)) finish(s, takenBy, 'eight', null, events);
else {
newRound(s, takenBy);
if (s.hands[takenBy]!.length === 0) finish(s, takenBy, 'emptyHand', null, events);
else events.push({ type: 'round', starter: takenBy });
}
break;
}
}
}
// ---------------------------------------------------------------- definition
export const cockroachPoker: GameDefinition<CockroachState, CockroachAction, CockroachView, CockroachOptions> = {
id: 'cockroach-poker',
nameKo: '바퀴벌레 포커',
minPlayers: 2,
maxPlayers: 6,
stateVersion: 1,
defaultOptions: cockroachOptionsSchema.parse({}),
optionsSchema: cockroachOptionsSchema as unknown as z.ZodType<CockroachOptions>,
actionSchema: cockroachActionSchema as unknown as z.ZodType<CockroachAction>,
setup({ players, options, rng, now }) {
if (players.length < 2 || players.length > 6) throw new Error(`cockroach-poker: ${players.length} players not allowed`);
const deck = rng.shuffle(allCardIds());
const removed = players.length === 2 ? deck.splice(0, REMOVED_2P) : [];
const first = players[rng.int(players.length)]!;
const per = <T>(f: (p: PlayerId) => T) => Object.fromEntries(players.map((p) => [p, f(p)])) as Record<PlayerId, T>;
const hands = per<string[]>(() => []);
// Deal one at a time clockwise from the first player, so the first player gets the most (§4).
const fi = players.indexOf(first);
deck.forEach((id, i) => hands[players[(fi + i) % players.length]!]!.push(id));
return {
options,
loseCount: loseCountFor(options, players.length),
order: [...players],
hands,
fronts: per(() => []),
removed,
round: { no: 1, starter: first, card: null, holder: null, lastPasser: null, claim: null, seen: [], peeked: false, claims: [] },
lastReveal: null,
timeoutsInRow: per(() => 0),
away: per(() => false),
said: [],
sayCount: per(() => 0),
turnStartedAt: now,
seq: 0,
final: null,
};
},
validate(s, actor, a) {
if (!s.order.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
if (finished(s)) return fail('게임이 이미 끝났어요.');
switch (a.type) {
case 'leave':
return ok;
case 'timeout':
return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.');
case 'say':
if (!s.options.quickChat) return fail('이 방은 빠른 말을 쓰지 않아요.');
if (!PHRASES.includes(a.phrase)) return fail('보낼 말을 골라 주세요.');
return (s.sayCount[actor] ?? 0) >= SAY_PER_ROUND ? fail(`빠른 말은 한 라운드에 ${SAY_PER_ROUND}번까지 할 수 있어요.`) : ok;
default:
return validateTurn(s, actor, a);
}
},
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const events: GameEvent[] = [];
s.seq += 1;
switch (a.type) {
case 'leave':
events.push({ type: 'left', player: actor });
finish(s, actor, 'left', null, events);
break;
case 'say':
s.sayCount[actor] = (s.sayCount[actor] ?? 0) + 1;
s.said = [...s.said, { seq: s.seq, player: actor, phrase: a.phrase }].slice(-SAID_KEEP);
events.push({ type: 'say', player: actor, phrase: a.phrase });
// Chatting does not restart the turn clock.
return { state: s, events };
case 'timeout': {
const concrete = autoAction(s, actor, rng);
const v = validateTurn(s, actor, concrete);
if (!v.ok) throw new Error(`cockroach-poker: auto action rejected: ${v.reason}`);
s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1;
events.push({ type: 'timeout', player: actor });
if (s.timeoutsInRow[actor]! >= AWAY_AFTER && !s.away[actor]) {
s.away[actor] = true;
events.push({ type: 'away', player: actor });
}
applyTurn(s, actor, concrete, events, true);
break;
}
default:
s.timeoutsInRow[actor] = 0;
s.away[actor] = false;
applyTurn(s, actor, a, events, false);
}
s.turnStartedAt = now;
return { state: s, events };
},
view(s, viewer) {
const me = viewer && s.order.includes(viewer) ? viewer : null;
const over = finished(s);
const r = s.round;
const fronts: Record<PlayerId, FrontView> = {};
for (const p of s.order) {
const counts = countKinds(s.fronts[p]!);
const kinds = KINDS.filter((k) => counts[k] > 0).sort((a, b) => counts[b] - counts[a] || KINDS.indexOf(a) - KINDS.indexOf(b));
const level = Object.fromEntries(
KINDS.map((k) => [k, counts[k] >= s.loseCount - 1 ? 'danger' : counts[k] >= Math.max(2, s.loseCount - 2) ? 'warn' : 'ok']),
) as Record<Kind, DangerLevel>;
fronts[p] = { counts, kinds, level, total: s.fronts[p]!.length, distinct: kinds.length };
}
let myHand: CockroachView['myHand'] = null;
if (me) {
const ids = s.hands[me]!;
myHand = KINDS.map((kind) => ({ kind, ids: ids.filter((id) => kindOf(id) === kind).sort() })).filter((g) => g.ids.length > 0);
}
let card: CockroachView['card'] = null;
if (r.card && !over) card = me && r.seen.includes(me) ? { known: true, id: r.card, kind: kindOf(r.card) } : { known: false };
let my: CockroachView['my'] = null;
if (me && !over) {
const offering = r.holder === null && r.starter === me;
const holding = r.holder === me;
const why = holding ? peekBlock(s) : null;
my = {
canOffer: offering,
offerTargets: offering ? after(s, me) : [],
canCall: holding && !r.peeked,
canPeek: holding && why === null,
peekBlocked: holding && !r.peeked ? why : null,
canPass: holding && r.peeked,
passTargets: holding && r.peeked ? passTargets(s, me) : [],
canSay: s.options.quickChat && (s.sayCount[me] ?? 0) < SAY_PER_ROUND,
};
}
return {
options: s.options,
loseCount: s.loseCount,
me,
order: s.order,
phase: over ? 'finished' : r.holder === null ? 'offer' : 'respond',
handCounts: Object.fromEntries(s.order.map((p) => [p, s.hands[p]!.length])),
fronts,
myHand,
round: {
no: r.no,
starter: r.starter,
holder: r.holder,
lastPasser: r.lastPasser,
claim: r.claim,
seen: [...r.seen],
peeked: r.peeked,
claims: s.options.claimHistory ? r.claims.map((c) => ({ ...c })) : [],
passes: Math.max(0, r.claims.length - 1),
},
card,
my,
lastReveal: s.lastReveal,
away: s.away,
said: s.said,
removedCount: s.removed.length,
turnStartedAt: s.turnStartedAt,
turnMs: turnMs(s),
seq: s.seq,
final: s.final,
revealed: over ? { hands: s.hands, removed: s.removed } : null,
};
},
activePlayers: waiting,
deadline(s) {
return finished(s) ? null : s.turnStartedAt + turnMs(s);
},
timeoutPlayers: waiting,
onTimeout() {
return { type: 'timeout' };
},
/** Leaving forfeits: the leaver becomes the one loser and everyone else wins. */
onLeave(s, player) {
if (!s.order.includes(player) || finished(s)) return null;
return { type: 'leave' };
},
result(s): GameResult | null {
const f = s.final;
if (!f) return null;
// Everyone but the loser wins together (§6); the detailed placings stay in the view.
return {
ranking: [s.order.filter((p) => p !== f.loser), [f.loser]],
scores: Object.fromEntries(s.order.map((p) => [p, p === f.loser ? 0 : 1])),
summary: f.summary,
reason: f.reason === 'left' ? 'resign' : 'normal',
};
},
/** Game moves only (quick chat is left out); same-kind cards are interchangeable, so one id per kind. */
legalActions(s, p) {
if (!s.order.includes(p) || finished(s)) return [];
const r = s.round;
const out: CockroachAction[] = [];
if (r.holder === null) {
if (r.starter !== p) return [];
const seenKinds = new Set<Kind>();
for (const id of s.hands[p]!) {
const k = kindOf(id);
if (seenKinds.has(k)) continue;
seenKinds.add(k);
for (const to of after(s, p)) for (const claim of KINDS) out.push({ type: 'offer', cardId: id, to, claim });
}
return out;
}
if (r.holder !== p) return [];
if (r.peeked) {
for (const to of passTargets(s, p)) for (const claim of KINDS) out.push({ type: 'pass', to, claim });
return out;
}
out.push({ type: 'call', call: 'true' }, { type: 'call', call: 'false' });
if (peekBlock(s) === null) out.push({ type: 'peek' });
return out;
},
/** `player`'s hand, the passed card while only they have seen it, and the removed cards (secret to all). */
secretsOf(s, player) {
if (finished(s)) return [];
const out = [...(s.hands[player] ?? []), ...s.removed];
const r = s.round;
if (r.card && r.seen.length === 1 && r.seen[0] === player) out.push(r.card);
return out;
},
};

View File

@@ -20,6 +20,7 @@ import { dalmuti } from './dalmuti';
import { splendor } from './splendor';
import { quoridor } from './quoridor';
import { lasVegas } from './las-vegas';
import { cockroachPoker } from './cockroach-poker';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -43,4 +44,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[splendor.id]: splendor,
[quoridor.id]: quoridor,
[lasVegas.id]: lasVegas,
[cockroachPoker.id]: cockroachPoker,
};