/** * 바퀴벌레 포커 (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 = { 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 = { 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; // ---------------------------------------------------------------- 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; /** 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; // ---------------------------------------------------------------- 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>; summary: string; } export interface Said { seq: number; player: PlayerId; phrase: Phrase; } export interface CockroachState { options: CockroachOptions; loseCount: number; order: PlayerId[]; hands: Record; fronts: Record; /** 2-player game: 10 cards removed unseen (secret until the end). */ removed: string[]; round: CockroachRound; lastReveal: LastReveal | null; timeoutsInRow: Record; away: Record; said: Said[]; sayCount: Record; turnStartedAt: number; seq: number; final: CockroachFinal | null; } // ---------------------------------------------------------------- view export type DangerLevel = 'ok' | 'warn' | 'danger'; export interface FrontView { counts: Record; /** Kinds with at least one card, most first (ties in KINDS order). */ kinds: Kind[]; level: Record; 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; fronts: Record; /** 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; 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; 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 { const out = Object.fromEntries(KINDS.map((k) => [k, 0])) as Record; 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 = { id: 'cockroach-poker', nameKo: '바퀴벌레 포커', minPlayers: 2, maxPlayers: 6, stateVersion: 1, defaultOptions: cockroachOptionsSchema.parse({}), optionsSchema: cockroachOptionsSchema as unknown as z.ZodType, actionSchema: cockroachActionSchema as unknown as z.ZodType, 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 = (f: (p: PlayerId) => T) => Object.fromEntries(players.map((p) => [p, f(p)])) as Record; const hands = per(() => []); // 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 = {}; 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; 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(); 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; }, };