diff --git a/apps/web/src/games/cockroach-poker/index.tsx b/apps/web/src/games/cockroach-poker/index.tsx new file mode 100644 index 0000000..faa2f89 --- /dev/null +++ b/apps/web/src/games/cockroach-poker/index.tsx @@ -0,0 +1,1059 @@ +/** 바퀴벌레 포커 화면 (docs/games/cockroach-poker.md §9). Critter art is original inline SVG. */ +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import type { ClaimEntry, CockroachOptions, CockroachView, FrontView, Kind, LastReveal, Phrase } from '@bg/games/cockroach-poker'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar } from '../../components/ui'; +import { TurnTimer } from '../../components/TurnTimer'; +import { haptic, sounds } from '../../lib/feedback'; +import type { BoardProps, GameUI, OptionsProps } from '../types'; + +// Kept local (type-only import above) so the screen chunk does not pull in the rules module + zod. +const KINDS: Kind[] = ['cockroach', 'bat', 'fly', 'toad', 'rat', 'scorpion', 'spider', 'stinkbug']; +const KIND_KO: Record = { + cockroach: '바퀴벌레', + bat: '박쥐', + fly: '파리', + toad: '두꺼비', + rat: '쥐', + scorpion: '전갈', + spider: '거미', + stinkbug: '노린재', +}; +const PHRASES: Phrase[] = ['really', 'doubt', 'hmm', 'trust']; +const PHRASE_KO: Record = { really: '정말이야!', doubt: '의심스러운데?', hmm: '음… 고민되네', trust: '믿어 볼게' }; + +const nick = (players: Record, id: string) => players[id]?.nickname ?? '알 수 없음'; +const userOf = (players: Record, id: string): PublicUser => + players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' }; +const hasBatchim = (word: string) => { + const c = word.charCodeAt(word.length - 1); + return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 !== 0; +}; +/** "이건 쥐야!" / "이건 전갈이야!" */ +const claimLine = (k: Kind) => `이건 ${KIND_KO[k]}${hasBatchim(KIND_KO[k]) ? '이야' : '야'}!`; +/** Korean particle after a word: j('전갈', '이라고', '라고') → '전갈이라고'. */ +const j = (word: string, withBatchim: string, without: string) => `${word}${hasBatchim(word) ? withBatchim : without}`; +const kj = (k: Kind, withBatchim: string, without: string) => j(KIND_KO[k], withBatchim, without); + +function waitingOn(v: CockroachView): string | null { + if (v.phase === 'finished') return null; + return v.round.holder ?? v.round.starter; +} + +function frontText(f: FrontView, loseCount: number): string { + if (f.total === 0) return '앞마당 비어 있음'; + return `앞마당 ${f.kinds.map((k) => `${KIND_KO[k]} ${f.counts[k]}장${f.counts[k] >= loseCount - 1 ? '(위험)' : ''}`).join(', ')}`; +} + +// ---------------------------------------------------------------- art + +const KIND_COLOR: Record = { + cockroach: { bg: '#f4e4d0', ink: '#7a4a1e' }, + bat: { bg: '#e7def5', ink: '#5b3f8c' }, + fly: { bg: '#dce8f7', ink: '#2f5c93' }, + toad: { bg: '#ddefd6', ink: '#3e7a2e' }, + rat: { bg: '#e6e6ea', ink: '#55555f' }, + scorpion: { bg: '#fbe3cf', ink: '#b4561a' }, + spider: { bg: '#ece6ef', ink: '#2b2b33' }, + stinkbug: { bg: '#d5efec', ink: '#1f6f68' }, +}; + +function Eyes({ x1, x2, y, r = 3.4 }: { x1: number; x2: number; y: number; r?: number }) { + return ( + + + + + + + ); +} + +/** Simple, cute, original critters (64×64). Each has a distinct silhouette so colour is never the only cue. */ +function CritterArt({ kind }: { kind: Kind }) { + const c = KIND_COLOR[kind].ink; + switch (kind) { + case 'cockroach': + return ( + + + + + + + + + ); + case 'bat': + return ( + + + + + + + + ); + case 'fly': + return ( + + + + + + + + + + + + ); + case 'toad': + return ( + + + + + + + + + + + ); + case 'rat': + return ( + + + + + + + + + + + + ); + case 'scorpion': + return ( + + + + + + + + + + + ); + case 'spider': + return ( + + + + + + + + + ); + case 'stinkbug': + return ( + + + + + + + + + ); + } +} + +/** Small icon on a light plate of the kind's colour, so dark critters stay visible in dark mode. */ +function CritterIcon({ kind, size = 28 }: { kind: Kind; size?: number }) { + return ( + + + + + ); +} + +/** A face-up card: tinted card + critter + name. */ +function FaceCard({ kind, size = 'md', fx }: { kind: Kind; size?: 'sm' | 'md' | 'lg'; fx?: string }) { + const col = KIND_COLOR[kind]; + return ( + + + + + {KIND_KO[kind]} + + ); +} + +function BackCard({ size = 'md', fx }: { size?: 'sm' | 'md' | 'lg'; fx?: string }) { + return ( + + ? + + ); +} + +// ---------------------------------------------------------------- styles + +const CSS = ` +.cp-board { width: 100%; max-width: 1040px; max-height: 100%; overflow: auto; display: flex; flex-direction: column; gap: 12px; padding: 2px; } +.cp-seats { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; } +.cp-seat { position: relative; background: var(--surface); border-radius: var(--radius-lg); padding: 9px 11px; box-shadow: var(--shadow); border: 3px solid transparent; display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.cp-seat.turn { border-color: var(--focus); } +.cp-seat.loser { border-color: var(--danger); } +.cp-head { display: flex; align-items: center; gap: 7px; min-height: 30px; min-width: 0; } +.cp-name { font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; } +.cp-badges { display: flex; gap: 4px; flex-wrap: wrap; } +.cp-badge { display: inline-block; border-radius: 999px; padding: 1px 8px; font-weight: 800; font-size: .78rem; background: var(--surface-2); color: var(--text); white-space: nowrap; } +.cp-badge.turn { background: var(--focus); color: #1c1e21; } +.cp-badge.seen { background: var(--primary-soft); color: var(--text); } +.cp-badge.lose { background: var(--danger-soft); color: var(--danger); } +.cp-badge.win { background: var(--success-soft); color: var(--success); } +.cp-meta { font-size: .85rem; color: var(--text-sub); display: flex; gap: 6px; align-items: center; } +.cp-front { display: flex; flex-wrap: wrap; gap: 4px; min-height: 30px; align-items: center; } +.cp-chip { display: inline-flex; align-items: center; gap: 2px; border-radius: 9px; padding: 1px 6px 1px 2px; font-weight: 900; font-size: .85rem; border: 2px solid transparent; font-variant-numeric: tabular-nums; } +.cp-chip.warn { border-color: var(--warning); background: var(--warning-soft); } +.cp-chip.danger { border-color: var(--danger); background: var(--danger-soft); color: var(--danger); } +.cp-chip .cp-mark { font-size: .72rem; margin-left: 1px; } +.cp-empty { font-size: .82rem; color: var(--text-sub); } +.cp-bubble { position: absolute; right: 8px; top: -12px; background: var(--text); color: var(--surface); border-radius: 12px; padding: 3px 10px; font-weight: 800; font-size: .85rem; box-shadow: var(--shadow); animation: cp-pop .25s ease-out; z-index: 2; max-width: 80%; } +.cp-mid { display: grid; grid-template-columns: minmax(0, 340px) minmax(0, 1fr); gap: 12px; align-items: start; } +.cp-table { background: var(--felt); color: #fff; border-radius: var(--radius-lg); padding: 12px; display: flex; flex-direction: column; gap: 10px; align-items: center; min-height: 230px; } +.cp-table .small { color: #fff; opacity: .92; } +.cp-chain { display: flex; flex-wrap: wrap; gap: 4px 6px; align-items: center; justify-content: center; font-size: .85rem; font-weight: 700; } +.cp-chain .arrow { opacity: .8; } +.cp-chain .said { background: rgba(255,255,255,.18); border-radius: 8px; padding: 0 6px; } +.cp-flight { display: flex; align-items: center; gap: 12px; } +.cp-speech { background: #fff; color: #1c1e21; border-radius: 14px; padding: 8px 12px; font-weight: 900; font-size: 1.05rem; position: relative; max-width: 170px; } +.cp-speech::before { content: ''; position: absolute; left: -8px; top: 50%; margin-top: -7px; border: 7px solid transparent; border-right-color: #fff; border-left: 0; } +.cp-speech .who { display: block; font-size: .78rem; font-weight: 700; color: #4a4f57; } +.cp-seenrow { font-size: .85rem; display: flex; gap: 4px; flex-wrap: wrap; justify-content: center; align-items: center; } +.cp-seenrow .s { background: rgba(0,0,0,.25); border-radius: 999px; padding: 0 8px; font-weight: 700; } +.cp-reveal { display: flex; align-items: center; gap: 10px; background: rgba(0,0,0,.22); border-radius: 12px; padding: 8px 10px; width: 100%; } +.cp-reveal .verdict { font-weight: 900; font-size: 1.05rem; } +.cp-reveal .verdict.ok { color: #86efac; } +.cp-reveal .verdict.no { color: #fca5a5; } +.cp-card { --w: 64px; width: var(--w); height: calc(var(--w) * 1.32); border-radius: 10px; border: 2px solid; display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px; + box-shadow: 0 3px 0 rgba(0,0,0,.28); flex: none; user-select: none; position: relative; } +.cp-card.sm { --w: 40px; border-radius: 7px; } +.cp-card.lg { --w: 92px; border-radius: 12px; } +.cp-card-art { width: 82%; height: auto; } +.cp-card-name { font-weight: 900; font-size: calc(var(--w) * .2); line-height: 1.1; white-space: nowrap; } +.cp-card.sm .cp-card-name { display: none; } +.cp-card.back { background: repeating-radial-gradient(circle at 30% 30%, #7c3aed 0 5px, #6d28d9 5px 10px); border-color: #3b0764; color: #fff; } +.cp-back-q { font-weight: 900; font-size: calc(var(--w) * .5); text-shadow: 0 2px 0 rgba(0,0,0,.35); } +.cp-card.slide { animation: cp-slide .45s ease-out; } +.cp-card.flip { animation: cp-flip .55s ease-out; } +.cp-card.mine { outline: 3px dashed #fde047; outline-offset: 3px; } +@keyframes cp-slide { 0% { transform: translateX(-40px) rotate(-12deg); opacity: .2; } 100% { transform: none; opacity: 1; } } +@keyframes cp-flip { 0% { transform: rotateY(90deg) scale(.9); } 100% { transform: none; } } +@keyframes cp-pop { 0% { transform: scale(.6); opacity: 0; } 100% { transform: none; opacity: 1; } } +.cp-panel { container-type: inline-size; background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); padding: 12px; display: flex; flex-direction: column; gap: 10px; min-width: 0; } +.cp-step { display: flex; flex-direction: column; gap: 6px; } +.cp-step > strong { font-size: .95rem; } +.cp-step.dim { opacity: .55; } +.cp-hand { display: flex; flex-wrap: wrap; gap: 8px; } +.cp-pick { position: relative; background: none; border: 0; padding: 3px; border-radius: 13px; cursor: pointer; color: inherit; } +.cp-pick:disabled { cursor: default; } +.cp-pick[aria-pressed='true'] { box-shadow: 0 0 0 4px var(--primary); } +.cp-pick[aria-pressed='true'] .cp-card { transform: translateY(-4px); } +.cp-pick:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.cp-count { position: absolute; right: -3px; top: -5px; min-width: 24px; height: 24px; border-radius: 12px; background: var(--text); color: var(--surface); font-weight: 900; font-size: .85rem; + display: flex; align-items: center; justify-content: center; padding: 0 5px; box-shadow: 0 1px 3px rgba(0,0,0,.3); font-variant-numeric: tabular-nums; } +.cp-targets { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; } +.cp-target { min-height: 52px; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; display: flex; align-items: center; gap: 7px; padding: 4px 9px; text-align: left; font-weight: 700; } +.cp-target .t-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } +.cp-target .t-sub { font-size: .75rem; color: var(--text-sub); display: block; font-weight: 600; white-space: normal; line-height: 1.25; } +.cp-target[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); } +.cp-target:disabled { opacity: .45; cursor: not-allowed; } +.cp-target:focus-visible, .cp-claim:focus-visible, .cp-say button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.cp-claims { display: grid; grid-template-columns: repeat(4, minmax(64px, 1fr)); gap: 8px; } +.cp-claim { min-height: 72px; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; font-weight: 800; font-size: .88rem; padding: 4px 2px; } +.cp-claim[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); box-shadow: 0 0 0 2px var(--primary); } +@container (min-width: 600px) { + .cp-claims { grid-template-columns: repeat(8, minmax(64px, 1fr)); } + .cp-hand .cp-card { --w: 58px; } +} +.cp-claim .truth { font-size: .7rem; color: var(--success); font-weight: 800; } +.cp-big { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.cp-big .btn { min-height: 64px; font-size: 1.2rem; } +.cp-true { background: var(--success); color: #fff; border: 0; } +.cp-false { background: var(--danger); color: #fff; border: 0; } +.cp-true:hover:not(:disabled), .cp-false:hover:not(:disabled) { filter: brightness(1.08); } +.cp-peek { min-height: 56px; } +.cp-why { font-size: .85rem; color: var(--text-sub); } +.cp-me { background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); padding: 10px 12px; border: 3px solid var(--primary); display: flex; flex-direction: column; gap: 8px; } +.cp-me.turn { border-color: var(--focus); } +.cp-me-row { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-start; } +.cp-me-row > div { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.cp-help { font-size: .85rem; color: var(--text-sub); } +.cp-say { display: flex; flex-wrap: wrap; gap: 6px; } +.cp-say button { min-height: 44px; border-radius: 999px; border: 2px solid var(--border); background: var(--surface); color: var(--text); font-weight: 700; padding: 0 12px; cursor: pointer; } +.cp-say button:disabled { opacity: .45; cursor: not-allowed; } +.cp-log { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-size: .9rem; } +.cp-log li { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +@media (max-width: 719px) { + .cp-board { max-height: none; overflow: visible; } + .cp-seats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } + .cp-seat { padding: 7px 8px; } + .cp-mid { grid-template-columns: minmax(0, 1fr); } + .cp-table { min-height: 0; } + .cp-card { --w: 54px; } + .cp-card.lg { --w: 76px; } + .cp-targets { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .cp-claims { gap: 6px; } + .cp-claim { min-height: 70px; font-size: .82rem; } +} +@media (prefers-reduced-motion: reduce) { + .cp-card.slide, .cp-card.flip, .cp-bubble { animation: none; } + .cp-pick[aria-pressed='true'] .cp-card { transform: none; } +} +`; + +// ---------------------------------------------------------------- pieces + +function FrontChips({ front, loseCount, size = 22 }: { front: FrontView; loseCount: number; size?: number }) { + if (front.total === 0) return 앞마당 비어 있음; + return ( + <> + {front.kinds.map((k) => { + const lv = front.level[k]; + return ( + + + {front.counts[k]} + {lv === 'danger' && !} + + ); + })} + + ); +} + +function Seat({ id, v, me, players, deadline, bubble }: { id: string; v: CockroachView; me: string | null; players: Record; deadline: number | null; bubble: string | null }) { + const u = userOf(players, id); + const r = v.round; + const active = waitingOn(v) === id; + const loser = v.final?.loser === id; + const front = v.fronts[id]!; + const role = v.phase === 'finished' ? null : r.holder === id ? (r.peeked ? '넘기는 중' : '받는 중') : r.holder === null && r.starter === id ? '건네는 중' : null; + const label = `${u.nickname}님${id === me ? '(나)' : ''}: 손패 ${v.handCounts[id]}장, ${frontText(front, v.loseCount)}${role ? `, ${role}` : ''}${r.seen.includes(id) && r.holder !== null ? ', 이 카드를 봤어요' : ''}${v.away[id] ? ', 자리 비움' : ''}`; + return ( +
+ {bubble && ( + + {bubble} + + )} +
+ + + {u.nickname} + {id === me && (나)} + + {active && } +
+
+ {role && {role}} + {r.holder !== null && r.seen.includes(id) && 👁 봤어요} + {v.away[id] && 자리 비움} + {v.final && {loser ? '패배' : '승리'}} +
+
+ 손패 {v.handCounts[id]}장 + · 앞마당 {front.total}장 +
+
+ +
+
+ ); +} + +function Chain({ claims, players }: { claims: ClaimEntry[]; players: Record }) { + if (claims.length === 0) return null; + return ( +
`${nick(players, c.from)}님이 ${nick(players, c.to)}님에게 ${kj(c.claim, '이라고', '라고')} 함`).join(', ')}`}> + {nick(players, claims[0]!.from)} + {claims.map((c, i) => ( + + + → + + 「{KIND_KO[c.claim]}」 + {nick(players, c.to)} + + ))} +
+ ); +} + +function RevealLine({ rv, players, me, fresh }: { rv: LastReveal; players: Record; me: string | null; fresh: boolean }) { + const callWord = rv.call === 'true' ? '진짜!' : '거짓말!'; + const truth = rv.kind === rv.claim; + const caller = rv.caller === me ? '내가' : `${nick(players, rv.caller)}님이`; + const taker = rv.takenBy === me ? '내' : `${nick(players, rv.takenBy)}님`; + return ( +
+ + + "{KIND_KO[rv.claim]}"{hasBatchim(KIND_KO[rv.claim]) ? "이라는" : "라는"} 말에 {caller} "{callWord}"{rv.auto ? '(시간 초과)' : ''} → 실제로는 {KIND_KO[rv.kind]}({truth ? '진짜' : '거짓'}) +
+ {rv.correct ? '맞혔어요!' : '틀렸어요!'} {kj(rv.kind, '은', '는')} {taker} 앞마당으로 +
+
+ ); +} + +function Table({ v, me, players, fresh }: { v: CockroachView; me: string | null; players: Record; fresh: { slide: boolean; flip: boolean } }) { + const r = v.round; + if (v.phase === 'finished') { + const f = v.final!; + return ( +
+ 게임이 끝났어요 + {f.losingKind && } + + {nick(players, f.loser)}님 패배 · 나머지 모두 승리 + + {v.lastReveal && } +
+ ); + } + return ( +
+ {r.holder !== null && r.claim ? ( + <> + {v.options.claimHistory && } +
+ {v.card && v.card.known ? : } + + + {r.lastPasser === me ? '내가' : `${nick(players, r.lastPasser!)}님이`} {r.holder === me ? '나에게' : `${nick(players, r.holder)}님에게`} + + {claimLine(r.claim)} + +
+ {v.card?.known && 점선 카드는 내가 아는 카드예요{v.card.kind === r.claim ? ' (지금 주장은 진짜)' : ' (지금 주장은 거짓)'}} +
nick(players, p)).join(', ')}`}> + 👁 본 사람 + {r.seen.map((p) => ( + + {p === me ? '나' : nick(players, p)} + + ))} + {r.passes > 0 && · {r.passes}번 넘어왔어요} +
+ + ) : ( + <> + + + {r.starter === me ? '내가 카드를 건넬 차례예요' : `${nick(players, r.starter)}님의 카드를 기다리는 중`} + + + )} + {v.lastReveal && (r.holder === null || v.lastReveal.round === r.no - 1) && } +
+ ); +} + +function Step({ title, dim, children, label }: { title: string; dim?: boolean; children: ReactNode; label: string }) { + return ( +
+ {title} + {children} +
+ ); +} + +function TargetButtons({ v, players, allowed, value, onPick, groupRef }: { v: CockroachView; players: Record; allowed: string[]; value: string | null; onPick: (p: string) => void; groupRef?: React.Ref }) { + const me = v.me; + const others = me ? v.order.slice(v.order.indexOf(me) + 1).concat(v.order.slice(0, v.order.indexOf(me))) : v.order; + return ( +
+ {others.map((p) => { + const ok = allowed.includes(p); + const seen = v.round.seen.includes(p); + return ( + + ); + })} +
+ ); +} + +function ClaimButtons({ value, onPick, truth, groupRef }: { value: Kind | null; onPick: (k: Kind) => void; truth: Kind | null; groupRef?: React.Ref }) { + return ( +
+ {KINDS.map((k) => ( + + ))} +
+ ); +} + +/** Offer (pick card → target → claim) or pass (target → claim) flow. */ +function GiveFlow({ v, players, act, pending }: { v: CockroachView; players: Record; act: (a: unknown) => void; pending: boolean }) { + const my = v.my!; + const offering = my.canOffer; + const [card, setCard] = useState(null); + const [to, setTo] = useState(null); + const [claim, setClaim] = useState(null); + const targetsRef = useRef(null); + const claimsRef = useRef(null); + const confirmRef = useRef(null); + const resetKey = `${v.round.no}|${v.round.holder}|${v.round.peeked}`; + useEffect(() => { + setCard(null); + setTo(null); + setClaim(null); + }, [resetKey]); + const focusFirst = (el: HTMLElement | null) => setTimeout(() => el?.querySelector('button:not(:disabled)')?.focus({ preventScroll: true }), 0); + const known = v.card && v.card.known ? v.card.kind : null; + const cardKind = offering ? card : known; + const allowed = offering ? my.offerTargets : my.passTargets; + const ready = !!cardKind && !!to && !!claim; + const send = () => { + if (!ready || pending) return; + if (offering) { + const id = v.myHand!.find((g) => g.kind === card)!.ids[0]!; + act({ type: 'offer', cardId: id, to, claim }); + } else act({ type: 'pass', to, claim }); + }; + const n = offering ? 3 : 2; + return ( +
{ + if (e.key === 'Escape') { + setCard(null); + setTo(null); + setClaim(null); + } + }} + > + {offering ? '카드를 건네 주세요' : '본 카드를 다른 사람에게 넘겨 주세요'} + {!offering && known && ( +
+ + + 몰래 본 카드는 {KIND_KO[known]}{hasBatchim(KIND_KO[known]) ? '이에요' : '예요'}. 아직 안 본 사람에게만 넘길 수 있어요. + +
+ )} + {offering && ( + +
+ {v.myHand!.map((g) => ( + + ))} +
+
+ )} + + { + setTo(p); + if (!claim) focusFirst(claimsRef.current); + }} + /> + + + { + setClaim(k); + setTimeout(() => confirmRef.current?.focus({ preventScroll: true }), 0); + }} + /> + + + {cardKind && claim && {cardKind === claim ? '진짜를 말하는 거예요.' : `${kj(cardKind, '을', '를')} ${kj(claim, '이라고', '라고')} 속이는 거예요.`}} +
+ ); +} + +function RespondPanel({ v, players, act, pending }: { v: CockroachView; players: Record; act: (a: unknown) => void; pending: boolean }) { + const my = v.my!; + const r = v.round; + const firstRef = useRef(null); + useEffect(() => { + firstRef.current?.focus({ preventScroll: true }); + }, [r.no, r.claims.length]); + return ( +
+ + {nick(players, r.lastPasser!)}님이 "{claimLine(r.claim!)}"라고 했어요. 진짜일까요? + +
+ + +
+ + {my.peekBlocked ? {my.peekBlocked} : 몰래 보면 판정할 수 없고, 아직 안 본 사람에게 꼭 넘겨야 해요.} + 맞히면 {nick(players, r.lastPasser!)}님이, 틀리면 내가 이 카드를 앞마당에 놓아요. +
+ ); +} + +function WaitPanel({ v, players, me }: { v: CockroachView; players: Record; me: string | null }) { + if (v.phase === 'finished') { + const f = v.final!; + return ( +
+ 게임이 끝났어요 + {f.summary} + 패배: {nick(players, f.loser)}님 · 오른쪽에서 모두의 남은 손패를 볼 수 있어요. +
+ ); + } + const r = v.round; + const who = nick(players, waitingOn(v)!); + const doing = r.holder === null ? '건넬 카드를 고르는 중이에요' : r.peeked ? '몰래 본 카드를 넘길 사람을 고르는 중이에요' : `"${claimLine(r.claim!)}"를 듣고 고민하는 중이에요`; + return ( +
+ + {who}님이 {doing} + + {me && v.order.includes(me) && r.holder !== null && r.seen.includes(me) && 나는 이 카드를 봤어요. 다시 받을 수 없어요.} + 같은 벌레가 {v.loseCount}장 모이면 져요. 빨간 배지는 한 장만 더 모이면 지는 벌레예요. +
+ ); +} + +function MyArea({ v, me, players, deadline, offering }: { v: CockroachView; me: string; players: Record; deadline: number | null; offering: boolean }) { + const front = v.fronts[me]!; + const active = waitingOn(v) === me; + const total = v.handCounts[me] ?? 0; + return ( +
+
+ + {nick(players, me)} (나) + {v.away[me] && 자리 비움} + {active && } +
+
+
+ 내 앞마당 {front.total}장 +
+ +
+
+
+ 내 손패 {total}장{offering ? ' (위에서 고르세요)' : ''} + {!offering && ( +
`${KIND_KO[g.kind]} ${g.ids.length}장`).join(', ') || '없음'}`}> + {(v.myHand ?? []).map((g) => ( + + + + {g.ids.length} + + + ))} + {total === 0 && 손패가 없어요. 카드를 건넬 차례가 오면 져요.} +
+ )} +
+
+
+ ); +} + +// ---------------------------------------------------------------- board + +function CockroachBoard({ view: v, me, players, act, pending, deadline }: BoardProps) { + const amPlayer = !!me && v.order.includes(me); + const [announce, setAnnounce] = useState(''); + const [bubbles, setBubbles] = useState>({}); + const [fresh, setFresh] = useState({ slide: false, flip: false }); + const prev = useRef({ claims: v.round.claims.length + v.round.no * 100, reveal: v.lastReveal?.round ?? 0, said: v.said[v.said.length - 1]?.seq ?? 0, waiting: waitingOn(v) }); + + useEffect(() => { + const p = prev.current; + const claimsKey = v.round.claims.length + v.round.no * 100; + const revealKey = v.lastReveal?.round ?? 0; + const lastSaid = v.said[v.said.length - 1]?.seq ?? 0; + const w = waitingOn(v); + let msg = ''; + const slide = claimsKey !== p.claims && v.round.holder !== null; + const flip = revealKey !== p.reveal && !!v.lastReveal; + if (flip) { + const rv = v.lastReveal!; + msg = `${nick(players, rv.caller)}님이 ${rv.call === 'true' ? '진짜라고' : '거짓말이라고'} 했고 ${rv.correct ? '맞혔어요' : '틀렸어요'}. ${kj(rv.kind, '은', '는')} ${nick(players, rv.takenBy)}님 앞마당으로 갔어요.`; + if (rv.takenBy === me) haptic([40, 30, 40]); + } else if (slide && v.round.claim && v.round.lastPasser) { + msg = `${nick(players, v.round.lastPasser)}님이 ${nick(players, v.round.holder!)}님에게 "${claimLine(v.round.claim)}"라고 카드를 건넸어요.`; + } + if (w !== p.waiting && w === me && v.phase !== 'finished') { + sounds.myTurn(); + haptic(40); + msg = `${msg} 내 차례예요.`.trim(); + } + if (msg) setAnnounce(msg); + if (lastSaid > p.said) { + const add: Record = {}; + for (const s of v.said) if (s.seq > p.said) add[s.player] = PHRASE_KO[s.phrase]; + setBubbles((b) => ({ ...b, ...add })); + setTimeout(() => setBubbles((b) => Object.fromEntries(Object.entries(b).filter(([k]) => !(k in add)))), 3500); + } + prev.current = { claims: claimsKey, reveal: revealKey, said: lastSaid, waiting: w }; + if (slide || flip) { + setFresh({ slide, flip }); + const t = setTimeout(() => setFresh({ slide: false, flip: false }), 700); + return () => clearTimeout(t); + } + return undefined; + }, [v.seq]); // eslint-disable-line react-hooks/exhaustive-deps + + const offering = amPlayer && !!v.my?.canOffer; + const passing = amPlayer && !!v.my?.canPass; + const responding = amPlayer && v.round.holder === me && !v.round.peeked && v.phase === 'respond'; + const seats = amPlayer ? v.order.slice(v.order.indexOf(me!) + 1).concat(v.order.slice(0, v.order.indexOf(me!))) : v.order; + + return ( +
+ +
+ {seats.map((p) => ( + + ))} +
+
+ + {offering || passing ? ( + + ) : responding ? ( + + ) : ( + + )} + + {amPlayer && } + {amPlayer && bubbles[me!] && ( + + {bubbles[me!]} + + )} +

+ {announce} +

+ + ); +} + +// ---------------------------------------------------------------- side + +function CockroachSide({ view: v, me, players, act, pending, deadline }: BoardProps) { + const running = v.phase !== 'finished'; + const w = waitingOn(v); + const r = v.round; + return ( + <> + +
+
+ {running ? `${r.no}라운드 · ${nick(players, w!)}님 차례` : '게임 끝'} + {running && } +
+ {optionsSummary(v.options, v.order.length)} +
    + {v.order.map((p) => ( +
  1. + + + {nick(players, p)} + {w === p ? ' · 차례' : ''} + {v.final ? (v.final.loser === p ? ' · 패배' : ' · 승리') : ''} + + + 손 {v.handCounts[p]} · 앞 {v.fronts[p]!.total} + +
  2. + ))} +
+ {v.removedCount > 0 && running && 2명 게임이라 카드 {v.removedCount}장을 보지 않고 뺐어요.} +
+ {v.final && } + {running && v.options.claimHistory && r.claims.length > 0 && ( +
+ 이번 라운드 주장 +
    + {r.claims.map((c, i) => ( +
  1. + {nick(players, c.from)} → {nick(players, c.to)}: "{claimLine(c.claim)}"{c.auto ? ' (시간 초과)' : ''} +
  2. + ))} +
+
+ )} + {running && v.options.quickChat && me && v.order.includes(me) && ( +
+ 빠른 말 +
+ {PHRASES.map((ph) => ( + + ))} +
+ 한 라운드에 3번까지 말할 수 있어요. +
+ )} +
+ 도움말 +
    +
  • 같은 벌레가 앞마당에 {v.loseCount}장 모이면 져요.
  • +
  • 노란 배지는 조심, 빨간 배지(!)는 한 장만 더 오면 지는 벌레예요.
  • +
  • 받은 카드는 "진짜!"·"거짓말!"로 판정하거나, 몰래 보고 아직 안 본 사람에게 넘길 수 있어요.
  • +
  • 맞히면 건넨 사람이, 틀리면 받은 사람이 카드를 가져가고 다음 라운드를 시작해요.
  • +
+
+ + ); +} + +function FinalCard({ v, players }: { v: CockroachView; players: Record }) { + const f = v.final!; + return ( +
+ 결과 + {f.summary} + 자세한 순위(같은 벌레가 적은 순 → 앞마당이 적은 순) +
    + {f.placings.map((g, i) => ( +
  1. + {i + 1}위 {g.map((p) => `${nick(players, p)}${p === f.loser ? ' (패배)' : ''}`).join(', ')} +
  2. + ))} +
+ {v.revealed && ( + <> + 남은 손패 공개 +
    + {v.order.map((p) => ( +
  • + {nick(players, p)}: {v.revealed!.hands[p]!.length === 0 ? '없음' : summarizeIds(v.revealed!.hands[p]!)} +
  • + ))} + {v.revealed.removed.length > 0 &&
  • 뺀 카드: {summarizeIds(v.revealed.removed)}
  • } +
+ + )} +
+ ); +} + +function summarizeIds(ids: string[]): string { + const counts = new Map(); + for (const id of ids) { + const k = id.slice(0, id.lastIndexOf('-')) as Kind; + counts.set(k, (counts.get(k) ?? 0) + 1); + } + return KINDS.filter((k) => counts.has(k)) + .map((k) => `${KIND_KO[k]} ${counts.get(k)}`) + .join(' · '); +} + +// ---------------------------------------------------------------- status + +function isMyTurn(v: CockroachView, me: string | null): boolean { + return !!me && v.order.includes(me) && waitingOn(v) === me; +} + +function statusText(v: CockroachView, me: string | null, players: Record): string { + if (v.phase === 'finished') return v.final?.summary ?? '게임이 끝났어요'; + const r = v.round; + if (isMyTurn(v, me)) { + if (r.holder === null) return '내 차례예요. 건넬 카드, 받을 사람, 할 말을 골라 주세요'; + if (r.peeked) return '내 차례예요. 본 카드를 넘길 사람과 할 말을 골라 주세요'; + return `내 차례예요. ${nick(players, r.lastPasser!)}님이 "${claimLine(r.claim!)}"라고 했어요`; + } + const who = nick(players, waitingOn(v)!); + if (r.holder === null) return `${who}님이 카드를 건네는 중이에요`; + return `${who}님이 ${r.peeked ? '카드를 넘기는' : '판단하는'} 중이에요`; +} + +// ---------------------------------------------------------------- options + +function Seg({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + return ( +
+ + set({ loseCount })} /> + + + set({ turnSeconds })} /> + + + set({ claimHistory })} /> + + + set({ quickChat })} /> + + + {more && ( + <> + + set({ eightKindsRule })} /> + + + set({ twoPlayerPass })} /> + + + )} +
+ ); +} + +function optionsSummary(o: CockroachOptions, players?: number): string { + const lose = o.loseCount === 'auto' ? (players ? `${players === 2 ? 5 : 4}장` : '자동(2명 5장, 그 외 4장)') : `${o.loseCount}장`; + return [ + `같은 벌레 ${lose}이면 패배`, + `제한 ${o.turnSeconds}초`, + ...(o.claimHistory ? [] : ['주장 기록 숨김']), + ...(o.quickChat ? [] : ['빠른 말 끔']), + ...(o.eightKindsRule ? ['8종 모으면 패배'] : []), + ...(o.twoPlayerPass ? ['2명 되돌려 주기'] : []), + ].join(' · '); +} + +function Rules({ options }: { options?: CockroachOptions }) { + const o = options; + const lose = !o || o.loseCount === 'auto' ? '4장(2명이면 5장)' : `${o.loseCount}장`; + return ( +
+

+ 벌레 카드를 뒤집어 건네며 "이건 쥐야!"처럼 말하고, 받은 사람은 그 말이 진짜인지 거짓말인지 맞히는 눈치 게임이에요. 2~6명이 할 수 있어요. 같은 벌레가 내 앞마당에 {lose} 모이면 지고, 나머지는 모두 + 이겨요. +

+
KIND_KO[k]).join(', ')}, 종류마다 8장`}> + {KINDS.map((k) => ( + + ))} +
+

+ 준비: 벌레 8종 × 8장 = 64장을 섞어 모두 나눠요(2명이면 10장을 보지 않고 빼고 27장씩). 첫 사람은 무작위로 정해요. +

+

+ 건네기: 차례인 사람이 손패 1장을 골라 다른 사람에게 뒷면으로 건네며 벌레 이름 하나를 말해요. 진짜를 말해도, 거짓말을 해도 돼요. +

+

+ 받은 사람은 둘 중 하나를 골라요. +

+
    +
  • + 진짜! / 거짓말!: 카드를 공개해요. 맞히면 건넨 사람이, 틀리면 내가 그 카드를 앞마당에 앞면으로 놓아요. +
  • +
  • + 몰래 보고 넘기기: 카드를 혼자 보고, 아직 이 카드를 보지 않은 사람에게 다시 말하며 넘겨요. 처음 건넨 사람과 이미 본 사람에게는 넘길 수 없어요. 모두 봤다면 꼭 판정해야 해요. + {o?.twoPlayerPass ? ' 2명일 때는 한 번만 되돌려 줄 수 있어요.' : ' 2명일 때는 넘기기 없이 항상 판정해요.'} +
  • +
+

카드를 앞마당에 놓은 사람이 다음 라운드를 시작해요. 그때 손에 카드가 한 장도 없으면 바로 져요.

+ {o?.eightKindsRule &&

하우스 룰: 앞마당에 8종을 모두 한 장 이상 모아도 져요.

} +

+ 시간 안에 고르지 않으면 자동으로 진행돼요(건네기: 무작위 카드를 다음 사람에게, 판정: 무작위, 넘기기: 아직 안 본 다음 사람에게 실제 벌레 이름으로). 세 번 연속 시간을 넘기면 제한 시간이 5초로 줄어요. 게임을 나가면 그 사람이 진 + 것으로 끝나요. +

+
+ ); +} + +const ui: GameUI = { + Board: CockroachBoard, + Side: CockroachSide, + statusText, + isMyTurn, + Options, + optionsSummary: (o) => optionsSummary(o), + Rules, +}; + +export default ui; diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index 2895ac0..e5c2088 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -23,6 +23,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { splendor: () => import('./splendor'), quoridor: () => import('./quoridor'), 'las-vegas': () => import('./las-vegas'), + 'cockroach-poker': () => import('./cockroach-poker'), }; const cache = new Map(); diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 420a27e..a6ccd5d 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -20,6 +20,7 @@ export const GAME_ICON: Record = { splendor: '💎', quoridor: '🧱', 'las-vegas': '⚅', + 'cockroach-poker': '🐞', }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 7dd0a7d..df897a5 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -528,6 +528,55 @@ async function lasVegasStep( } } +/** + * 바퀴벌레 포커: one UI step for whoever must act on `p` — offer (card → person → claim → confirm), pass after + * peeking (person → claim → confirm) or answer an offer (진짜!/거짓말!, sometimes 몰래 보고 넘기기). + */ +async function cockroachStep(p: Page, stats: Record): Promise { + const status = await p.locator('.turn-status').innerText().catch(() => ''); + if (!status.includes('내 차례예요')) return null; + const panel = p.locator('.cp-panel').first(); + const offering = (await panel.getByText('카드를 건네 주세요', { exact: true }).count()) > 0; + if (offering || (await panel.getByText('본 카드를 다른 사람에게 넘겨 주세요', { exact: true }).count())) { + const give = panel.getByRole('button', { name: /(건네기|넘기기)$/ }); + const n = (stats.offer ?? 0) + (stats.pass ?? 0); + if (offering) { + const cards = panel.locator('.cp-hand button.cp-pick'); + await cards.nth(n % (await cards.count())).click(); + } + const targets = panel.locator('button.cp-target:not([disabled])'); + await targets.nth(n % (await targets.count())).click(); + await panel.locator('button.cp-claim').nth((n * 3) % 8).click(); + if (!stats[`shot-give-${tag(p)}`]) { + stats[`shot-give-${tag(p)}`] = 1; + await p.screenshot({ path: join(SHOTS, `cockroach-poker-give-${tag(p)}.png`), fullPage: tag(p) === 'phone' }); + } + await give.click({ timeout: 2000 }); + stats[offering ? 'offer' : 'pass'] = (stats[offering ? 'offer' : 'pass'] ?? 0) + 1; + return offering ? 'offer' : 'pass'; + } + const yes = panel.getByRole('button', { name: /^진짜!/ }); + if (await yes.count()) { + if (!stats[`shot-respond-${tag(p)}`]) { + stats[`shot-respond-${tag(p)}`] = 1; + await p.screenshot({ path: join(SHOTS, `cockroach-poker-respond-${tag(p)}.png`), fullPage: tag(p) === 'phone' }); + } + const peek = panel.getByRole('button', { name: '👁 몰래 보고 넘기기' }); + const calls = (stats.true ?? 0) + (stats.false ?? 0); + if ((await peek.isEnabled().catch(() => false)) && calls % 3 === 1) { + await peek.click(); + await panel.getByText('본 카드를 다른 사람에게 넘겨 주세요').waitFor({ timeout: 3000 }); + stats.peek = (stats.peek ?? 0) + 1; + return 'peek'; + } + const which = calls % 2 === 0 ? 'true' : 'false'; + await panel.getByRole('button', { name: which === 'true' ? /^진짜!/ : /^거짓말!/ }).click(); + stats[which] = (stats[which] ?? 0) + 1; + return which; + } + return null; +} + const PLANS: Record = { rummikub: { // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. @@ -1383,6 +1432,61 @@ const PLANS: Record = { console.log(` las-vegas: ${st.rolls} rolls, ${st.places} placements, tie cancellation seen`); }, }, + 'cockroach-poker': { + // PC host + 2 phones, "같은 벌레 3장" for a shorter game. Real turns through the UI until someone loses: + // offer a face-down card with a claim, the target calls 진짜!/거짓말! or peeks and passes; the card goes face-up. + players: 3, + lobby: async (host) => { + await host.getByRole('group', { name: '같은 벌레 몇 장이면 패배' }).getByRole('button', { name: '3장(짧게)' }).click(); + await host.getByRole('button', { name: '설정 저장' }).click(); + await host.getByText(/같은 벌레 3장이면 패배/).first().waitFor(); + }, + drive: async (pages) => { + const [host, guest] = pages as [Page, Page, Page]; + const stats: Record = {}; + let actions = 0; + let shot = false; + const until = Date.now() + 300_000; + const over = () => host.getByRole('dialog', { name: '게임 결과' }).count(); + while (Date.now() < until && !(await over())) { + let acted = false; + for (const p of pages) { + const did = await cockroachStep(p, stats); + if (did) { + acted = true; + actions++; + // Wait for the server to move on before looking again. + await p.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요') || !!document.querySelector('.cp-panel button.cp-target'), null, { timeout: 4000 }).catch(() => {}); + await p.waitForTimeout(120); + break; + } + } + if (!shot && (stats.true ?? 0) + (stats.false ?? 0) >= 4 && (await host.locator('.cp-chip').count()) >= 3) { + shot = true; + await host.waitForTimeout(800); + await host.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-phone.png') }); + await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-phone-full.png'), fullPage: true }); + await host.emulateMedia({ colorScheme: 'dark' }); + await guest.emulateMedia({ colorScheme: 'dark' }); + await host.waitForTimeout(400); + await host.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-pc-dark.png') }); + await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-phone-dark.png'), fullPage: true }); + await host.emulateMedia({ colorScheme: 'light' }); + await guest.emulateMedia({ colorScheme: 'light' }); + } + if (!acted) await host.waitForTimeout(120); + } + if (!(await over())) throw new Error(`game did not finish (${actions} UI actions: ${JSON.stringify(stats)})`); + for (const p of pages) await p.getByRole('dialog', { name: '게임 결과' }).waitFor(); + await host.waitForTimeout(600); + await host.screenshot({ path: join(SHOTS, 'cockroach-poker-result-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-result-phone.png') }); + const calls = (stats.true ?? 0) + (stats.false ?? 0); + if (!stats.offer || calls < 3) throw new Error(`too few real turns: ${JSON.stringify(stats)}`); + console.log(` cockroach-poker: finished after ${actions} UI actions ${JSON.stringify(Object.fromEntries(Object.entries(stats).filter(([k]) => !k.startsWith('shot'))))}`); + }, + }, }; const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); diff --git a/packages/games/src/cockroach-poker/cockroach-poker.test.ts b/packages/games/src/cockroach-poker/cockroach-poker.test.ts new file mode 100644 index 0000000..ccb6841 --- /dev/null +++ b/packages/games/src/cockroach-poker/cockroach-poker.test.ts @@ -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 = {}, players = ['A', 'B', 'C', 'D'], seed = 1): CockroachState { + return game.setup({ players, options: game.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(seed)), now: 0 }); +} + +/** + * A game in progress with the given hands / fronts (as kinds; ids are assigned in order) and `starter` to offer. + * Unused cards are dropped from play (tests only care about the listed ones). + */ +function rigged(hands: Record, opts: Partial = {}, fronts: Record = {}, starter?: string): CockroachState { + const players = Object.keys(hands); + const s = setup(opts, players); + const next: Record = Object.fromEntries(KINDS.map((k) => [k, 0])) as Record; + const take = (k: Kind) => `${k}-${next[k]++}`; + for (const p of players) { + s.hands[p] = hands[p]!.map(take); + s.fronts[p] = (fronts[p] ?? []).map(take); + } + s.removed = []; + s.round = { no: 1, starter: starter ?? players[0]!, card: null, holder: null, lastPasser: null, claim: null, seen: [], peeked: false, claims: [] }; + return s; +} + +function actE(s: CockroachState, p: string, a: CockroachAction): { state: CockroachState; events: GameEvent[] } { + const v = game.validate(s, p, a); + if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`); + return game.apply(s, p, a, { rng: rng(), now: (clock += 1000) }); +} +const act = (s: CockroachState, p: string, a: CockroachAction) => actE(s, p, a).state; +const reject = (s: CockroachState, p: string, a: CockroachAction) => { + const v = game.validate(s, p, a); + return v.ok ? null : v.reason; +}; +const cardOf = (s: CockroachState, p: string, k: Kind) => s.hands[p]!.find((id) => kindOf(id) === k)!; +const offer = (s: CockroachState, from: string, k: Kind, to: string, claim: Kind) => act(s, from, { type: 'offer', cardId: cardOf(s, from, k), to, claim }); +const frontKinds = (s: CockroachState, p: string) => s.fronts[p]!.map(kindOf); + +/** Every card exists exactly once across hands, fronts, the removed pile and the passed card. */ +function checkInvariants(st: unknown, total = 64): void { + const s = st as CockroachState; + const all = [...Object.values(s.hands).flat(), ...Object.values(s.fronts).flat(), ...s.removed, ...(s.round.card ? [s.round.card] : [])]; + if (all.length !== total || new Set(all).size !== total) throw new Error(`cards not conserved: ${all.length}/${total}`); + if (s.final === null) { + for (const p of s.order) { + const c = countKinds(s.fronts[p]!); + if (KINDS.some((k) => c[k] >= s.loseCount)) throw new Error(`${p} should have lost`); + } + const r = s.round; + if ((r.card === null) !== (r.holder === null)) throw new Error('card/holder mismatch'); + if (r.holder !== null && !r.seen.includes(r.starter)) throw new Error('starter not in seen'); + } +} + +/** Structural hidden-info check for one view (beyond the id-substring leak test). */ +function checkViewShape(s: CockroachState, v: CockroachView, viewer: string | null): void { + const over = s.final !== null; + for (const p of s.order) expect(v.handCounts[p]).toBe(s.hands[p]!.length); + if (viewer === null) expect(v.myHand).toBeNull(); + const json = JSON.stringify(v); + if (!over) { + expect(v.revealed).toBeNull(); + for (const id of s.removed) if (json.includes(JSON.stringify(id))) throw new Error(`removed card ${id} leaks`); + for (const p of s.order) if (p !== viewer) for (const id of s.hands[p]!) if (json.includes(JSON.stringify(id))) throw new Error(`hand card ${id} of ${p} leaks to ${viewer}`); + const knows = viewer !== null && s.round.seen.includes(viewer); + if (s.round.card) { + if (knows) expect(v.card).toEqual({ known: true, id: s.round.card, kind: kindOf(s.round.card) }); + else { + expect(v.card).toEqual({ known: false }); + if (json.includes(JSON.stringify(s.round.card))) throw new Error(`passed card leaks to ${viewer}`); + } + } + } +} + +// ---------------------------------------------------------------- setup + +describe('셋업 (§4)', () => { + test('인원별 손패 장수가 표와 일치(첫 플레이어부터), 2인은 10장 제거 후 27장씩, 카드 총합 보존', () => { + const table: Record = { 2: [27, 27], 3: [22, 21, 21], 4: [16, 16, 16, 16], 5: [13, 13, 13, 13, 12], 6: [11, 11, 11, 11, 10, 10] }; + for (let n = 2; n <= 6; n++) { + expect(handSizes(n)).toEqual(table[n]!); + for (let seed = 1; seed <= 10; seed++) { + const players = Array.from({ length: n }, (_, i) => `P${i}`); + const s = setup({}, players, seed); + const fi = players.indexOf(s.round.starter); + const sizes = players.map((_, k) => s.hands[players[(fi + k) % n]!]!.length); + expect(sizes).toEqual(table[n]!); + expect(s.removed.length).toBe(n === 2 ? 10 : 0); + expect(s.loseCount).toBe(n === 2 ? 5 : 4); + checkInvariants(s); + for (const p of players) expect(s.fronts[p]).toEqual([]); + } + } + expect(allCardIds().length).toBe(64); + for (const k of KINDS) expect(allCardIds().filter((id) => kindOf(id) === k).length).toBe(8); + }); + + test('같은 시드 = 같은 셋업; 첫 시작자는 시드에 따라 달라짐', () => { + expect(JSON.stringify(setup({}, ['A', 'B', 'C'], 5))).toBe(JSON.stringify(setup({}, ['A', 'B', 'C'], 5))); + const starters = new Set(Array.from({ length: 30 }, (_, i) => setup({}, ['A', 'B', 'C'], i + 1).round.starter)); + expect(starters.size).toBe(3); + }); + + test('loseCount 옵션: 자동(2인 5, 3~6인 4) 또는 지정값', () => { + expect(setup({ loseCount: 3 }, ['A', 'B']).loseCount).toBe(3); + expect(setup({ loseCount: 5 }, ['A', 'B', 'C']).loseCount).toBe(5); + expect(setup({}, ['A', 'B', 'C', 'D', 'E', 'F']).loseCount).toBe(4); + }); +}); + +// ---------------------------------------------------------------- rules + +describe('판정 (§5.2, §5.3)', () => { + test('진실 주장에 "진짜" → 전달자 앞마당; 받은 사람이 다음 시작자', () => { + let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }); + s = offer(s, 'A', 'rat', 'B', 'rat'); + const out = actE(s, 'B', { type: 'call', call: 'true' }); + s = out.state; + expect(frontKinds(s, 'A')).toEqual(['rat']); + expect(frontKinds(s, 'B')).toEqual([]); + expect(s.round.starter).toBe('A'); + expect(s.round.no).toBe(2); + expect(s.lastReveal).toMatchObject({ kind: 'rat', claim: 'rat', call: 'true', caller: 'B', correct: true, takenBy: 'A' }); + expect(out.events.find((e) => e.type === 'reveal')).toMatchObject({ kind: 'rat', correct: true, takenBy: 'A' }); + checkInvariants(s, 4); + }); + + test('거짓 주장에 "거짓" → 전달자; 거짓 주장에 "진짜" → 수신자; 진실 주장에 "거짓" → 수신자', () => { + let s = rigged({ A: ['rat', 'rat'], B: ['bat', 'bat'], C: ['toad'] }); + s = offer(s, 'A', 'rat', 'B', 'fly'); + s = act(s, 'B', { type: 'call', call: 'false' }); + expect(frontKinds(s, 'A')).toEqual(['rat']); + expect(s.round.starter).toBe('A'); + s = offer(s, 'A', 'rat', 'C', 'fly'); + s = act(s, 'C', { type: 'call', call: 'true' }); + expect(frontKinds(s, 'C')).toEqual(['rat']); + expect(s.round.starter).toBe('C'); + s = offer(s, 'C', 'toad', 'B', 'toad'); + s = act(s, 'B', { type: 'call', call: 'false' }); + expect(frontKinds(s, 'B')).toEqual(['toad']); + expect(s.round.starter).toBe('B'); + }); + + test('§5.4 예시: A가 거미를 "파리"로 B에게 → B가 보고 C에게 "거미"(진실) → C "거짓" → C 앞마당, C가 다음 시작자', () => { + let s = rigged({ A: ['spider', 'rat'], B: ['bat'], C: ['toad'], D: ['fly'] }); + s = offer(s, 'A', 'spider', 'B', 'fly'); + s = act(s, 'B', { type: 'peek' }); + expect(s.round.seen).toEqual(['A', 'B']); + expect(game.view(s, 'B').card).toEqual({ known: true, id: 'spider-0', kind: 'spider' }); + s = act(s, 'B', { type: 'pass', to: 'C', claim: 'spider' }); + expect([s.round.holder, s.round.lastPasser, s.round.claim]).toEqual(['C', 'B', 'spider']); + const alt = s; + s = act(s, 'C', { type: 'call', call: 'false' }); + expect(frontKinds(s, 'C')).toEqual(['spider']); + expect(s.round.starter).toBe('C'); + // C could instead peek and pass only to D; D then has nobody left and must call. + let t = act(alt, 'C', { type: 'peek' }); + expect(game.view(t, 'C').my!.passTargets).toEqual(['D']); + expect(reject(t, 'C', { type: 'pass', to: 'A', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.'); + t = act(t, 'C', { type: 'pass', to: 'D', claim: 'bat' }); + expect(reject(t, 'D', { type: 'peek' })).toContain('넘길 사람이 없어요'); + expect(game.view(t, 'D').my).toMatchObject({ canCall: true, canPeek: false }); + expect(game.view(t, 'D').my!.peekBlocked).toContain('넘길 사람이 없어요'); + t = act(t, 'D', { type: 'call', call: 'false' }); // claim "bat", card spider → lie → D correct → C takes it + expect(frontKinds(t, 'C')).toEqual(['spider']); + }); +}); + +describe('떠넘기기 거부 (§5.2, §8.2)', () => { + test('seen에 있는 사람(시작자 포함)에게 pass 거부, 본인에게도 거부', () => { + let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'], D: ['fly'] }); + s = offer(s, 'A', 'rat', 'B', 'rat'); + s = act(s, 'B', { type: 'peek' }); + expect(reject(s, 'B', { type: 'pass', to: 'A', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.'); + expect(reject(s, 'B', { type: 'pass', to: 'B', claim: 'rat' })).toBe('나에게는 넘길 수 없어요.'); + s = act(s, 'B', { type: 'pass', to: 'C', claim: 'rat' }); + s = act(s, 'C', { type: 'peek' }); + expect(reject(s, 'C', { type: 'pass', to: 'B', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.'); + expect(reject(s, 'C', { type: 'pass', to: 'A', claim: 'rat' })).toBe('이 카드를 이미 본 사람에게는 넘길 수 없어요.'); + expect(reject(s, 'C', { type: 'pass', to: 'D', claim: 'rat' })).toBeNull(); + }); + + test('나머지 전원이 본 상태의 holder는 peek 거부, call만 가능', () => { + let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] }); + s = offer(s, 'A', 'rat', 'B', 'rat'); + s = act(s, 'B', { type: 'peek' }); + s = act(s, 'B', { type: 'pass', to: 'C', claim: 'fly' }); + expect(reject(s, 'C', { type: 'peek' })).toBe('모두 이 카드를 이미 봐서 넘길 사람이 없어요. 진짜인지 거짓인지 골라 주세요.'); + expect(game.legalActions!(s, 'C')).toEqual([ + { type: 'call', call: 'true' }, + { type: 'call', call: 'false' }, + ]); + }); + + test('peek 후 call 거부; peek 전 pass 거부; 두 번 peek 거부', () => { + let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] }); + s = offer(s, 'A', 'rat', 'B', 'rat'); + expect(reject(s, 'B', { type: 'pass', to: 'C', claim: 'rat' })).toBe('먼저 카드를 몰래 봐야 넘길 수 있어요.'); + s = act(s, 'B', { type: 'peek' }); + expect(reject(s, 'B', { type: 'call', call: 'true' })).toBe('카드를 이미 봤으니 다른 사람에게 넘겨야 해요.'); + expect(reject(s, 'B', { type: 'peek' })).toBe('이미 카드를 봤어요.'); + expect(game.legalActions!(s, 'B').every((a) => a.type === 'pass')).toBe(true); + }); + + test('차례가 아닌 사람·잘못된 offer 거부', () => { + let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] }); + expect(reject(s, 'B', { type: 'offer', cardId: 'bat-0', to: 'C', claim: 'bat' })).toBe('지금은 내 차례가 아니에요.'); + expect(reject(s, 'A', { type: 'offer', cardId: 'bat-0', to: 'C', claim: 'bat' })).toBe('내 손에 없는 카드예요.'); + expect(reject(s, 'A', { type: 'offer', cardId: 'rat-0', to: 'A', claim: 'bat' })).toBe('나에게는 건넬 수 없어요.'); + expect(reject(s, 'A', { type: 'offer', cardId: 'rat-0', to: 'Z', claim: 'bat' })).toBe('이 게임의 플레이어가 아니에요.'); + expect(reject(s, 'A', { type: 'call', call: 'true' })).toBe('지금은 내가 판정할 차례가 아니에요.'); + expect(reject(s, 'Z', { type: 'peek' })).toBe('이 게임의 플레이어가 아니에요.'); + s = offer(s, 'A', 'rat', 'B', 'rat'); + expect(reject(s, 'A', { type: 'offer', cardId: 'rat-0', to: 'C', claim: 'bat' })).toBe('지금은 카드를 건넬 때가 아니에요.'); + expect(reject(s, 'C', { type: 'call', call: 'true' })).toBe('지금은 내가 판정할 차례가 아니에요.'); + expect(reject(s, 'C', { type: 'timeout' })).toBe('시간 초과 대상이 아니에요.'); + }); +}); + +describe('2인 규칙 (§5.2, §6)', () => { + test('peek 거부(항상 판정), 같은 종류 5장에서 패배, 4장에서는 계속', () => { + let s = rigged({ A: ['rat', 'rat', 'bat'], B: ['fly', 'fly'] }, {}, { A: ['rat', 'rat', 'rat'] }); + expect(s.loseCount).toBe(5); + s = offer(s, 'A', 'rat', 'B', 'rat'); + expect(reject(s, 'B', { type: 'peek' })).toBe('2명이 할 때는 몰래 보고 넘기기를 할 수 없어요. 진짜인지 거짓인지 골라 주세요.'); + expect(game.view(s, 'B').my).toMatchObject({ canPeek: false, canCall: true }); + s = act(s, 'B', { type: 'call', call: 'true' }); // A takes 4th rat → continues + expect(frontKinds(s, 'A').filter((k) => k === 'rat').length).toBe(4); + expect(s.final).toBeNull(); + s = offer(s, 'A', 'rat', 'B', 'bat'); + s = act(s, 'B', { type: 'call', call: 'false' }); // 5th rat → A loses + expect(s.final).toMatchObject({ loser: 'A', reason: 'four', losingKind: 'rat' }); + expect(game.result(s)).toMatchObject({ ranking: [['B'], ['A']], scores: { A: 0, B: 1 }, reason: 'normal' }); + }); + + test('twoPlayerPass: 한 번만 보고 되돌려 줄 수 있고, 되돌려 받은 사람은 판정만', () => { + let s = rigged({ A: ['rat', 'bat'], B: ['fly'] }, { twoPlayerPass: true }); + s = offer(s, 'A', 'rat', 'B', 'bat'); + expect(game.view(s, 'B').my!.canPeek).toBe(true); + s = act(s, 'B', { type: 'peek' }); + expect(game.view(s, 'B').my!.passTargets).toEqual(['A']); + s = act(s, 'B', { type: 'pass', to: 'A', claim: 'rat' }); + expect(reject(s, 'A', { type: 'peek' })).toContain('되돌려 받은 카드'); + s = act(s, 'A', { type: 'call', call: 'true' }); + expect(frontKinds(s, 'B')).toEqual(['rat']); + expect(s.round.starter).toBe('B'); + }); +}); + +describe('패배 조건 (§6)', () => { + test('같은 종류 4장 도달 즉시 종료, 다른 종류 4장(합계)은 무관', () => { + let s = rigged({ A: ['rat', 'rat'], B: ['fly'], C: ['toad'] }, {}, { B: ['bat', 'fly', 'toad'], C: ['rat', 'rat', 'rat'] }); + s = offer(s, 'A', 'rat', 'B', 'rat'); + s = act(s, 'B', { type: 'call', call: 'false' }); // wrong → B takes rat: 4 different kinds + expect(s.final).toBeNull(); + expect(s.fronts.B!.length).toBe(4); + s = offer(s, 'B', 'fly', 'C', 'rat'); + const out = actE(s, 'C', { type: 'call', call: 'true' }); // wrong → C takes fly: still 3 rats + 1 fly + expect(out.state.final).toBeNull(); + s = offer(out.state, 'C', 'toad', 'A', 'toad'); + s = act(s, 'A', { type: 'call', call: 'false' }); // wrong → A takes toad + s = offer(s, 'A', 'rat', 'C', 'bat'); + const end = actE(s, 'C', { type: 'call', call: 'true' }); // wrong → C takes 4th rat + expect(end.state.final).toMatchObject({ loser: 'C', reason: 'four', losingKind: 'rat' }); + expect(end.events.some((e) => e.type === 'gameEnded')).toBe(true); + expect(game.activePlayers(end.state)).toEqual([]); + expect(game.deadline!(end.state)).toBeNull(); + const res = game.result(end.state)!; + expect(res.ranking).toEqual([['A', 'B'], ['C']]); + expect(res.summary).toContain('쥐 4장'); + // Detailed placings: A (max 1, total 2) before B (max 1, total 4); loser last. + expect(end.state.final!.placings).toEqual([['A'], ['B'], ['C']]); + expect(reject(end.state, 'A', { type: 'offer', cardId: 'rat-0', to: 'B', claim: 'rat' })).toBe('게임이 이미 끝났어요.'); + }); + + test('loseCount=3 옵션', () => { + let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }, { loseCount: 3 }, { B: ['rat', 'rat'] }); + s = offer(s, 'A', 'rat', 'B', 'fly'); + s = act(s, 'B', { type: 'call', call: 'true' }); + expect(s.final).toMatchObject({ loser: 'B', reason: 'four', losingKind: 'rat' }); + }); + + test('다음 시작자의 손패가 0장 → 즉시 패배(reason emptyHand)', () => { + let s = rigged({ A: ['rat', 'fly'], B: [], C: ['toad'] }); + s = offer(s, 'A', 'rat', 'B', 'rat'); + s = act(s, 'B', { type: 'call', call: 'false' }); // wrong → B takes it and must start with 0 cards + expect(s.final).toMatchObject({ loser: 'B', reason: 'emptyHand' }); + expect(game.result(s)!.ranking[1]).toEqual(['B']); + }); + + test('손패 0장인 사람에게 건네기 허용, 그가 떠넘기기/판정 정상 동작', () => { + let s = rigged({ A: ['rat', 'fly'], B: [], C: ['toad'], D: ['bat'] }); + expect(game.view(s, 'A').my!.offerTargets).toEqual(['B', 'C', 'D']); + s = offer(s, 'A', 'rat', 'B', 'bat'); + s = act(s, 'B', { type: 'peek' }); + s = act(s, 'B', { type: 'pass', to: 'C', claim: 'rat' }); + s = act(s, 'C', { type: 'call', call: 'true' }); // true claim → C right → B takes it, then B starts with 0 cards + expect(frontKinds(s, 'B')).toEqual(['rat']); + expect(s.final).toMatchObject({ loser: 'B', reason: 'emptyHand' }); + // Calling correctly with an empty hand is fine: the passer takes the card. + let t = rigged({ A: ['rat', 'fly'], B: [], C: ['toad'] }); + t = offer(t, 'A', 'rat', 'B', 'rat'); + t = act(t, 'B', { type: 'call', call: 'true' }); + expect(t.final).toBeNull(); + expect(t.round.starter).toBe('A'); + }); + + test('eightKindsRule: 8종이 모이면 패배; 끄면 계속', () => { + const fr = { B: ['cockroach', 'bat', 'fly', 'toad', 'scorpion', 'spider', 'stinkbug'] as Kind[] }; + for (const on of [true, false]) { + let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }, { eightKindsRule: on }, fr); + s = offer(s, 'A', 'rat', 'B', 'fly'); + s = act(s, 'B', { type: 'call', call: 'true' }); + if (on) expect(s.final).toMatchObject({ loser: 'B', reason: 'eight' }); + else expect(s.final).toBeNull(); + } + }); +}); + +// ---------------------------------------------------------------- timers + +describe('시간 초과 (§8.5)', () => { + test('시작자 무응답 → 자동 offer(다음 좌석, 손패 1장, 무작위 주장)', () => { + const s = rigged({ A: ['rat', 'fly', 'bat'], B: ['bat'], C: ['toad'] }); + expect(game.onTimeout(s, 'A')).toEqual({ type: 'timeout' }); + expect(game.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false); + const out = actE(s, 'A', { type: 'timeout' }); + const t = out.state; + expect(t.round.holder).toBe('B'); + expect(t.round.claims[0]).toMatchObject({ from: 'A', to: 'B', auto: true }); + expect(t.hands.A!.length).toBe(2); + expect(out.events.map((e) => e.type)).toEqual(['timeout', 'offer']); + expect(t.timeoutsInRow.A).toBe(1); + }); + + test('holder 무응답 → 자동 call; peek 후 무응답 → 자동 pass(seen 밖 첫 좌석, 실제 종류로)', () => { + let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'], D: ['fly'] }); + s = offer(s, 'A', 'rat', 'B', 'fly'); + const called = act(s, 'B', { type: 'timeout' }); + expect(called.lastReveal).toMatchObject({ caller: 'B', auto: true }); + expect(called.round.no).toBe(2); + s = act(s, 'B', { type: 'peek' }); + const passed = actE(s, 'B', { type: 'timeout' }); + expect(passed.state.round).toMatchObject({ holder: 'C', lastPasser: 'B', claim: 'rat', peeked: false }); + expect(passed.state.round.claims[1]).toMatchObject({ from: 'B', to: 'C', claim: 'rat', auto: true }); + // C peeks; first unseen seat clockwise from C is D. + let t = act(passed.state, 'C', { type: 'peek' }); + t = act(t, 'C', { type: 'timeout' }); + expect(t.round.holder).toBe('D'); + expect(autoAction(t, 'D', rng()).type).toBe('call'); + }); + + test('3번 연속 시간 초과 → 자리 비움(마감 5초), 직접 행동하면 해제', () => { + const t0 = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }, { turnSeconds: 30 }); + let t = act({ ...t0, timeoutsInRow: { A: 1, B: 0, C: 0 } }, 'A', { type: 'timeout' }); + expect([t.away.A, t.timeoutsInRow.A]).toEqual([false, 2]); + const third = actE({ ...t0, timeoutsInRow: { A: 2, B: 0, C: 0 } }, 'A', { type: 'timeout' }); + expect(third.state.away.A).toBe(true); + expect(third.events.map((e) => e.type)).toEqual(['timeout', 'away', 'offer']); + t = third.state; + expect(game.view(t, null).away.A).toBe(true); + const w = { ...t0, away: { A: true, B: false, C: false } }; + expect(game.deadline!(w)! - w.turnStartedAt).toBe(5000); + expect(game.view(w, 'A').turnMs).toBe(5000); + const back = act(w, 'A', { type: 'offer', cardId: 'rat-0', to: 'B', claim: 'rat' }); + expect(back.away.A).toBe(false); + expect(back.timeoutsInRow.A).toBe(0); + expect(game.deadline!(back)! - back.turnStartedAt).toBe(30_000); + }); + + test('모두 무응답이어도 게임이 끝남; 매 상태에서 onTimeout이 합법', () => { + for (let seed = 1; seed <= 40; seed++) { + const n = 2 + (seed % 5); + const players = Array.from({ length: n }, (_, i) => `P${i}`); + let s = setup({ twoPlayerPass: seed % 2 === 0 }, players, seed); + const r = SeededRng.fromSeed(seedN(seed)); + let step = 0; + for (; step < 2000 && !s.final; step++) { + const p = game.timeoutPlayers!(s)[0]!; + expect(game.validate(s, p, game.onTimeout(s, p))).toEqual({ ok: true }); + s = game.apply(s, p, game.onTimeout(s, p), { rng: r, now: step }).state; + checkInvariants(s); + } + expect(s.final).not.toBeNull(); + } + }); +}); + +describe('나가기·빠른 말', () => { + test('나가면 그 사람이 패배하고 나머지 모두 승리(reason resign)', () => { + let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] }); + s = offer(s, 'A', 'rat', 'B', 'rat'); + expect(game.onLeave!(s, 'C')).toEqual({ type: 'leave' }); + expect(game.onLeave!(s, 'Z')).toBeNull(); + s = act(s, 'C', { type: 'leave' }); + expect(s.final).toMatchObject({ loser: 'C', reason: 'left' }); + expect(game.result(s)).toMatchObject({ ranking: [['A', 'B'], ['C']], reason: 'resign' }); + expect(game.onLeave!(s, 'A')).toBeNull(); + expect(game.actionSchema.safeParse({ type: 'leave' }).success).toBe(false); + }); + + test('빠른 말: 라운드당 3번, 옵션을 끄면 거부, 차례 시계는 그대로', () => { + let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'] }); + const started = s.turnStartedAt; + for (let i = 0; i < 3; i++) s = act(s, 'C', { type: 'say', phrase: 'doubt' }); + expect(s.turnStartedAt).toBe(started); + expect(reject(s, 'C', { type: 'say', phrase: 'doubt' })).toContain('3번까지'); + expect(game.view(s, 'C').said.length).toBe(3); + expect(game.view(s, 'C').my!.canSay).toBe(false); + s = offer(s, 'A', 'rat', 'B', 'rat'); + s = act(s, 'B', { type: 'call', call: 'true' }); // next round resets the count + expect(reject(s, 'C', { type: 'say', phrase: 'really' })).toBeNull(); + const off = rigged({ A: ['rat'], B: ['bat'] }, { quickChat: false }); + expect(reject(off, 'A', { type: 'say', phrase: 'really' })).toBe('이 방은 빠른 말을 쓰지 않아요.'); + }); +}); + +// ---------------------------------------------------------------- info + +describe('비공개 정보 (§8.3)', () => { + test('미확인 holder·관전자·다른 플레이어 view에 건네는 카드가 없고, 본 사람에게만 보임', () => { + let s = rigged({ A: ['rat', 'fly'], B: ['bat'], C: ['toad'], D: ['fly'] }); + s = offer(s, 'A', 'rat', 'B', 'fly'); + expect(game.view(s, 'A').card).toEqual({ known: true, id: 'rat-0', kind: 'rat' }); + for (const v of ['B', 'C', 'D', null]) { + expect(game.view(s, v).card).toEqual({ known: false }); + expect(JSON.stringify(game.view(s, v))).not.toContain('"rat-0"'); + } + s = act(s, 'B', { type: 'peek' }); + expect(game.view(s, 'B').card).toEqual({ known: true, id: 'rat-0', kind: 'rat' }); + expect(game.view(s, 'C').card).toEqual({ known: false }); + expect(game.view(s, null).round.seen).toEqual(['A', 'B']); + expect(game.view(s, 'C').myHand).toEqual([{ kind: 'toad', ids: ['toad-0'] }]); + expect(JSON.stringify(game.view(s, 'C'))).not.toContain('"fly-0"'); + expect(game.view(s, null).myHand).toBeNull(); + expect(game.view(s, null).my).toBeNull(); + }); + + test('2인 제거 카드는 진행 중 비공개, 종료 후 전체 손패와 함께 공개', () => { + let s = setup({}, ['A', 'B'], 3); + for (const v of ['A', 'B', null]) { + const j = JSON.stringify(game.view(s, v)); + for (const id of s.removed) expect(j).not.toContain(JSON.stringify(id)); + expect(game.view(s, v).removedCount).toBe(10); + } + s = act(s, 'A', { type: 'leave' }); + const v = game.view(s, null); + expect(v.revealed!.removed).toEqual(s.removed); + expect(v.revealed!.hands.B).toEqual(s.hands.B!); + expect(game.secretsOf!(s, 'A')).toEqual([]); + }); + + test('claimHistory=off: 주장 기록은 숨기고 현재 주장만', () => { + let s = rigged({ A: ['rat'], B: ['bat'], C: ['toad'] }, { claimHistory: false }); + s = offer(s, 'A', 'rat', 'B', 'fly'); + const v = game.view(s, 'C'); + expect(v.round.claims).toEqual([]); + expect(v.round.claim).toBe('fly'); + expect(game.view(rigged({ A: ['rat'], B: ['bat'] }), 'A').round.claims).toEqual([]); + }); + + test('무작위 진행 중 모든 view가 비공개 정보를 숨기고 이벤트에도 미공개 카드가 없음', () => { + for (let seed = 1; seed <= 150; seed++) { + const n = 2 + (seed % 5); + const players = Array.from({ length: n }, (_, i) => `P${i}`); + const r = SeededRng.fromSeed(seedN(seed)); + const chooser = SeededRng.fromSeed(seedN(seed + 77)); + let s = setup({ twoPlayerPass: seed % 3 === 0, claimHistory: seed % 4 !== 0 }, players, seed); + for (let step = 0; step < 600 && !s.final; step++) { + for (const viewer of [...players, null]) checkViewShape(s, game.view(s, viewer), viewer); + const actor = game.activePlayers(s)[0]!; + const legal = game.legalActions!(s, actor); + const a = chooser.int(5) === 0 ? game.onTimeout(s, actor) : legal[chooser.int(legal.length)]!; + const out = game.apply(s, actor, a, { rng: r, now: step * 1000 }); + if (!out.state.final) { + const hidden = [...Object.values(out.state.hands).flat(), ...out.state.removed, ...(out.state.round.card ? [out.state.round.card] : [])]; + for (const e of out.events) { + const ej = JSON.stringify(e); + for (const id of hidden) if (ej.includes(JSON.stringify(id))) throw new Error(`event ${e.type} leaks ${id}`); + } + } + s = out.state; + } + expect(s.final).not.toBeNull(); + } + }, 30_000); +}); + +// ---------------------------------------------------------------- schema + +describe('옵션·스키마', () => { + test('모든 옵션에 한국어 메타; 기본값이 문서와 같음', () => { + const shape = (game.optionsSchema as unknown as { shape: Record }).shape; + for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy(); + expect(game.defaultOptions).toEqual({ loseCount: 'auto', turnSeconds: 30, claimHistory: true, quickChat: true, eightKindsRule: false, twoPlayerPass: false }); + expect(game.optionsSchema.safeParse({ loseCount: 6 }).success).toBe(false); + expect(game.optionsSchema.safeParse({ turnSeconds: 20 }).success).toBe(false); + }); + + test('클라이언트 행동만 스키마에 있음', () => { + expect(game.actionSchema.safeParse({ type: 'offer', cardId: 'rat-0', to: 'B', claim: 'rat' }).success).toBe(true); + expect(game.actionSchema.safeParse({ type: 'offer', cardId: 'rat-0', to: 'B', claim: 'dragon' }).success).toBe(false); + expect(game.actionSchema.safeParse({ type: 'call', call: true }).success).toBe(false); + expect(game.actionSchema.safeParse({ type: 'peek' }).success).toBe(true); + expect(game.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false); + }); +}); + +// ---------------------------------------------------------------- properties + +function cfg(i: number): { players: string[]; options: CockroachOptions } { + return { + players: Array.from({ length: 2 + (i % 5) }, (_, k) => `P${k}`), + options: game.optionsSchema.parse({ + loseCount: (['auto', 3, 4, 5] as const)[i % 4], + claimHistory: i % 3 !== 0, + eightKindsRule: i % 7 === 0, + twoPlayerPass: i % 2 === 0, + turnSeconds: ([15, 30, 60] as const)[i % 3], + }), + }; +} +function invariant(st: unknown): void { + checkInvariants(st); + const s = st as CockroachState; + for (const p of game.timeoutPlayers!(s)) { + const v = game.validate(s, p, game.onTimeout(s, p)); + if (!v.ok) throw new Error(`onTimeout illegal: ${v.reason}`); + const c = game.validate(s, p, autoAction(s, p, rng())); + if (!c.ok) throw new Error(`auto action illegal: ${c.reason}`); + } +} + +describe('무작위 대국 (docs/09 §7)', () => { + test('engine randomPlayout, seeds 1–200: 끝남, 카드 보존, 유출 없음, onTimeout 합법, 결정성', () => { + for (let i = 1; i <= 200; i++) { + const { players, options } = cfg(i); + const r = randomPlayout(game, { players, options, seed: seedN(i), maxSteps: 2000, invariant, maxApplyMs: 50 }); + expect(r.finished).toBe(true); + const res = game.result(r.finalState as CockroachState)!; + expect(res.ranking.flat().sort()).toEqual([...players].sort()); + expect(res.ranking[1]!.length).toBe(1); + if (i % 40 === 1) { + const b = randomPlayout(game, { players, options, seed: seedN(i), maxSteps: 2000, maxApplyMs: 50 }); + expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(r.finalState)); + expect(b.trace).toEqual(r.trace); + } + } + }, 60_000); + + test('fast loop, seeds 201–1000 (+ timeouts): 끝남, 카드 보존, 상태 JSON 왕복, 같은 로그 재생 = 같은 결과', () => { + let maxSteps = 0; + for (let i = 201; i <= 1000; i++) { + const { players, options } = cfg(i); + const r = SeededRng.fromSeed(seedN(i)); + const chooser = SeededRng.fromSeed(seedN(i + 5000)); + let s = game.setup({ players, options, rng: r, now: 0 }); + const log: { actor: string; action: CockroachAction }[] = []; + let step = 0; + for (; step < 2000 && !s.final; step++) { + const actor = game.activePlayers(s)[0]!; + const legal = game.legalActions!(s, actor); + const a = chooser.int(10) === 0 ? game.onTimeout(s, actor) : legal[chooser.int(legal.length)]!; + const v = game.validate(s, actor, a); + if (!v.ok) throw new Error(`seed ${i}: ${v.reason}`); + s = game.apply(s, actor, a, { rng: r, now: step * 1000 }).state; + log.push({ actor, action: a }); + checkInvariants(s); + } + maxSteps = Math.max(maxSteps, step); + expect(s.final).not.toBeNull(); + expect(JSON.parse(JSON.stringify(s))).toEqual(s); + if (i % 50 === 0) { + // Replay: same seed + same action log → identical state. + const r2 = SeededRng.fromSeed(seedN(i)); + let t = game.setup({ players, options, rng: r2, now: 0 }); + log.forEach(({ actor, action }, k) => (t = game.apply(t, actor, action, { rng: r2, now: k * 1000 }).state)); + expect(JSON.stringify(t)).toBe(JSON.stringify(s)); + } + } + expect(maxSteps).toBeLessThan(2000); + }, 60_000); +}); diff --git a/packages/games/src/cockroach-poker/index.ts b/packages/games/src/cockroach-poker/index.ts new file mode 100644 index 0000000..981f5c4 --- /dev/null +++ b/packages/games/src/cockroach-poker/index.ts @@ -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 = { + 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; + }, +}; diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index aee25e5..bb25dae 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -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 = { [omok.id]: omok, @@ -43,4 +44,5 @@ export const GAMES: Record = { [splendor.id]: splendor, [quoridor.id]: quoridor, [lasVegas.id]: lasVegas, + [cockroachPoker.id]: cockroachPoker, }; diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index 3dab5b6..b39abcc 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -32,6 +32,7 @@ export const CATALOG: CatalogEntry[] = [ { id: 'splendor', nameKo: '보석 상인', minPlayers: 2, maxPlayers: 4, minutes: '20~40', level: '보통', blurb: '보석을 모아 카드를 사고, 할인으로 더 비싼 카드까지! 귀족 방문은 덤', available: true }, { id: 'quoridor', nameKo: '벽 쌓기 길찾기', minPlayers: 2, maxPlayers: 4, minutes: '10~20', level: '쉬움', blurb: '말을 움직이거나 벽을 세워 먼저 반대편 끝줄로! · 2명 또는 4명', available: true }, { id: 'las-vegas', nameKo: '라스베가스', minPlayers: 2, maxPlayers: 5, minutes: '20~30', level: '쉬움', blurb: '주사위를 같은 숫자 카지노에 몽땅! 개수가 같으면 둘 다 꽝 · 놀이용 지폐', available: true }, + { id: 'cockroach-poker', nameKo: '바퀴벌레 포커', minPlayers: 2, maxPlayers: 6, minutes: '15~20', level: '쉬움', blurb: '벌레 카드를 건네며 "이건 쥐야!" 진짜일까 거짓말일까?', available: true }, ]; export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);