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