M8: 도블 규칙·화면·테스트

- packages/games/src/dobble: 7차 사영평면 덱(57장·57그림, 55장 모드), 카드별 레이아웃 6템플릿,
  미니게임 4종(탑 쌓기·우물·뜨거운 감자·독이 든 선물), 서버 도착 순서 판정과 "늦었어요",
  틀림 잠금(serverTs 기준), 지연 보정 light(60ms 창·보정 상한 80ms), 45초 정체 힌트,
  자리 비움 3회 → 우물·뜨거운 감자 기권, 봇 의심(방장에게만), 순위 점수 3·2·1
- 테스트: §10 체크리스트 전부, 시드 1,000판 + 엔진 randomPlayout(카드 보존·비공개 누출·결정성·onTimeout 합법),
  방 단위 테스트(같은 ms 두 정답, 시스템 행동 거절, 지연 보정 창, 끊긴 사람 기권)
- 화면 apps/web/src/games/dobble: 자체 제작 SVG 그림 57종, 두 원형 카드(PC 좌우·휴대폰 위아래),
  누르는 즉시 판정 표시(찾았어요·틀렸어요 흔들림+잠금 원·늦었어요), 카드가 막 바뀐 직후 탭은 늦음 처리,
  상대 고르기(뜨거운 감자·독이 든 선물), 핑 표시, 키보드 조작, 라이트/다크, 움직임 줄이기
- 등록: GAMES, CATALOG(도블), 화면 레지스트리, 아이콘
- E2E: PC 방장 + 휴대폰 친구, 탑 쌓기 55장을 실제 탭으로 끝까지(오답 1회 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 00:33:29 +09:00
parent bc199de10b
commit 953ae90546
13 changed files with 3674 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
/** Projective-plane deck and card layouts (docs/games/dobble.md §3). */
import type { Rng } from '@bg/engine';
import type { CardLayout } from './types';
export const ORDER = 7;
export const SYMBOLS = ORDER * ORDER + ORDER + 1; // 57
export const FULL_DECK = SYMBOLS; // 57 cards
export const PER_CARD = ORDER + 1; // 8
/** §3.1: the order-n projective plane (n prime). Card i → its n+1 symbol numbers. */
export function buildDeck(n = ORDER): number[][] {
const cards: number[][] = [];
cards.push(Array.from({ length: n + 1 }, (_, i) => i));
for (let j = 0; j < n; j++) {
const c = [0];
for (let k = 0; k < n; k++) c.push(n + 1 + n * j + k);
cards.push(c);
}
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const c = [i + 1];
for (let k = 0; k < n; k++) c.push(n + 1 + n * k + ((i * k + j) % n));
cards.push(c);
}
}
return cards;
}
/** The single symbol two different cards share (projective plane: exactly one). */
export function commonSymbol(a: readonly number[], b: readonly number[]): number {
for (const x of a) if (b.includes(x)) return x;
throw new Error('dobble: cards share no symbol');
}
/**
* §3.3: six circle-packing templates, slot = [x, y, r] in the unit circle. Every slot is at least 0.48 of
* the radius wide (≥ 72px on a 300px card, well over the 48px touch target) and no two slots overlap.
*/
export const TEMPLATES: readonly (readonly [number, number, number])[][] = [
[[-0.448, 0.516, 0.287], [-0.683, -0.028, 0.287], [0.011, -0.014, 0.287], [0.18, -0.659, 0.287], [0.628, -0.271, 0.287], [0.603, 0.322, 0.287], [-0.404, -0.552, 0.287], [0.124, 0.672, 0.287]],
[[0, 0.002, 0.399], [-0.695, 0.005, 0.275], [0.568, -0.4, 0.275], [-0.465, -0.516, 0.275], [0.078, -0.691, 0.275], [0.677, 0.159, 0.275], [-0.457, 0.523, 0.275], [0.33, 0.612, 0.275]],
[[-0.134, 0.603, 0.353], [0.365, -0.498, 0.353], [-0.654, -0.246, 0.271], [-0.261, -0.648, 0.271], [-0.129, -0.044, 0.271], [0.5, 0.488, 0.271], [0.726, 0.003, 0.244], [-0.666, 0.289, 0.244]],
[[-0.033, 0.019, 0.343], [0.051, -0.653, 0.315], [0.595, 0.274, 0.315], [0.59, -0.346, 0.286], [-0.414, 0.545, 0.286], [0.147, 0.683, 0.272], [-0.526, -0.48, 0.257], [-0.662, 0.037, 0.257]],
[[0.008, -0.646, 0.324], [0.059, 0.021, 0.324], [-0.568, -0.307, 0.324], [0.679, 0.108, 0.282], [-0.61, 0.318, 0.282], [-0.146, 0.672, 0.282], [0.568, -0.436, 0.254], [0.404, 0.591, 0.254]],
[[0.363, 0.461, 0.383], [-0.043, -0.128, 0.312], [0.393, -0.562, 0.284], [-0.307, 0.614, 0.284], [-0.591, -0.376, 0.27], [-0.678, 0.177, 0.27], [0.708, -0.1, 0.255], [-0.158, -0.695, 0.241]],
];
/** Fixes one card's look: template, which symbol sits in which slot, and (classic) size and rotation. */
export function makeLayout(symbols: readonly number[], style: 'easy' | 'classic', rng: Rng): CardLayout {
const template = rng.int(TEMPLATES.length);
const slots = TEMPLATES[template]!;
const order = rng.shuffle(symbols);
return {
template,
items: order.map((symbol, i) => {
const [x, y, r] = slots[i]!;
if (style === 'easy') return { symbol, x, y, r, scale: 1, rot: 0 };
const scale = Math.round((0.6 + rng.next() * 0.6) * 100) / 100;
const rot = rng.int(360);
return { symbol, x, y, r, scale, rot };
}),
};
}

View File

@@ -0,0 +1,849 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, checkNoLeak, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import {
AWAY_STEP_MS,
COUNTDOWN_MS,
FAST_MS,
HINT_MS,
LATE_TAKEN,
ROUND_BREAK_MS,
STALL_MS,
SYMBOLS,
SYMBOL_NAMES,
TEMPLATES,
WINDOW_MS,
allCardIds,
answerArt,
buildDeck,
cardKey,
commonSymbol,
dobble,
dueAt,
pairsFor,
type ClaimAction,
type DobbleAction,
type DobbleOptions,
type DobbleState,
} from './index';
// ---------------------------------------------------------------- helpers
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
const T0 = 1_000_000;
const START = T0 + COUNTDOWN_MS;
function make(opts: Partial<DobbleOptions> = {}, players = ['A', 'B', 'C'], seed = 1): DobbleState {
const options = dobble.optionsSchema.parse(opts);
return dobble.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: T0 });
}
function applyE(s: DobbleState, p: string, a: DobbleAction, now: number): { state: DobbleState; events: GameEvent[] } {
const v = dobble.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return dobble.apply(s, p, a, { rng: rng(), now });
}
const act = (s: DobbleState, p: string, a: DobbleAction, now: number) => applyE(s, p, a, now).state;
/** Countdown over → playing. */
const started = (s: DobbleState, now = START) => act(s, s.order[0]!, { type: 'tick' }, now);
/** The correct claim of `p` (on `target` for gift / hot potato). */
function right(s: DobbleState, p: string, target?: string, extra: Partial<ClaimAction> = {}): ClaimAction {
const pair = pairsFor(s, p).find((x) => (x.target ?? undefined) === target);
if (!pair) throw new Error(`${p} has no pair on ${target}`);
return {
type: 'claim',
base: pair.base,
symbol: answerArt(s, p, pair)!,
...(pair.target ? { target: pair.target } : {}),
...(pair.mine !== null ? { mine: pair.mine } : {}),
...extra,
};
}
/** A symbol that is on one compared card but is not the shared one. */
function wrong(s: DobbleState, p: string, target?: string): ClaimAction {
const r = right(s, p, target);
const card = s.options.miniGame === 'poisonedGift' ? s.center.at(-1)! : s.piles[p]!.at(-1)!;
const sym = s.cards[card]!.find((x) => s.symbolArt[x] !== r.symbol)!;
return { ...r, symbol: s.symbolArt[sym]! };
}
const top = (s: DobbleState, p: string) => s.piles[p]!.at(-1);
/** Ranking groups with ids sorted inside each group (seats are shuffled, so tie order follows the seat order). */
const groups = (r: { ranking: PlayerId[][] }) => r.ranking.map((g) => [...g].sort());
function conserved(s: DobbleState): void {
const all = allCardIds(s);
if (all.length !== 57) throw new Error(`card count ${all.length}`);
if (new Set(all).size !== 57) throw new Error('duplicate card');
}
/** Puts the given cards (bottom → top) on the given piles, everything else into the draw pile (tests only). */
function rig(s: DobbleState, r: { piles?: Record<string, number[]>; center?: number[]; draw?: number[] }): DobbleState {
const used = new Set([...Object.values(r.piles ?? {}).flat(), ...(r.center ?? []), ...(r.draw ?? [])]);
for (const p of s.order) {
s.piles[p] = r.piles?.[p] ?? [];
s.taken[p] = [];
}
s.center = r.center ?? [];
s.drawPile = r.draw ?? [];
s.unused = Array.from({ length: 57 }, (_, i) => i).filter((i) => !used.has(i));
return s;
}
// ---------------------------------------------------------------- §3
describe('덱과 카드 모양 (§3)', () => {
test('57장·57종, 카드당 서로 다른 8개, 모든 쌍(1596쌍) 공유 1개, 그림마다 8번', () => {
const deck = buildDeck();
expect(deck).toHaveLength(57);
expect(SYMBOLS).toBe(57);
expect(SYMBOL_NAMES).toHaveLength(57);
expect(new Set(SYMBOL_NAMES).size).toBe(57);
const seen = new Map<number, number>();
for (const c of deck) {
expect(new Set(c).size).toBe(8);
for (const x of c) seen.set(x, (seen.get(x) ?? 0) + 1);
}
expect(seen.size).toBe(57);
expect([...seen.keys()].every((x) => x >= 0 && x < 57)).toBe(true);
expect([...seen.values()].every((n) => n === 8)).toBe(true);
let pairs = 0;
for (let i = 0; i < 57; i++) {
for (let j = i + 1; j < 57; j++) {
expect(deck[i]!.filter((x) => deck[j]!.includes(x))).toHaveLength(1);
pairs++;
}
}
expect(pairs).toBe(1596);
});
test('55장 모드: RNG로 2장을 빼고 남은 55장도 쌍마다 공유 1개', () => {
for (const seed of [1, 2, 3]) {
const s = make({ deckSize: 55, miniGame: 'tower' }, ['A', 'B'], seed);
conserved(s);
expect(s.unused).toHaveLength(2);
const inPlay = allCardIds(s).filter((id) => !s.unused.includes(id));
expect(inPlay).toHaveLength(55);
for (const a of inPlay) for (const b of inPlay) if (a < b) expect(s.cards[a]!.filter((x) => s.cards[b]!.includes(x))).toHaveLength(1);
}
// Different seeds remove different cards.
expect(make({ deckSize: 55 }, ['A', 'B'], 1).unused).not.toEqual(make({ deckSize: 55 }, ['A', 'B'], 2).unused);
});
test('템플릿 6종: 원 안, 겹침 없음, 지름 300px 카드에서 칸이 48px 이상', () => {
expect(TEMPLATES).toHaveLength(6);
for (const t of TEMPLATES) {
expect(t).toHaveLength(8);
for (const [x, y, r] of t) {
expect(Math.hypot(x, y) + r).toBeLessThanOrEqual(1);
expect(2 * r * 150).toBeGreaterThanOrEqual(48);
}
for (let i = 0; i < 8; i++) for (let j = i + 1; j < 8; j++) expect(Math.hypot(t[i]![0] - t[j]![0], t[i]![1] - t[j]![1])).toBeGreaterThanOrEqual(t[i]![2] + t[j]![2]);
}
});
test('레이아웃은 셋업 때 카드마다 확정: classic은 크기 0.6~1.2·회전 0~359, easy는 1·0', () => {
const c = make({ symbolStyle: 'classic' });
const e = make({ symbolStyle: 'easy' });
for (const [s, easy] of [
[c, false],
[e, true],
] as const) {
expect(s.layouts).toHaveLength(57);
s.layouts.forEach((l, id) => {
expect(l.items.map((i) => i.symbol).sort((a, b) => a - b)).toEqual([...s.cards[id]!].sort((a, b) => a - b));
for (const i of l.items) {
if (easy) expect([i.scale, i.rot]).toEqual([1, 0]);
else {
expect(i.scale).toBeGreaterThanOrEqual(0.6);
expect(i.scale).toBeLessThanOrEqual(1.2);
expect(i.rot).toBeGreaterThanOrEqual(0);
expect(i.rot).toBeLessThan(360);
}
}
});
}
expect(new Set(c.layouts.map((l) => l.template)).size).toBeGreaterThan(3);
expect(new Set(c.layouts.flatMap((l) => l.items.map((i) => i.scale))).size).toBeGreaterThan(20);
});
test('그림 번호 → 아이콘 배정·좌석·덱 순서는 시드로 정해지고 판마다 달라요', () => {
const a = make({}, ['A', 'B', 'C'], 1);
const b = make({}, ['A', 'B', 'C'], 1);
const c = make({}, ['A', 'B', 'C'], 2);
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
expect(a.symbolArt).not.toEqual(c.symbolArt);
expect([...a.symbolArt].sort((x, y) => x - y)).toEqual(Array.from({ length: 57 }, (_, i) => i));
expect(a.host).toBe('A');
});
});
// ---------------------------------------------------------------- §4
describe('준비 (§4)', () => {
test('탑 쌓기·독이 든 선물: 각자 1장 공개, 가운데 1장 공개, 나머지는 뽑기 더미. 3초 카운트다운 동안 그림은 안 보여요', () => {
for (const miniGame of ['tower', 'poisonedGift'] as const) {
const s = make({ miniGame }, ['A', 'B', 'C', 'D']);
conserved(s);
for (const p of s.order) expect(s.piles[p]).toHaveLength(1);
expect(s.center).toHaveLength(1);
expect(s.drawPile).toHaveLength(57 - 5);
expect(s.phase).toBe('countdown');
const v = dobble.view(s, 'A');
expect(v.center).toBeNull();
expect(v.seats.every((x) => x.top === null)).toBe(true);
expect(v.pairs).toEqual([]);
expect(dobble.validate(s, 'A', { type: 'claim', base: s.center[0]!, symbol: 0 })).toEqual({ ok: false, reason: '곧 시작해요. 잠시만 기다려 주세요.' });
expect(dueAt(s)).toBe(START);
const p = started(s);
expect(p.phase).toBe('playing');
const v2 = dobble.view(p, 'A');
expect(v2.center?.key).toBe(cardKey(p.center[0]!));
expect(v2.seats.every((x) => x.top !== null)).toBe(true);
}
});
test.each([2, 3, 4, 5, 6, 7, 8])('우물 %i명: 가운데 1장, 1인당 floor((덱-1)/N)장, 나머지는 사용 안 함', (n) => {
const players = Array.from({ length: n }, (_, i) => `P${i}`);
for (const deckSize of [57, 55] as const) {
const s = make({ miniGame: 'well', deckSize }, players);
conserved(s);
const per = Math.floor((deckSize - 1) / n);
for (const p of players) expect(s.piles[p]).toHaveLength(per);
expect(s.center).toHaveLength(1);
expect(s.unused).toHaveLength(57 - 1 - per * n);
// Only the top of each pile is face up (after the countdown).
const v = dobble.view(started(s), players[0]!);
for (const seat of v.seats) expect(seat.top?.key).toBe(cardKey(s.piles[seat.id]!.at(-1)!));
}
});
test('뜨거운 감자: 라운드마다 각자 1장, 8명 × 7라운드도 57장이면 충분, 55장이면 시작 전에 막아요', () => {
const s = make({ miniGame: 'hotPotato' }, ['A', 'B', 'C']);
for (const p of s.order) expect(s.piles[p]).toHaveLength(1);
expect(s.drawPile).toHaveLength(54);
const eight = Array.from({ length: 8 }, (_, i) => `P${i}`);
expect(dobble.playerCountError!(dobble.optionsSchema.parse({ miniGame: 'hotPotato', hotPotatoRounds: 7 }), 8)).toBeNull();
expect(dobble.playerCountError!(dobble.optionsSchema.parse({ miniGame: 'hotPotato', hotPotatoRounds: 7, deckSize: 55 }), 8)).toContain('카드가 모자라요');
expect(dobble.playerCountError!(dobble.optionsSchema.parse({ miniGame: 'hotPotato', hotPotatoRounds: 7, deckSize: 55 }), 7)).toBeNull();
expect(make({ miniGame: 'hotPotato', hotPotatoRounds: 7 }, eight).drawPile).toHaveLength(57 - 8);
});
});
// ---------------------------------------------------------------- §5
describe('찾기 판정 (§5.1, §5.3)', () => {
test('탑 쌓기: 같은 가운데 카드에 두 사람이 정답 → 먼저 도착한 사람만 가져가고, 나중 사람은 "늦었어요"(잠금 없음)', () => {
let s = started(make({}, ['A', 'B', 'C']));
const center = s.center[0]!;
const a = right(s, 'A');
const b = right(s, 'B');
expect(a.base).toBe(center);
expect(b.base).toBe(center);
const { state, events } = applyE(s, 'A', a, START + 500);
s = state;
expect(s.piles.A).toEqual([expect.any(Number), center]);
expect(top(s, 'A')).toBe(center);
expect(s.center).toHaveLength(1);
expect(s.center[0]).not.toBe(center);
expect(events.map((e) => e.type)).toEqual(['hit', 'reveal']);
expect(s.feedback.A).toMatchObject({ kind: 'ok', art: a.symbol });
// B's claim for the same centre card arrives 50ms later.
expect(dobble.validate(s, 'B', b)).toEqual({ ok: false, reason: LATE_TAKEN });
expect(s.lockedUntil.B).toBe(0);
expect(s.stats.B).toMatchObject({ correct: 0, wrong: 0 });
});
test('틀린 그림 → wrongLockoutMs 동안 정답도 무시, 잠금이 풀리면 허용 (serverTs 기준)', () => {
let s = started(make({ wrongLockoutMs: 2000 }, ['A', 'B']));
s = act(s, 'A', wrong(s, 'A'), START + 100);
expect(s.lockedUntil.A).toBe(START + 2100);
expect(s.stats.A!.wrong).toBe(1);
expect(s.feedback.A?.kind).toBe('wrong');
const before = s.piles.A!.length;
s = act(s, 'A', right(s, 'A'), START + 2099);
expect(s.feedback.A?.kind).toBe('locked');
expect(s.piles.A).toHaveLength(before);
s = act(s, 'A', right(s, 'A'), START + 2100);
expect(s.feedback.A?.kind).toBe('ok');
expect(s.piles.A).toHaveLength(before + 1);
});
test('wrongLockoutMs 0: 틀려도 바로 다시 누를 수 있어요', () => {
let s = started(make({ wrongLockoutMs: 0 }, ['A', 'B']));
s = act(s, 'A', wrong(s, 'A'), START + 100);
s = act(s, 'A', right(s, 'A'), START + 100);
expect(s.feedback.A?.kind).toBe('ok');
});
test('house rule wrongGivesCard: 탑 쌓기에서 틀리면 내 탑 맨 위가 뽑기 더미 맨 아래로(마지막 1장은 지켜요)', () => {
let s = started(make({ wrongGivesCard: true, wrongLockoutMs: 0 }, ['A', 'B']));
s = act(s, 'A', wrong(s, 'A'), START + 10);
expect(s.piles.A).toHaveLength(1);
s = act(s, 'A', right(s, 'A'), START + 20);
const myTop = top(s, 'A')!;
const draw = s.drawPile.length;
s = act(s, 'A', wrong(s, 'A'), START + 30);
expect(s.piles.A).toHaveLength(1);
expect(s.drawPile).toHaveLength(draw + 1);
expect(s.drawPile[0]).toBe(myTop);
conserved(s);
});
test('잘못된 요청: 탑·우물은 대상 없음, 선물·감자는 대상 필수, 이미 바뀐 내 카드는 늦음', () => {
const s = started(make({}, ['A', 'B']));
expect(dobble.validate(s, 'A', { ...right(s, 'A'), target: 'B' }).ok).toBe(false);
const g = started(make({ miniGame: 'poisonedGift' }, ['A', 'B']));
expect(dobble.validate(g, 'A', { type: 'claim', base: g.center[0]!, symbol: 1 })).toEqual({ ok: false, reason: '누구 카드에서 찾을지 먼저 골라 주세요.' });
expect(dobble.validate(s, 'Z', right(s, 'A')).ok).toBe(false);
const h = started(make({ miniGame: 'hotPotato' }, ['A', 'B', 'C']));
const c = right(h, 'A', 'B');
expect(dobble.validate(h, 'A', { ...c, mine: (c.mine! + 1) % 57 }).ok).toBe(false);
});
});
describe('미니게임 (§5.2, §6)', () => {
test('탑 쌓기: 뽑기 더미가 끝나면 카드가 가장 많은 사람이 1등, 동점은 공동 순위, 점수 3·2·1', () => {
let s = started(make({}, ['A', 'B', 'C']));
rig(s, { piles: { A: [0], B: [1], C: [2] }, center: [3], draw: [4, 5, 6] });
let now = START;
for (const p of ['A', 'A', 'B', 'C']) s = act(s, p, right(s, p), (now += 300));
expect(s.phase).toBe('ended');
expect(dobble.view(s, 'A').center).toBeNull();
const r = dobble.result(s)!;
expect(groups(r)).toEqual([['A'], ['B', 'C']]);
expect(r.scores).toEqual({ A: 3, B: 2, C: 2 });
expect(r.summary).toBe('탑 쌓기 끝! 카드 3장을 모은 사람이 1등이에요.');
expect(s.final!.ranking[0]).toMatchObject({ id: 'A', rank: 1, cards: 3, points: 3 });
});
test('우물: 내 카드를 가운데로, 다 비운 순서가 순위, 마지막 1명은 꼴찌로 바로 종료', () => {
let s = started(make({ miniGame: 'well' }, ['A', 'B', 'C']));
rig(s, { piles: { A: [0], B: [1, 2], C: [3, 4] }, center: [5] });
let now = START;
const aTop = top(s, 'A')!;
s = act(s, 'A', right(s, 'A'), (now += 200));
expect(s.center.at(-1)).toBe(aTop);
expect(s.status.A).toBe('finished');
expect(s.finishedOrder).toEqual(['A']);
expect(dobble.validate(s, 'A', { type: 'claim', base: s.center.at(-1)!, symbol: 0 })).toEqual({ ok: false, reason: '이미 카드를 다 비웠어요.' });
s = act(s, 'C', right(s, 'C'), (now += 200));
s = act(s, 'C', right(s, 'C'), (now += 200));
expect(s.phase).toBe('ended');
const r = dobble.result(s)!;
expect(groups(r)).toEqual([['A'], ['C'], ['B']]);
expect(r.scores).toEqual({ A: 3, C: 2, B: 1 });
expect(dobble.view(s, null).seats.find((x) => x.id === 'C')!.place).toBe(2);
});
test('우물 wellEndAtFirst: 처음 다 비운 사람이 나오면 바로 끝, 나머지는 남은 장수 순', () => {
let s = started(make({ miniGame: 'well', wellEndAtFirst: true }, ['A', 'B', 'C']));
rig(s, { piles: { A: [0, 1], B: [2, 3, 4], C: [5, 6] }, center: [7] });
let now = START;
s = act(s, 'A', right(s, 'A'), (now += 200));
expect(s.phase).toBe('playing');
s = act(s, 'A', right(s, 'A'), (now += 200));
expect(s.phase).toBe('ended');
expect(groups(dobble.result(s)!)).toEqual([['A'], ['C'], ['B']]);
});
test('뜨거운 감자: 묶음 전체를 상대 위로, 탈출자는 대상 불가, 한 명이 다 가지면 라운드 끝·벌점 누적', () => {
let s = started(make({ miniGame: 'hotPotato', hotPotatoRounds: 3 }, ['A', 'B', 'C']));
rig(s, { piles: { A: [0], B: [1], C: [2] }, draw: Array.from({ length: 10 }, (_, i) => 10 + i) });
let now = START;
s = act(s, 'A', right(s, 'A', 'B'), (now += 300));
expect(s.piles.A).toEqual([]);
expect(s.piles.B).toEqual([1, 0]);
expect(s.status.A).toBe('escaped');
expect(s.outThisRound).toEqual(['A']);
// Nobody may target the escaped A, and A cannot claim this round.
expect(dobble.validate(s, 'C', { type: 'claim', base: 0, target: 'A', symbol: 1 })).toEqual({ ok: false, reason: '이미 이번 라운드를 탈출한 사람이에요.' });
expect(dobble.validate(s, 'A', { type: 'claim', base: 0, target: 'C', symbol: 1 }).ok).toBe(false);
expect(pairsFor(s, 'C').map((x) => x.target).sort()).toEqual(['B']);
// C's claim was made against B's old top → late.
expect(dobble.validate(s, 'C', { type: 'claim', base: 1, target: 'B', symbol: 1 }).ok).toBe(false);
s = act(s, 'C', right(s, 'C', 'B'), (now += 300));
expect(s.phase).toBe('roundEnd');
expect(s.penalty).toEqual({ A: 0, B: 3, C: 0 });
expect(s.taken.B).toEqual([1, 0, 2]);
expect(s.lastRound).toEqual({ round: 1, loser: 'B', cards: 3 });
conserved(s);
// Round 2 after the break + countdown.
s = act(s, 'A', { type: 'tick' }, (now += ROUND_BREAK_MS));
expect(s.phase).toBe('countdown');
expect(s.round).toBe(2);
expect(s.order.every((p) => s.piles[p]!.length === 1 && s.status[p] === 'playing')).toBe(true);
s = act(s, 'A', { type: 'tick' }, (now += COUNTDOWN_MS));
expect(s.phase).toBe('playing');
s = act(s, 'B', right(s, 'B', 'A'), (now += 300));
s = act(s, 'C', right(s, 'C', 'A'), (now += 300));
expect(s.penalty).toEqual({ A: 3, B: 3, C: 0 });
s = act(s, 'A', { type: 'tick' }, (now += ROUND_BREAK_MS));
s = act(s, 'A', { type: 'tick' }, (now += COUNTDOWN_MS));
s = act(s, 'B', right(s, 'B', 'C'), (now += 300));
s = act(s, 'A', right(s, 'A', 'C'), (now += 300));
expect(s.phase).toBe('ended');
expect(s.penalty).toEqual({ A: 3, B: 3, C: 3 });
expect(groups(dobble.result(s)!)).toEqual([['A', 'B', 'C']]);
expect(dobble.result(s)!.summary).toContain('공동 1등 3명');
conserved(s);
});
test('뜨거운 감자: 설정한 라운드가 끝나면 벌점이 가장 적은 사람이 1등', () => {
let s = started(make({ miniGame: 'hotPotato', hotPotatoRounds: 3 }, ['A', 'B']));
let now = START;
for (const loser of ['A', 'A', 'B']) {
const winner = loser === 'A' ? 'B' : 'A';
s = act(s, winner, right(s, winner, loser), (now += 400));
if (s.phase === 'ended') break;
s = act(s, 'A', { type: 'tick' }, (now += ROUND_BREAK_MS));
s = act(s, 'A', { type: 'tick' }, (now += COUNTDOWN_MS));
}
expect(s.phase).toBe('ended');
expect(s.penalty).toEqual({ A: 4, B: 2 });
const r = dobble.result(s)!;
expect(groups(r)).toEqual([['B'], ['A']]);
expect(s.final!.ranking.find((x) => x.id === 'B')).toMatchObject({ penalty: 2, points: 3 });
});
test('독이 든 선물: 내 카드에는 못 올리고, 더미가 끝나면 카드가 가장 적은 사람이 1등(동점 공동)', () => {
let s = started(make({ miniGame: 'poisonedGift' }, ['A', 'B', 'C']));
rig(s, { piles: { A: [0], B: [1], C: [2] }, center: [3], draw: [4, 5] });
expect(dobble.validate(s, 'A', { type: 'claim', base: 3, target: 'A', symbol: 1 })).toEqual({ ok: false, reason: '내 카드에는 선물을 줄 수 없어요.' });
expect(pairsFor(s, 'A').map((x) => x.target).sort()).toEqual(['B', 'C']);
let now = START;
s = act(s, 'A', right(s, 'A', 'B'), (now += 300));
expect(s.piles.B).toEqual([1, 3]);
s = act(s, 'B', right(s, 'B', 'C'), (now += 300));
s = act(s, 'C', right(s, 'C', 'B'), (now += 300));
expect(s.phase).toBe('ended');
const r = dobble.result(s)!;
expect(groups(r)).toEqual([['A'], ['C'], ['B']]);
expect(r.summary).toBe('독이 든 선물 끝! 카드를 1장만 받은 사람이 1등이에요.');
// Tie: two players with the fewest cards share 1st.
let t = started(make({ miniGame: 'poisonedGift' }, ['A', 'B', 'C']));
rig(t, { piles: { A: [0], B: [1], C: [2] }, center: [3] });
t = act(t, 'A', right(t, 'A', 'C'), START + 100);
expect(groups(dobble.result(t)!)).toEqual([['A', 'B'], ['C']]);
expect(dobble.result(t)!.scores).toEqual({ A: 3, B: 3, C: 1 });
});
});
describe('지연 보정 light (§5.3)', () => {
function duel(delayMs: number) {
let s = started(make({ latencyComp: 'light' }, ['A', 'B']));
s.rtt = { A: 200, B: 20 };
const t = START + 1000;
const b = right(s, 'B', undefined, { rtt: 20 });
const a = right(s, 'A', undefined, { rtt: 200 });
s = act(s, 'B', b, t);
expect(s.pendingWindow?.claims).toHaveLength(1);
expect(s.center).toHaveLength(1);
const v = dobble.validate(s, 'A', a);
if (v.ok) s = act(s, 'A', a, t + delayMs);
// The server closes the window when its deadline comes.
if (s.pendingWindow) {
expect(dobble.onTimeout(s, 'A')).toEqual({ type: 'resolve' });
expect(dueAt(s)).toBe(t + WINDOW_MS);
s = act(s, 'A', { type: 'resolve' }, t + WINDOW_MS);
}
return s;
}
test('상대 RTT 20ms(보정 10ms), 내 RTT 200ms(보정 80ms): 40ms 늦게 도착해도 보정 시각이 30ms 빨라 승리', () => {
const s = duel(40);
expect(s.stats.A!.correct).toBe(1);
expect(s.stats.B!.correct).toBe(0);
expect(s.feedback.B?.kind).toBe('late');
expect(s.lastHit?.player).toBe('A');
});
test('100ms 늦게 도착하면 60ms 창 밖이라 패배("늦었어요")', () => {
const s = duel(100);
expect(s.stats.B!.correct).toBe(1);
expect(s.stats.A!.correct).toBe(0);
expect(s.feedback.A?.kind).toBe('late');
});
test('RTT는 찾기에 실린 값의 지수이동평균(α=0.2), 보정 상한 80ms', () => {
let s = started(make({ latencyComp: 'light' }, ['A', 'B']));
s = act(s, 'A', wrong(s, 'A'), START + 10);
s = act(s, 'B', { ...wrong(s, 'B'), rtt: 100 }, START + 20);
s = act(s, 'B', { ...wrong(s, 'B'), rtt: 200 }, START + 3000);
expect(s.rtt.B).toBe(120);
expect(s.rtt.A).toBeNull();
s = act(s, 'B', { ...right(s, 'B'), rtt: 5000 }, START + 6000);
expect(s.rtt.B).toBe(496);
expect(s.pendingWindow!.claims[0]!.adjTs).toBe(START + 6000 - 80);
});
});
describe('시간 초과·자리 비움·나가기 (§8.5)', () => {
test('onTimeout은 tick(창이 열려 있으면 resolve), 45초 동안 아무도 못 맞히면 공통 그림을 2초 보여 주고 계속', () => {
let s = started(make({}, ['A', 'B']));
expect(dobble.onTimeout(s, 'A')).toEqual({ type: 'tick' });
expect(dueAt(s)).toBe(START + STALL_MS);
s = act(s, 'A', { type: 'tick' }, START + STALL_MS);
expect(s.hintUntil).toBe(START + STALL_MS + HINT_MS);
const v = dobble.view(s, 'A');
expect(v.hint).toEqual([right(s, 'A').symbol]);
expect(dobble.view(s, 'B').hint).toEqual([right(s, 'B').symbol]);
expect(dobble.view(s, null).hint).toEqual([]);
s = act(s, 'B', { type: 'tick' }, START + STALL_MS + HINT_MS);
expect(s.hintUntil).toBe(0);
expect(dobble.view(s, 'A').hint).toEqual([]);
expect(s.stats.A!.correct + s.stats.B!.correct).toBe(0);
expect(dueAt(s)).toBe(START + STALL_MS + HINT_MS + STALL_MS);
});
test('우물: 30초 넘게 끊긴 사람은 기권(카드는 상자로) → 꼴찌 그룹, 남은 사람끼리 계속', () => {
let s = started(make({ miniGame: 'well' }, ['A', 'B', 'C']));
// The room times out a disconnected player before the game deadline only after its grace period:
// such early ticks come at most every AWAY_STEP_MS (timeoutNotBefore) and are strikes.
let now = START + 30_000;
expect(dobble.timeoutNotBefore!(s)).toBeLessThanOrEqual(now);
for (let i = 0; i < 3; i++) {
expect(s.status.C).toBe('playing');
s = act(s, 'C', { type: 'tick' }, now);
expect(dobble.timeoutNotBefore!(s)).toBe(now + AWAY_STEP_MS);
// Someone keeps playing meanwhile (the stall timer must not swallow the strikes).
s = act(s, 'A', right(s, 'A'), now + 5000);
now += AWAY_STEP_MS;
}
expect(s.status.C).toBe('forfeit');
expect(s.piles.C).toEqual([]);
expect(s.phase).toBe('playing');
conserved(s);
expect(dobble.activePlayers(s)).toEqual(s.order.filter((p) => p !== 'C'));
expect(dobble.validate(s, 'C', { type: 'claim', base: s.center.at(-1)!, symbol: 0 }).ok).toBe(false);
// A and B play on; the forfeited C ends in the bottom group.
while (s.phase === 'playing') s = act(s, 'B', right(s, 'B'), (now += 300));
const r = dobble.result(s)!;
expect(r.ranking.at(-1)).toEqual(['C']);
expect(s.final!.ranking.find((x) => x.id === 'C')).toMatchObject({ status: 'forfeit', points: 0 });
});
test('짧게 끊겼다 돌아오면(찾기를 하면) 기권 카운트가 초기화돼요', () => {
let s = started(make({ miniGame: 'well' }, ['A', 'B', 'C']));
let now = START + 60_000;
s = act(s, 'C', { type: 'tick' }, now);
s = act(s, 'C', { type: 'tick' }, (now += AWAY_STEP_MS));
s = act(s, 'C', wrong(s, 'C'), (now += 1000));
s = act(s, 'C', { type: 'tick' }, (now += AWAY_STEP_MS));
expect(s.status.C).toBe('playing');
expect(s.away.C!.strikes).toBe(1);
});
test('탑 쌓기: 끊긴 사람이 있어도 그대로 진행, 모두 자리를 비우면 끝내요', () => {
let s = started(make({}, ['A', 'B']));
let now = START + 10_000;
for (let i = 0; i < 3; i++) {
s = act(s, 'B', { type: 'tick' }, now);
s = act(s, 'A', right(s, 'A'), now + 1000);
now += AWAY_STEP_MS;
}
expect(s.away.B!.strikes).toBe(3);
expect(s.status.B).toBe('playing');
expect(s.phase).toBe('playing');
// Now both are away: the room ticks whoever is first in timeoutPlayers once timeoutNotBefore passes.
for (let i = 0; i < 20 && s.phase === 'playing'; i++) {
now = Math.max(now, dobble.timeoutNotBefore!(s)!);
const who = dobble.timeoutPlayers!(s)[0]!;
s = act(s, who, { type: 'tick' }, now);
}
expect(s.phase).toBe('ended');
expect(dobble.result(s)!.summary).toBe('모두 자리를 비워서 게임을 끝냈어요.');
});
test('나가기: 탑 쌓기는 탑이 남고 꼴찌, 1명만 남으면 끝', () => {
let s = started(make({}, ['A', 'B', 'C']));
expect(dobble.onLeave!(s, 'C')).toEqual({ type: 'leave' });
s = act(s, 'C', { type: 'leave' }, START + 100);
expect(s.status.C).toBe('left');
expect(s.piles.C).toHaveLength(1);
expect(dobble.onLeave!(s, 'C')).toBeNull();
s = act(s, 'B', { type: 'leave' }, START + 200);
expect(s.phase).toBe('ended');
const r = dobble.result(s)!;
expect(groups(r)).toEqual([['A'], ['B', 'C']]);
expect(r.summary).toContain('다른 사람이 모두 나가서');
});
test('나가기: 뜨거운 감자는 대상에서 빠지고 카드는 상자로, 겨룰 사람이 없어지면 그 라운드는 무효', () => {
let s = started(make({ miniGame: 'hotPotato' }, ['A', 'B', 'C']));
s = act(s, 'A', right(s, 'A', 'B'), START + 300);
s = act(s, 'C', { type: 'leave' }, START + 400);
expect(s.phase).toBe('roundEnd');
expect(s.lastRound).toEqual({ round: 1, loser: null, cards: 0 });
expect(s.penalty).toEqual({ A: 0, B: 0, C: 0 });
conserved(s);
s = act(s, 'A', { type: 'tick' }, START + 400 + ROUND_BREAK_MS);
expect(s.piles.C).toEqual([]);
expect(s.piles.A).toHaveLength(1);
});
test('onTimeout은 모든 상태에서 합법', () => {
let s = make({ miniGame: 'hotPotato', latencyComp: 'light' }, ['A', 'B', 'C']);
for (const p of dobble.timeoutPlayers!(s)) expect(dobble.validate(s, p, dobble.onTimeout(s, p)).ok).toBe(true);
s = started(s);
s = act(s, 'A', right(s, 'A', 'B'), START + 10);
for (const p of dobble.timeoutPlayers!(s)) expect(dobble.validate(s, p, dobble.onTimeout(s, p)).ok).toBe(true);
});
});
describe('비공개 정보·봇 의심·결정성 (§8.3, §10)', () => {
test('view에는 뽑기 더미·우물 뒷면·미사용 카드가 없다: 숨긴 카드를 섞어도 view가 같다', () => {
for (const miniGame of ['tower', 'well', 'hotPotato', 'poisonedGift'] as const) {
const s = started(make({ miniGame, deckSize: 55 }, ['A', 'B', 'C']));
const t = structuredClone(s);
// Swap hidden cards around (draw pile ↔ box ↔ well face-down cards).
const hidden = [...t.drawPile, ...t.unused, ...(miniGame === 'well' ? t.order.flatMap((p) => t.piles[p]!.slice(0, -1)) : [])];
const shuffled = SeededRng.fromSeed([1, 2, 3, 4]).shuffle(hidden);
let k = 0;
t.drawPile = t.drawPile.map(() => shuffled[k++]!);
t.unused = t.unused.map(() => shuffled[k++]!);
if (miniGame === 'well') for (const p of t.order) t.piles[p] = [...t.piles[p]!.slice(0, -1).map(() => shuffled[k++]!), t.piles[p]!.at(-1)!];
for (const viewer of ['A', 'B', null]) expect(JSON.stringify(dobble.view(t, viewer))).toBe(JSON.stringify(dobble.view(s, viewer)));
for (const p of s.order) checkNoLeak(dobble, s, p, s.order);
// Layouts of hidden cards are never sent.
const v = JSON.stringify(dobble.view(s, 'A'));
for (const id of s.drawPile) expect(v).not.toContain(`"key":"${cardKey(id)}"`);
expect(v).not.toContain('"symbol"');
expect(v).not.toContain('rng');
}
});
test('카운트다운 중에는 모든 카드가 비밀(덱이 미리 보이지 않음)', () => {
const s = make({ miniGame: 'hotPotato' }, ['A', 'B']);
expect(dobble.secretsOf!(s, 'A')).toContain(cardKey(s.piles.A![0]!));
for (const p of s.order) checkNoLeak(dobble, s, p, s.order);
});
test('봇 의심: 카드가 바뀐 뒤 200ms 안에 맞히기를 5번 연속 → 방장 view에만 표시', () => {
let s = started(make({ wrongLockoutMs: 0 }, ['A', 'B', 'C']));
let now = START;
for (let i = 0; i < 5; i++) {
expect(s.suspect).toEqual([]);
s = act(s, 'B', right(s, 'B'), (now += FAST_MS - 50));
}
expect(s.suspect).toEqual(['B']);
expect(dobble.view(s, 'A').suspects).toEqual(['B']);
expect(dobble.view(s, 'A').isHost).toBe(true);
expect(dobble.view(s, 'B').suspects).toEqual([]);
expect(dobble.view(s, 'C').suspects).toEqual([]);
expect(dobble.view(s, null).suspects).toEqual([]);
// A slow answer resets the streak.
let t = started(make({}, ['A', 'B']));
let n = START;
for (let i = 0; i < 4; i++) t = act(t, 'B', right(t, 'B'), (n += 100));
t = act(t, 'B', right(t, 'B'), (n += 900));
expect(t.stats.B!.fastHits).toBe(0);
});
test('이벤트에는 공개된 카드만 담긴다', () => {
let s = started(make({}, ['A', 'B']));
const { state, events } = applyE(s, 'A', right(s, 'A'), START + 300);
s = state;
const secrets = dobble.secretsOf!(s, 'A');
for (const e of events) for (const k of secrets) expect(JSON.stringify(e)).not.toContain(`"${k}"`);
expect(events.find((e) => e.type === 'reveal')).toEqual({ type: 'reveal', key: cardKey(s.center.at(-1)!) });
});
test('같은 시드 + 액션 로그(serverTs 포함) 재생 → 같은 결과', () => {
for (const opts of [{}, { miniGame: 'hotPotato', latencyComp: 'light' }, { miniGame: 'well' }] as Partial<DobbleOptions>[]) {
const options = dobble.optionsSchema.parse(opts);
const a = fastPlayout(['A', 'B', 'C', 'D'], options, seedN(42), 20_000);
expect(a.finished).toBe(true);
const replay = (log: FastReport['log']) => {
let s = dobble.setup({ players: ['A', 'B', 'C', 'D'], options, rng: SeededRng.fromSeed(seedN(42)), now: 1_700_000_000_000 });
for (const x of log) s = dobble.apply(s, x.p, x.a, { rng: rng(), now: x.now }).state;
return s;
};
expect(JSON.stringify(replay(a.log))).toBe(JSON.stringify(a.final));
}
});
});
// ---------------------------------------------------------------- random playouts
interface FastReport {
steps: number;
finished: boolean;
log: { p: PlayerId; a: DobbleAction; now: number }[];
final: DobbleState;
}
function invariants(s: DobbleState): void {
conserved(s);
const o = s.options;
for (const p of s.order) {
const st = s.status[p]!;
if (o.miniGame === 'well' && st === 'finished' && s.piles[p]!.length) throw new Error('finished with cards');
if ((o.miniGame === 'tower' || o.miniGame === 'poisonedGift') && s.piles[p]!.length < 1) throw new Error(`${p} lost the starting card`);
if (o.miniGame === 'hotPotato' && s.penalty[p] !== s.taken[p]!.length) throw new Error('penalty != taken');
if (st === 'escaped' && (o.miniGame !== 'hotPotato' || s.piles[p]!.length)) throw new Error('bad escaped');
}
if (s.phase === 'playing') {
if (s.center.length > 0 && o.miniGame !== 'well' && s.center.length !== 1) throw new Error('centre holds more than one card');
const live = s.order.filter((p) => s.status[p] !== 'forfeit' && s.status[p] !== 'left');
if (live.length < 2) throw new Error('game should have ended');
if (o.miniGame === 'hotPotato' && s.order.filter((p) => s.status[p] === 'playing' && s.piles[p]!.length).length < 2) throw new Error('round should have ended');
if (o.miniGame !== 'hotPotato' && s.center.length === 0) throw new Error('no centre card');
}
if (s.phase === 'ended' && !s.final) throw new Error('ended without result');
}
/**
* Time moves 20–1500ms per step. Mostly correct claims (some made against an older table → late),
* some wrong ones, server ticks when due, and now and then a disconnected player's early tick or a leave.
*/
function fastPlayout(players: PlayerId[], options: DobbleOptions, seed: RngState, maxSteps: number, away: PlayerId | null = null): FastReport {
const r = SeededRng.fromSeed(seed);
const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]);
let now = 1_700_000_000_000;
let s = dobble.setup({ players, options, rng: r, now });
const log: FastReport['log'] = [];
let stale: { p: PlayerId; a: ClaimAction } | null = null;
for (let step = 0; step < maxSteps; step++) {
invariants(s);
if (step % 20 === 0) checkNoLeak(dobble, s, players[step % players.length]!, players);
for (const p of dobble.timeoutPlayers!(s)) {
const v = dobble.validate(s, p, dobble.onTimeout(s, p));
if (!v.ok) throw new Error(`onTimeout rejected for ${p}: ${v.reason}`);
}
if (dobble.result(s)) return { steps: step, finished: true, log, final: s };
now += 20 + chooser.int(1480);
const due = dueAt(s)!;
let actor: PlayerId;
let action: DobbleAction;
const roll = chooser.int(100);
const claimers = s.order.filter((p) => p !== away && pairsFor(s, p).length > 0);
const notBefore = dobble.timeoutNotBefore!(s);
if (away && notBefore !== null && now >= notBefore && now < due && dobble.timeoutPlayers!(s).includes(away)) {
// The disconnected player is timed out as soon as timeoutNotBefore allows (the room does the same after grace).
actor = away;
action = dobble.onTimeout(s, away);
} else if (now >= due || claimers.length === 0) {
if (now < due) now = due;
const tp = dobble.timeoutPlayers!(s);
actor = tp[chooser.int(tp.length)]!;
action = dobble.onTimeout(s, actor);
} else if (roll < 2 && dobble.timeoutPlayers!(s).length > 0) {
// A disconnected player's early timeout (only fires once per away step, as the room would).
const nb = dobble.timeoutNotBefore!(s)!;
if (now < nb) now = nb;
const tp = dobble.timeoutPlayers!(s);
actor = tp[0]!;
action = dobble.onTimeout(s, actor);
} else if (roll < 3) {
const live = s.order.filter((p) => dobble.onLeave!(s, p));
actor = live[chooser.int(live.length)]!;
action = { type: 'leave' };
} else if (roll < 10 && stale) {
actor = stale.p;
action = stale.a;
} else {
actor = claimers[chooser.int(claimers.length)]!;
const pairs = pairsFor(s, actor);
const pair = pairs[chooser.int(pairs.length)]!;
const ans = answerArt(s, actor, pair)!;
const wrongOne = roll < 25;
const cc = s.options.miniGame === 'poisonedGift' ? s.center.at(-1)! : s.piles[actor]!.at(-1)!;
const sym = wrongOne ? s.symbolArt[s.cards[cc]![chooser.int(8)]!]! : ans;
action = {
type: 'claim',
base: pair.base,
symbol: sym,
...(pair.target ? { target: pair.target } : {}),
...(pair.mine !== null ? { mine: pair.mine } : {}),
rtt: chooser.int(300),
};
stale = { p: claimers[chooser.int(claimers.length)]!, a: action as ClaimAction };
}
const v = dobble.validate(s, actor, action);
if (!v.ok) {
if (action.type !== 'claim') throw new Error(`generated action rejected: ${JSON.stringify(action)} ${v.reason}`);
continue;
}
const out = dobble.apply(s, actor, action, { rng: r, now });
const secrets = dobble.secretsOf!(out.state, actor);
for (const e of out.events) {
const j = JSON.stringify(e);
for (const k of secrets) if (j.includes(`"${k}"`)) throw new Error(`event leaks ${k}`);
}
s = out.state;
log.push({ p: actor, a: action, now });
}
return { steps: maxSteps, finished: false, log, final: s };
}
const PRESETS: Partial<DobbleOptions>[] = [
{},
{ miniGame: 'well' },
{ miniGame: 'hotPotato' },
{ miniGame: 'poisonedGift' },
{ miniGame: 'tower', latencyComp: 'light', wrongGivesCard: true, deckSize: 55 },
{ miniGame: 'well', wellEndAtFirst: true, wrongLockoutMs: 0, symbolStyle: 'easy' },
{ miniGame: 'hotPotato', hotPotatoRounds: 7, latencyComp: 'light', wrongLockoutMs: 2000 },
{ miniGame: 'poisonedGift', deckSize: 55, latencyComp: 'light' },
{ miniGame: 'hotPotato', hotPotatoRounds: 3, deckSize: 55 },
];
describe('random playouts (§10, docs/09 §7)', () => {
test('1,000 seeded games: finish, 57 cards conserved, no leaks, onTimeout always legal, deterministic', () => {
const tally: Record<string, number> = { correct: 0, wrong: 0, forfeit: 0, left: 0 };
for (let i = 1; i <= 1000; i++) {
const n = 2 + (i % 7);
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = dobble.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
// Every 5th game one player is disconnected for the whole game (never claims).
const away = i % 5 === 0 ? players[1]! : null;
const a = fastPlayout(players, options, seedN(i), 20_000, away);
if (!a.finished) throw new Error(`seed ${i} (${n} players, ${JSON.stringify(PRESETS[i % PRESETS.length])}) did not finish`);
const res = dobble.result(a.final)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
for (const p of players) {
tally.correct! += a.final.stats[p]!.correct;
tally.wrong! += a.final.stats[p]!.wrong;
if (a.final.status[p] === 'forfeit') tally.forfeit!++;
if (a.final.status[p] === 'left') tally.left!++;
}
if (i % 50 === 0) {
const b = fastPlayout(players, options, seedN(i), 20_000, away);
expect(JSON.stringify(b.log)).toBe(JSON.stringify(a.log));
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(tally.correct).toBeGreaterThan(10_000);
expect(tally.wrong).toBeGreaterThan(1000);
expect(tally.forfeit).toBeGreaterThan(0);
expect(tally.left).toBeGreaterThan(0);
}, 60_000);
test.each([
[2, {}],
[3, { miniGame: 'well' }],
[4, { miniGame: 'hotPotato', hotPotatoRounds: 3 }],
[5, { miniGame: 'poisonedGift', latencyComp: 'light' }],
[8, { miniGame: 'tower', wrongGivesCard: true }],
] as const)('engine randomPlayout (per-step leak / round-trip checks): %i players %o', (n, opts) => {
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = dobble.optionsSchema.parse(opts);
const inv = (st: unknown) => invariants(st as DobbleState);
for (let i = 1; i <= 4; i++) {
const a = randomPlayout(dobble, { players, options, seed: seedN(700 + i), maxSteps: 20_000, invariant: inv, maxApplyMs: 20 });
expect(a.finished).toBe(true);
if (i === 1) {
const b = randomPlayout(dobble, { players, options, seed: seedN(700 + i), maxSteps: 20_000, maxApplyMs: 20 });
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
}
}
});
});
test('공통 그림 헬퍼', () => {
const d = buildDeck();
expect(d[0]!).toContain(commonSymbol(d[0]!, d[1]!));
expect(d[1]!).toContain(commonSymbol(d[0]!, d[1]!));
});

View File

@@ -0,0 +1,856 @@
/**
* 도블 — 같은 그림 찾기 (docs/games/dobble.md). Pure and deterministic; state is JSON; time only via ctx.now.
* Real time: everyone may claim at any moment, the room's single-threaded queue (arrival order) decides.
*/
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
import { FULL_DECK, buildDeck, makeLayout } from './deck';
import {
MINI_GAME_KO,
dobbleActionSchema,
dobbleOptionsSchema,
type CardView,
type ClaimAction,
type ClaimPair,
type DobbleAction,
type DobbleFinal,
type DobbleOptions,
type DobbleState,
type DobbleView,
type Feedback,
type LogEntry,
type PendingClaim,
type RankRow,
type SeatStatus,
} from './types';
export * from './types';
export { FULL_DECK, PER_CARD, SYMBOLS, TEMPLATES, buildDeck, commonSymbol, makeLayout } from './deck';
type S = DobbleState;
type A = DobbleAction;
export const COUNTDOWN_MS = 3000;
/** Hot potato: pause showing the round's loser before the next countdown. */
export const ROUND_BREAK_MS = 2500;
/** §5.3 `latencyComp: 'light'` window. */
export const WINDOW_MS = 60;
export const MAX_LATENCY_CREDIT_MS = 80;
/** §8.5 stall: nobody found anything for this long → show the common symbol for HINT_MS. */
export const STALL_MS = 45_000;
export const HINT_MS = 2000;
/**
* Away detection: the room only times a player out before the game's own deadline when they have been
* disconnected past the grace period. Such early ticks are spaced AWAY_STEP_MS apart; AWAY_STRIKES of them
* (≥ grace + 20s, so always ≥ 30s) forfeit the player in well / hot potato (§8.5).
*/
export const AWAY_STEP_MS = 10_000;
export const AWAY_STRIKES = 3;
/** §5.3 봇 의심: FAST_STREAK correct claims in a row, each < FAST_MS after the cards changed. */
export const FAST_MS = 200;
export const FAST_STREAK = 5;
const RTT_ALPHA = 0.2;
const LOG_KEEP = 30;
const POINTS = [3, 2, 1];
/** Public key of a visible card; secretsOf uses the same form so leak checks are meaningful. */
export const cardKey = (id: number): string => `dc${id}`;
export const LATE_TAKEN = '늦었어요! 다른 사람이 먼저 찾았어요.';
export const LATE_CHANGED = '늦었어요! 그 사이에 카드가 바뀌었어요.';
export const lockedText = (ms: number): string => `잠깐! ${Math.max(1, Math.ceil(ms / 1000))}초 뒤에 다시 눌러요`;
// ---------------------------------------------------------------- queries
const topOf = (s: S, p: PlayerId): number | undefined => s.piles[p]?.[s.piles[p]!.length - 1];
const centerTop = (s: S): number | undefined => s.center[s.center.length - 1];
/** Still in the game (not forfeited / left). */
const inGame = (s: S, p: PlayerId): boolean => s.status[p] !== 'forfeit' && s.status[p] !== 'left';
const usesTarget = (o: DobbleOptions): boolean => o.miniGame === 'hotPotato' || o.miniGame === 'poisonedGift';
/** Every card id, wherever it is (test invariant: each id exactly once). */
export function allCardIds(s: S): number[] {
return [...s.order.flatMap((p) => [...s.piles[p]!, ...s.taken[p]!]), ...s.center, ...s.drawPile, ...s.unused];
}
export function canClaim(s: S, p: PlayerId): boolean {
if (s.phase !== 'playing' || s.status[p] !== 'playing') return false;
return s.options.miniGame === 'poisonedGift' || s.piles[p]!.length > 0;
}
/** Whether `q` may be the target of `p`'s claim (gift / hot potato). */
function validTarget(s: S, p: PlayerId, q: PlayerId): boolean {
return q !== p && s.order.includes(q) && s.status[q] === 'playing' && s.piles[q]!.length > 0;
}
/** Pairs of cards `p` may claim on right now (§5.2). */
export function pairsFor(s: S, p: PlayerId): ClaimPair[] {
if (!canClaim(s, p)) return [];
const c = centerTop(s);
const mine = topOf(s, p);
switch (s.options.miniGame) {
case 'tower':
case 'well':
return c === undefined || mine === undefined ? [] : [{ target: null, base: c, mine }];
case 'poisonedGift':
return c === undefined ? [] : s.order.filter((q) => validTarget(s, p, q)).map((q) => ({ target: q, base: c, mine: null }));
case 'hotPotato':
return mine === undefined ? [] : s.order.filter((q) => validTarget(s, p, q)).map((q) => ({ target: q, base: topOf(s, q)!, mine }));
}
}
/** The two card ids compared by a claim of `p` on `target` (null = the claim cannot be judged). */
function comparedCards(s: S, p: PlayerId, target: PlayerId | undefined): [number, number] | null {
const c = centerTop(s);
const mine = topOf(s, p);
switch (s.options.miniGame) {
case 'tower':
case 'well':
return c === undefined || mine === undefined ? null : [mine, c];
case 'poisonedGift': {
const t = target === undefined ? undefined : topOf(s, target);
return c === undefined || t === undefined ? null : [t, c];
}
case 'hotPotato': {
const t = target === undefined ? undefined : topOf(s, target);
return mine === undefined || t === undefined ? null : [mine, t];
}
}
}
/** The single symbol number shared by two cards. */
function common(s: S, a: number, b: number): number {
const x = s.cards[a]!;
const y = s.cards[b]!;
for (const v of x) if (y.includes(v)) return v;
throw new Error(`dobble: cards ${a} and ${b} share nothing`);
}
/** The correct icon id for a pair (tests / legalActions / stall hint). */
export function answerArt(s: S, p: PlayerId, pair: ClaimPair): number | null {
const cc = comparedCards(s, p, pair.target ?? undefined);
return cc ? s.symbolArt[common(s, cc[0], cc[1])]! : null;
}
/** Reference card id a claim on `target` must carry. */
function currentBase(s: S, target: PlayerId | undefined): number | undefined {
return s.options.miniGame === 'hotPotato' ? (target === undefined ? undefined : topOf(s, target)) : centerTop(s);
}
/** Earliest time the server timer has something to do (null when ended). */
export function dueAt(s: S): number | null {
if (s.phase === 'ended') return null;
if (s.phase === 'countdown' || s.phase === 'roundEnd') return s.phaseEndsAt;
if (s.pendingWindow) return s.pendingWindow.openedAt + WINDOW_MS;
return s.hintUntil > 0 ? s.hintUntil : s.changedAt + STALL_MS;
}
// ---------------------------------------------------------------- helpers
function clone(s: S): S {
const rec = <T>(r: Record<PlayerId, T>, f: (v: T) => T) => Object.fromEntries(Object.entries(r).map(([k, v]) => [k, f(v)])) as Record<PlayerId, T>;
return {
...s, // cards, layouts, symbolArt and options are never mutated
order: [...s.order],
status: { ...s.status },
piles: rec(s.piles, (v) => [...v]),
center: [...s.center],
drawPile: [...s.drawPile],
unused: [...s.unused],
taken: rec(s.taken, (v) => [...v]),
lockedUntil: { ...s.lockedUntil },
pendingWindow: s.pendingWindow ? { ...s.pendingWindow, claims: s.pendingWindow.claims.map((c) => ({ ...c })) } : null,
penalty: { ...s.penalty },
outThisRound: [...s.outThisRound],
finishedOrder: [...s.finishedOrder],
away: rec(s.away, (v) => ({ ...v })),
rtt: { ...s.rtt },
stats: rec(s.stats, (v) => ({ ...v })),
suspect: [...s.suspect],
feedback: { ...s.feedback },
log: [...s.log],
};
}
interface Ctx {
now: number;
events: GameEvent[];
}
type LogBody = LogEntry extends infer E ? (E extends LogEntry ? Omit<E, 'n'> : never) : never;
function log(s: S, e: LogBody): void {
s.logNo += 1;
s.log.push({ n: s.logNo, ...e } as LogEntry);
if (s.log.length > LOG_KEEP) s.log.splice(0, s.log.length - LOG_KEEP);
}
function judge(s: S, p: PlayerId, kind: Feedback['kind'], art: number | null, ctx: Ctx): void {
s.seq += 1;
s.feedback[p] = { seq: s.seq, kind, art, at: ctx.now };
}
/** Public cards changed: restart the stall timer and cancel a showing hint. */
function cardsChanged(s: S, now: number): void {
s.changedAt = now;
s.hintUntil = 0;
}
/** Turns the next hidden card face up in the centre (tower / gift); false when the draw pile is empty. */
function revealNext(s: S, ctx: Ctx): boolean {
const id = s.drawPile.pop();
if (id === undefined) return false;
s.center.push(id);
ctx.events.push({ type: 'reveal', key: cardKey(id) });
return true;
}
function dealRound(s: S, ctx: Ctx): boolean {
const players = s.order.filter((p) => inGame(s, p));
if (s.drawPile.length < players.length) return false;
for (const p of players) {
s.status[p] = 'playing';
s.piles[p] = [s.drawPile.pop()!];
}
s.outThisRound = [];
s.phase = 'countdown';
s.phaseEndsAt = ctx.now + COUNTDOWN_MS;
s.changedAt = s.phaseEndsAt;
s.hintUntil = 0;
ctx.events.push({ type: 'roundStart', round: s.round });
return true;
}
// ---------------------------------------------------------------- claims
function succeed(s: S, c: PendingClaim, ctx: Ctx): void {
const p = c.player;
const st = s.stats[p]!;
st.correct += 1;
if (ctx.now - s.changedAt < FAST_MS) {
st.fastHits += 1;
if (st.fastHits >= FAST_STREAK && !s.suspect.includes(p)) s.suspect.push(p);
} else st.fastHits = 0;
s.away[p] = { strikes: 0, last: 0 };
const art = s.symbolArt[c.symbol]!;
judge(s, p, 'ok', art, ctx);
s.lastHit = { seq: s.seq, player: p, target: c.target, art, at: ctx.now };
log(s, { t: 'hit', p, target: c.target, art });
ctx.events.push({ type: 'hit', player: p, target: c.target, art });
cardsChanged(s, ctx.now);
switch (s.options.miniGame) {
case 'tower': {
s.piles[p]!.push(s.center.pop()!);
if (!revealNext(s, ctx) && s.center.length === 0) finish(s, ctx);
return;
}
case 'poisonedGift': {
s.piles[c.target as PlayerId]!.push(s.center.pop()!);
if (!revealNext(s, ctx) && s.center.length === 0) finish(s, ctx);
return;
}
case 'well': {
s.center.push(s.piles[p]!.pop()!);
if (s.piles[p]!.length === 0) {
s.status[p] = 'finished';
s.finishedOrder.push(p);
log(s, { t: 'done', p, place: s.finishedOrder.length });
ctx.events.push({ type: 'done', player: p, place: s.finishedOrder.length });
}
checkWellEnd(s, ctx);
return;
}
case 'hotPotato': {
const t = c.target as PlayerId;
s.piles[t]!.push(...s.piles[p]!);
s.piles[p] = [];
s.status[p] = 'escaped';
s.outThisRound.push(p);
const holders = s.order.filter((q) => s.status[q] === 'playing' && s.piles[q]!.length > 0);
if (holders.length <= 1) endRound(s, holders[0] ?? null, ctx);
return;
}
}
}
function checkWellEnd(s: S, ctx: Ctx): void {
if (s.phase === 'ended') return;
const left = s.order.filter((q) => s.status[q] === 'playing');
if ((s.options.wellEndAtFirst && s.finishedOrder.length > 0) || left.length <= 1) finish(s, ctx);
}
/** Hot potato: the last holder keeps the bundle as penalty (null = round voided because someone left). */
function endRound(s: S, loser: PlayerId | null, ctx: Ctx): void {
let cards = 0;
if (loser) {
cards = s.piles[loser]!.length;
s.penalty[loser] = (s.penalty[loser] ?? 0) + cards;
s.taken[loser]!.push(...s.piles[loser]!);
s.piles[loser] = [];
} else {
for (const p of s.order) {
s.unused.push(...s.piles[p]!);
s.piles[p] = [];
}
}
s.pendingWindow = null;
s.lastRound = { round: s.round, loser, cards };
log(s, { t: 'round', round: s.round, loser, cards });
ctx.events.push({ type: 'roundEnd', round: s.round, loser, cards });
for (const p of s.order) if (s.status[p] === 'escaped') s.status[p] = 'playing';
s.outThisRound = [];
const players = s.order.filter((p) => inGame(s, p));
if (s.round >= s.options.hotPotatoRounds || players.length < 2) {
finish(s, ctx);
return;
}
s.phase = 'roundEnd';
s.phaseEndsAt = ctx.now + ROUND_BREAK_MS;
s.hintUntil = 0;
}
/** Re-checks a claim against the current table (it may have changed since it arrived). */
function stillValid(s: S, c: PendingClaim): boolean {
if (!canClaim(s, c.player)) return false;
const target = c.target === 'center' ? undefined : c.target;
if (target !== undefined && !validTarget(s, c.player, target)) return false;
if (currentBase(s, target) !== c.base) return false;
if (c.mine !== null && s.options.miniGame !== 'poisonedGift' && topOf(s, c.player) !== c.mine) return false;
const cc = comparedCards(s, c.player, target);
return !!cc && s.cards[cc[0]]!.includes(c.symbol) && s.cards[cc[1]]!.includes(c.symbol);
}
/** Closes the latency window: earliest adjusted time first (ties: arrival), stale claims become "late". */
function resolveWindow(s: S, ctx: Ctx): void {
const w = s.pendingWindow;
if (!w) return;
s.pendingWindow = null;
const sorted = [...w.claims].sort((a, b) => a.adjTs - b.adjTs || a.arrived - b.arrived);
for (const c of sorted) {
if (s.phase === 'playing' && stillValid(s, c)) succeed(s, c, ctx);
else if (s.order.includes(c.player)) {
judge(s, c.player, 'late', null, ctx);
ctx.events.push({ type: 'late', player: c.player });
}
}
}
function claim(s: S, p: PlayerId, a: ClaimAction, ctx: Ctx): void {
if (s.pendingWindow && ctx.now >= s.pendingWindow.openedAt + WINDOW_MS) resolveWindow(s, ctx);
if (a.rtt !== undefined) {
const sample = Math.min(2000, Math.max(0, Math.round(a.rtt)));
const old = s.rtt[p];
s.rtt[p] = old === null || old === undefined ? sample : Math.round(old * (1 - RTT_ALPHA) + sample * RTT_ALPHA);
}
const target = a.target;
const c: PendingClaim = {
player: p,
target: target ?? 'center',
base: a.base,
mine: a.mine ?? null,
symbol: s.symbolArt.indexOf(a.symbol),
adjTs: ctx.now,
arrived: ctx.now,
};
// The table moved while the window was being resolved → this claim is late (no penalty).
if (s.phase !== 'playing' || !canClaim(s, p) || (target !== undefined && !validTarget(s, p, target)) || currentBase(s, target) !== a.base) {
judge(s, p, 'late', null, ctx);
ctx.events.push({ type: 'late', player: p });
return;
}
if (c.mine !== null && s.options.miniGame !== 'poisonedGift' && topOf(s, p) !== c.mine) {
judge(s, p, 'late', null, ctx);
ctx.events.push({ type: 'late', player: p });
return;
}
s.away[p] = { strikes: 0, last: 0 };
// §5.3 잠금: judged on the server clock.
if (ctx.now < (s.lockedUntil[p] ?? 0)) {
judge(s, p, 'locked', null, ctx);
ctx.events.push({ type: 'locked', player: p });
return;
}
const cc = comparedCards(s, p, target)!;
const right = c.symbol >= 0 && s.cards[cc[0]]!.includes(c.symbol) && s.cards[cc[1]]!.includes(c.symbol);
if (!right) {
s.stats[p]!.wrong += 1;
s.lockedUntil[p] = ctx.now + s.options.wrongLockoutMs;
judge(s, p, 'wrong', a.symbol, ctx);
ctx.events.push({ type: 'wrong', player: p });
// §7 wrongGivesCard (tower): my top card goes under the draw pile; my last card always stays.
if (s.options.miniGame === 'tower' && s.options.wrongGivesCard && s.piles[p]!.length >= 2) {
s.drawPile.unshift(s.piles[p]!.pop()!);
ctx.events.push({ type: 'gaveCard', player: p });
}
return;
}
if (s.options.latencyComp === 'light') {
const rtt = s.rtt[p] ?? 0;
c.adjTs = ctx.now - Math.min(rtt / 2, MAX_LATENCY_CREDIT_MS);
if (!s.pendingWindow) s.pendingWindow = { openedAt: ctx.now, claims: [] };
s.pendingWindow.claims.push(c);
ctx.events.push({ type: 'windowed', player: p });
return;
}
succeed(s, c, ctx);
}
// ---------------------------------------------------------------- timer, away, leaving
function processDue(s: S, ctx: Ctx): void {
if (s.phase === 'countdown') {
s.phase = 'playing';
cardsChanged(s, ctx.now);
ctx.events.push({ type: 'start', round: s.round });
return;
}
if (s.phase === 'roundEnd') {
s.round += 1;
if (!dealRound(s, ctx)) finish(s, ctx);
return;
}
if (s.pendingWindow) {
resolveWindow(s, ctx);
return;
}
if (s.hintUntil > 0) {
// Hint over: keep playing the same cards (no points for anyone), stall timer restarts.
cardsChanged(s, ctx.now);
return;
}
s.hintUntil = ctx.now + HINT_MS;
log(s, { t: 'hint' });
ctx.events.push({ type: 'hint' });
}
/** Removes `p` (left the room, or forfeited after being away in well / hot potato; §8.5). */
function drop(s: S, p: PlayerId, why: 'forfeit' | 'left', ctx: Ctx): void {
const was = s.status[p];
s.status[p] = why;
if (s.pendingWindow) s.pendingWindow.claims = s.pendingWindow.claims.filter((c) => c.player !== p);
const mode = s.options.miniGame;
const holding = s.piles[p]!.length > 0;
if (mode === 'well' || mode === 'hotPotato') {
// Cards go back to the box ("가운데로 반환"); tower / gift piles stay as they are.
s.unused.push(...s.piles[p]!);
s.piles[p] = [];
}
s.outThisRound = s.outThisRound.filter((q) => q !== p);
log(s, { t: 'out', p, why });
ctx.events.push({ type: 'out', player: p, why });
if (s.order.filter((q) => inGame(s, q)).length < 2) {
finish(s, ctx);
return;
}
if (mode === 'well') checkWellEnd(s, ctx);
if (mode === 'hotPotato' && s.phase === 'playing' && was === 'playing' && holding) {
const holders = s.order.filter((q) => s.status[q] === 'playing' && s.piles[q]!.length > 0);
// Nobody left to race against: the round is void (no penalty).
if (holders.length <= 1) endRound(s, null, ctx);
}
}
/** An early tick: the room timed `p` out while disconnected past the grace period. */
function awayStrike(s: S, p: PlayerId, ctx: Ctx): void {
const st = s.status[p];
if (st !== 'playing' && st !== 'escaped') return;
const prev = s.away[p] ?? { strikes: 0, last: 0 };
// Strikes rotate over every away player, so the gap between one player's strikes is ≤ players × step.
const fresh = prev.last > 0 && ctx.now - prev.last <= AWAY_STEP_MS * (s.order.length + 1);
const strikes = fresh ? prev.strikes + 1 : 1;
s.away[p] = { strikes, last: ctx.now };
const mode = s.options.miniGame;
if (strikes >= AWAY_STRIKES && (mode === 'well' || mode === 'hotPotato')) {
drop(s, p, 'forfeit', ctx);
return;
}
// Tower / gift keep going without the away player — unless everybody is away.
if (mode === 'tower' || mode === 'poisonedGift') {
const live = s.order.filter((q) => s.status[q] === 'playing');
const allAway = live.every((q) => {
const a = s.away[q];
return !!a && a.strikes >= AWAY_STRIKES && ctx.now - a.last <= AWAY_STEP_MS * (s.order.length + 1);
});
if (allAway) finish(s, ctx, '모두 자리를 비워서 게임을 끝냈어요.');
}
}
function tick(s: S, actor: PlayerId, ctx: Ctx): void {
const due = dueAt(s);
if (due !== null && ctx.now >= due) processDue(s, ctx);
else if (ctx.now >= s.awayCheckAt) {
awayStrike(s, actor, ctx);
s.awayRot += 1;
}
s.awayCheckAt = Math.max(s.awayCheckAt, ctx.now + AWAY_STEP_MS);
}
// ---------------------------------------------------------------- ending
function rankBy(ids: PlayerId[], key: (p: PlayerId) => number, start: number): { id: PlayerId; rank: number }[] {
const sorted = [...ids].sort((a, b) => key(a) - key(b));
return sorted.map((id, i) => {
let j = i;
while (j > 0 && key(sorted[j - 1]!) === key(id)) j--;
return { id, rank: start + j };
});
}
function finish(s: S, ctx: Ctx, override?: string): void {
if (s.phase === 'ended') return;
s.phase = 'ended';
s.pendingWindow = null;
s.hintUntil = 0;
const mode = s.options.miniGame;
const live = s.order.filter((p) => inGame(s, p));
const gone = s.order.filter((p) => !inGame(s, p));
const cards = (p: PlayerId) => s.piles[p]!.length;
let ranks: { id: PlayerId; rank: number }[];
switch (mode) {
case 'tower':
ranks = rankBy(live, (p) => -cards(p), 1);
break;
case 'poisonedGift':
ranks = rankBy(live, cards, 1);
break;
case 'hotPotato':
ranks = rankBy(live, (p) => s.penalty[p] ?? 0, 1);
break;
case 'well': {
const done = s.finishedOrder.filter((p) => live.includes(p)).map((id, i) => ({ id, rank: i + 1 }));
ranks = [...done, ...rankBy(live.filter((p) => !s.finishedOrder.includes(p)), cards, done.length + 1)];
break;
}
}
// Forfeited / left players share the bottom place (§8.5 "꼴찌 그룹").
for (const id of gone) ranks.push({ id, rank: live.length + 1 });
const ranking: RankRow[] = ranks.map(({ id, rank }) => ({
rank,
id,
...(mode === 'hotPotato' ? { penalty: s.penalty[id] ?? 0 } : { cards: cards(id) }),
// Forfeited / left players score nothing even when the bottom place is 3rd.
points: inGame(s, id) ? (POINTS[rank - 1] ?? 0) : 0,
status: s.status[id]!,
}));
const winners = ranking.filter((r) => r.rank === 1);
const w = winners[0]!;
let summary: string;
if (override) summary = override;
else if (live.length === 1 && gone.length > 0) summary = `다른 사람이 모두 나가서 ${MINI_GAME_KO[mode]}을(를) 끝냈어요.`;
else if (mode === 'tower') summary = `탑 쌓기 끝! 카드 ${w.cards}장을 모은 사람이 1등이에요.`;
else if (mode === 'poisonedGift') summary = `독이 든 선물 끝! 카드를 ${w.cards}장만 받은 사람이 1등이에요.`;
else if (mode === 'hotPotato') summary = `뜨거운 감자 ${s.lastRound?.round ?? s.round}라운드 끝! 벌점 ${w.penalty}장으로 가장 적은 사람이 1등이에요.`;
else summary = s.finishedOrder.length > 0 ? '우물 끝! 카드를 가장 먼저 다 비운 사람이 1등이에요.' : '우물 끝! 카드가 가장 적게 남은 사람이 1등이에요.';
if (winners.length > 1 && !override) summary += ` (공동 1등 ${winners.length}명)`;
const stats: DobbleFinal['stats'] = {};
for (const p of s.order) stats[p] = { correct: s.stats[p]!.correct, wrong: s.stats[p]!.wrong };
s.final = { miniGame: mode, ranking, stats, summary };
ctx.events.push({ type: 'gameEnd' });
}
// ---------------------------------------------------------------- view
function cardView(s: S, id: number): CardView {
return {
id,
key: cardKey(id),
items: s.layouts[id]!.items.map((i) => ({ art: s.symbolArt[i.symbol]!, x: i.x, y: i.y, r: i.r, scale: i.scale, rot: i.rot })),
};
}
function setup(players: PlayerId[], options: DobbleOptions, rng: Rng, now: number): S {
if (players.length < 2 || players.length > 8) throw new Error(`dobble: ${players.length} players not allowed`);
const err = playerCountError(options, players.length);
if (err) throw new Error(err);
// §8.4: seats, symbol → icon, card looks, deck order and the 55-card removals all come from the seeded RNG.
const order = rng.shuffle(players);
const symbolArt = rng.shuffle(Array.from({ length: FULL_DECK }, (_, i) => i));
const cards = buildDeck();
const layouts = cards.map((c) => makeLayout(c, options.symbolStyle, rng));
const deck = rng.shuffle(cards.map((_, i) => i));
const unused = options.deckSize === 55 ? deck.splice(0, FULL_DECK - 55) : [];
const per0 = <T>(v: () => T) => Object.fromEntries(order.map((p) => [p, v()])) as Record<PlayerId, T>;
const s: S = {
options,
host: players[0]!,
cards,
layouts,
symbolArt,
order,
status: per0<SeatStatus>(() => 'playing'),
piles: per0<number[]>(() => []),
center: [],
drawPile: [],
unused,
taken: per0<number[]>(() => []),
lockedUntil: per0(() => 0),
pendingWindow: null,
round: 1,
penalty: per0(() => 0),
outThisRound: [],
finishedOrder: [],
phase: 'countdown',
phaseEndsAt: now + COUNTDOWN_MS,
changedAt: now + COUNTDOWN_MS,
hintUntil: 0,
awayCheckAt: now + COUNTDOWN_MS + AWAY_STEP_MS,
awayRot: 0,
away: per0(() => ({ strikes: 0, last: 0 })),
rtt: per0<number | null>(() => null),
stats: per0(() => ({ correct: 0, wrong: 0, fastHits: 0 })),
suspect: [],
feedback: {},
lastHit: null,
lastRound: null,
seq: 0,
logNo: 0,
log: [],
final: null,
};
const ctx: Ctx = { now, events: [] };
switch (options.miniGame) {
case 'tower':
case 'poisonedGift':
for (const p of order) s.piles[p] = [deck.pop()!];
s.center = [deck.pop()!];
s.drawPile = deck;
break;
case 'well': {
s.center = [deck.pop()!];
const per = Math.floor(deck.length / order.length);
order.forEach((p, i) => (s.piles[p] = deck.slice(i * per, (i + 1) * per)));
s.unused.push(...deck.slice(order.length * per));
break;
}
case 'hotPotato':
s.drawPile = deck;
dealRound(s, ctx);
break;
}
return s;
}
function playerCountError(o: DobbleOptions, count: number): string | null {
if (o.miniGame === 'hotPotato' && count * o.hotPotatoRounds > o.deckSize) {
return `뜨거운 감자 ${o.hotPotatoRounds}라운드를 ${count}명이 하기에는 카드가 모자라요. 라운드를 줄이거나 57장 덱을 골라 주세요.`;
}
return null;
}
// ---------------------------------------------------------------- definition
type Def = GameDefinition<S, A, DobbleView, DobbleOptions>;
export const dobble: Def = {
id: 'dobble',
nameKo: '도블',
minPlayers: 2,
maxPlayers: 8,
stateVersion: 1,
defaultOptions: dobbleOptionsSchema.parse({}),
optionsSchema: dobbleOptionsSchema as unknown as Def['optionsSchema'],
actionSchema: dobbleActionSchema as unknown as Def['actionSchema'],
playerCountError,
setup({ players, options, rng, now }) {
return setup(players, options, rng, now);
},
validate(s, actor, a) {
if (!s.order.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase === 'ended') return fail('게임이 이미 끝났어요.');
switch (a.type) {
case 'tick':
return ok;
case 'resolve':
return s.pendingWindow ? ok : fail('판정할 찾기가 없어요.');
case 'leave':
return s.status[actor] === 'playing' || s.status[actor] === 'escaped' ? ok : fail('이미 게임에서 빠졌어요.');
case 'claim': {
if (s.phase === 'countdown') return fail('곧 시작해요. 잠시만 기다려 주세요.');
if (s.phase === 'roundEnd') return fail('다음 라운드를 준비하고 있어요.');
const st = s.status[actor];
if (st === 'forfeit' || st === 'left') return fail('게임에서 빠져서 찾을 수 없어요.');
if (st === 'finished') return fail('이미 카드를 다 비웠어요.');
if (st === 'escaped') return fail('이번 라운드는 탈출했어요. 다음 라운드를 기다려요.');
if (!canClaim(s, actor)) return fail('지금은 찾을 카드가 없어요.');
if (usesTarget(s.options)) {
if (a.target === undefined) return fail('누구 카드에서 찾을지 먼저 골라 주세요.');
if (a.target === actor) return fail(s.options.miniGame === 'poisonedGift' ? '내 카드에는 선물을 줄 수 없어요.' : '내 카드끼리는 비교할 수 없어요.');
if (!s.order.includes(a.target)) return fail('그런 사람은 없어요.');
const ts = s.status[a.target];
if (ts === 'escaped') return fail('이미 이번 라운드를 탈출한 사람이에요.');
if (ts !== 'playing' || !validTarget(s, actor, a.target)) return fail('게임에서 빠진 사람이에요.');
} else if (a.target !== undefined) return fail('이 미니게임은 대상을 고르지 않아요.');
// §5.1: the reference card moved on → "늦었어요" (no penalty).
if (currentBase(s, a.target) !== a.base) return fail(s.options.miniGame === 'hotPotato' ? LATE_CHANGED : LATE_TAKEN);
if (a.mine !== undefined && s.options.miniGame !== 'poisonedGift' && topOf(s, actor) !== a.mine) return fail(LATE_CHANGED);
if (s.pendingWindow?.claims.some((c) => c.player === actor)) return fail('이미 눌렀어요. 판정을 기다려 주세요.');
return ok;
}
}
},
apply(prev, actor, a, { now }) {
const s = clone(prev);
const ctx: Ctx = { now, events: [] };
switch (a.type) {
case 'tick':
tick(s, actor, ctx);
break;
case 'resolve':
if (s.pendingWindow && now >= s.pendingWindow.openedAt + WINDOW_MS) resolveWindow(s, ctx);
break;
case 'claim':
claim(s, actor, a, ctx);
break;
case 'leave':
drop(s, actor, 'left', ctx);
break;
}
return { state: s, events: ctx.events };
},
view(s, viewer) {
const me = viewer && s.order.includes(viewer) ? viewer : null;
const reveal = s.phase !== 'countdown';
const pairs = me && s.phase === 'playing' ? pairsFor(s, me) : [];
let hint: number[] = [];
if (s.phase === 'playing' && s.hintUntil > 0) {
const c0 = centerTop(s);
const arts = me
? pairsFor(s, me).map((pair) => answerArt(s, me, pair))
: s.options.miniGame === 'poisonedGift' && c0 !== undefined
? s.order.filter((q) => s.status[q] === 'playing' && topOf(s, q) !== undefined).map((q) => s.symbolArt[common(s, topOf(s, q)!, c0)]!)
: [];
hint = [...new Set(arts.filter((x): x is number => x !== null))];
}
const c = centerTop(s);
return {
options: s.options,
me,
isHost: viewer === s.host,
phase: s.phase,
phaseEndsAt: s.phaseEndsAt,
round: s.round,
rounds: s.options.miniGame === 'hotPotato' ? s.options.hotPotatoRounds : 1,
seats: s.order.map((p) => {
const t = topOf(s, p);
const place = s.finishedOrder.indexOf(p);
return {
id: p,
status: s.status[p]!,
cards: s.piles[p]!.length,
top: reveal && t !== undefined ? cardView(s, t) : null,
penalty: s.penalty[p] ?? 0,
lockedUntil: s.lockedUntil[p] ?? 0,
correct: s.stats[p]!.correct,
wrong: s.stats[p]!.wrong,
rtt: s.rtt[p] ?? null,
place: place >= 0 ? place + 1 : null,
};
}),
center: reveal && c !== undefined ? cardView(s, c) : null,
centerCount: s.center.length,
drawCount: s.drawPile.length,
unusedCount: s.unused.length,
pairs,
windowOpen: s.pendingWindow !== null,
hint,
hintUntil: s.hintUntil,
lastHit: s.lastHit,
feedback: me ? (s.feedback[me] ?? null) : null,
lastRound: s.lastRound,
suspects: viewer !== null && viewer === s.host ? [...s.suspect] : [],
log: s.log.slice(-12),
result: s.final,
};
},
/** No turns: everyone still in the game may claim at any time (§8.5). */
activePlayers(s) {
if (s.phase === 'ended') return [];
return s.order.filter((p) => s.status[p] === 'playing' || s.status[p] === 'escaped');
},
deadline(s) {
return dueAt(s);
},
/**
* Everyone still in drives the timer (countdown, latency window, stall hint), rotated after every away
* strike so that several disconnected players are all noticed in turn.
*/
timeoutPlayers(s) {
if (s.phase === 'ended') return [];
const ps = s.order.filter((p) => s.status[p] === 'playing' || s.status[p] === 'escaped');
if (ps.length === 0) return [];
const k = s.awayRot % ps.length;
return [...ps.slice(k), ...ps.slice(0, k)];
},
/** Disconnected players are timed out at most once per AWAY_STEP_MS (no busy loop), never before the window closes. */
timeoutNotBefore(s) {
const due = dueAt(s);
if (due === null) return null;
if (s.pendingWindow) return due;
return Math.min(due, s.awayCheckAt);
},
onTimeout(s) {
return s.pendingWindow ? { type: 'resolve' } : { type: 'tick' };
},
onLeave(s, player) {
if (s.phase === 'ended' || !s.order.includes(player)) return null;
return s.status[player] === 'playing' || s.status[player] === 'escaped' ? { type: 'leave' } : null;
},
result(s): GameResult | null {
const f = s.final;
if (!f) return null;
const groups = new Map<number, PlayerId[]>();
for (const r of f.ranking) groups.set(r.rank, [...(groups.get(r.rank) ?? []), r.id]);
const scores: Record<PlayerId, number> = {};
for (const r of f.ranking) scores[r.id] = r.points;
return {
ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids),
scores,
summary: f.summary,
reason: 'normal',
};
},
/** Test helper: the right claim on every pair, a wrong one now and then, and the timer tick. */
legalActions(s, p) {
if (s.phase === 'ended' || !s.order.includes(p)) return [];
const out: A[] = [{ type: 'tick' }];
if (s.pendingWindow) out.push({ type: 'resolve' });
const pairs = pairsFor(s, p);
for (const pair of pairs) {
const art = answerArt(s, p, pair)!;
out.push({ type: 'claim', base: pair.base, symbol: art, ...(pair.target ? { target: pair.target } : {}), ...(pair.mine !== null ? { mine: pair.mine } : {}) });
}
const st = s.stats[p]!;
const first = pairs[0];
if (first && (st.correct + st.wrong) % 4 === 3) {
const cc = comparedCards(s, p, first.target ?? undefined)!;
const right = common(s, cc[0], cc[1]);
const wrong = s.cards[cc[0]]!.find((x) => x !== right)!;
out.push({ type: 'claim', base: first.base, symbol: s.symbolArt[wrong]!, ...(first.target ? { target: first.target } : {}) });
}
return out.filter((x) => dobble.validate(s, p, x).ok);
},
/** Cards nobody may see: draw pile, box, well face-down piles, and everything dealt during a countdown. */
secretsOf(s) {
const hidden = [...s.drawPile, ...s.unused];
for (const p of s.order) {
const pile = s.piles[p]!;
if (s.options.miniGame === 'well') hidden.push(...pile.slice(0, -1));
if (s.phase === 'countdown' && pile.length) hidden.push(pile[pile.length - 1]!);
}
if (s.phase === 'countdown' && s.center.length) hidden.push(s.center[s.center.length - 1]!);
return hidden.map(cardKey);
},
};

View File

@@ -0,0 +1,272 @@
/** 도블(같은 그림 찾기) options, actions, state and view types (docs/games/dobble.md §2, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
/** §3.2: 57 original icons (drawn in apps/web/src/games/dobble/art.ts), index = icon id. */
export const SYMBOL_NAMES = [
'사과', '해', '달', '별', '하트', '물방울', '불꽃', '눈송이', '번개', '구름',
'나무', '꽃', '나뭇잎', '선인장', '버섯', '당근', '레몬', '체리', '고양이', '강아지',
'물고기', '새', '거북이', '무당벌레', '거미', '공룡', '토끼', '펭귄', '열쇠', '자물쇠',
'시계', '전구', '연필', '가위', '안경', '우산', '모자', '왕관', '종', '닻',
'자전거', '자동차', '로켓', '배', '공', '주사위', '음표', '카메라', '집', '성',
'선물상자', '촛불', '망치', '느낌표', '물음표', '눈사람', '무지개',
] as const;
export type MiniGame = 'tower' | 'well' | 'hotPotato' | 'poisonedGift';
export const MINI_GAME_KO: Record<MiniGame, string> = { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' };
const lit = <T extends string | number | null>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
export const dobbleOptionsSchema = z.object({
miniGame: z
.enum(['tower', 'well', 'hotPotato', 'poisonedGift'])
.default('tower')
.meta({ title: '미니게임', labels: { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' } }),
deckSize: lit<57 | 55>(57, 55)
.default(57)
.meta({ title: '카드 장수', labels: { 57: '57장(수학적으로 꽉 찬 덱)', 55: '55장(원작과 같은 장수)' } }),
hotPotatoRounds: lit<3 | 5 | 7>(3, 5, 7)
.default(5)
.meta({ title: '뜨거운 감자 라운드 수', labels: { 3: '3라운드', 5: '5라운드', 7: '7라운드' } }),
wrongLockoutMs: lit<0 | 1000 | 2000>(0, 1000, 2000)
.default(1000)
.meta({ title: '틀린 그림을 눌렀을 때 잠금 시간', labels: { 0: '없음', 1000: '1초', 2000: '2초' } }),
latencyComp: z
.enum(['off', 'light'])
.default('off')
.meta({ title: '지연 보정', labels: { off: '끄기(서버 도착 순서)', light: '가볍게(최대 0.08초 보정)' } }),
symbolStyle: z
.enum(['easy', 'classic'])
.default('classic')
.meta({ title: '그림 크기·회전', labels: { easy: '쉽게(크기 같고 회전 없음)', classic: '원작처럼(크기·회전 무작위)' } }),
showRtt: z.boolean().default(true).meta({ title: '각자 핑(연결 지연) 표시' }),
wellEndAtFirst: z.boolean().default(false).meta({ title: '우물: 처음 다 비운 사람이 나오면 바로 끝내기' }),
wrongGivesCard: z.boolean().default(false).meta({ title: '탑 쌓기: 틀리면 내 탑 맨 위 카드를 가운데 더미 맨 아래로' }),
});
export type DobbleOptions = z.infer<typeof dobbleOptionsSchema>;
export const dobbleActionSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('claim'),
/** Reference card id the client was looking at: centre top (tower/well/gift) or the target's top (hot potato). */
base: z.number().int().min(0).max(56),
/** Gift / hot potato: whose card the symbol is matched on. */
target: z.string().min(1).max(64).optional(),
/** My own top card id the client saw (hot potato: someone may have just dumped their bundle on me). */
mine: z.number().int().min(0).max(56).optional(),
/** Icon id that was tapped. */
symbol: z.number().int().min(0).max(56),
/** Client-measured round trip (ms); feeds the per-player RTT average (§5.3), clamped. */
rtt: z.number().min(0).max(10_000).optional(),
}),
]);
/** Client claims plus system-only actions (server timer, latency window, leaving) that are not in the schema. */
export type DobbleAction = z.infer<typeof dobbleActionSchema> | { type: 'tick' } | { type: 'resolve' } | { type: 'leave' };
export type ClaimAction = Extract<DobbleAction, { type: 'claim' }>;
export interface LayoutItem {
/** Symbol number 0..56 (projective-plane point); the screen shows `symbolArt[symbol]`. */
symbol: number;
/** Slot centre in the unit circle (−1..1) and slot radius. */
x: number;
y: number;
r: number;
/** §3.3 classic: 0.6–1.2, easy: 1. */
scale: number;
/** Degrees, classic: 0–359, easy: 0. */
rot: number;
}
export interface CardLayout {
template: number;
items: LayoutItem[];
}
export type SeatStatus = 'playing' | 'finished' | 'escaped' | 'forfeit' | 'left';
export type Phase = 'countdown' | 'playing' | 'roundEnd' | 'ended';
export interface PendingClaim {
player: PlayerId;
target: PlayerId | 'center';
base: number;
mine: number | null;
symbol: number;
adjTs: number;
arrived: number;
}
export interface PendingWindow {
openedAt: number;
claims: PendingClaim[];
}
export interface Stats {
correct: number;
wrong: number;
/** Correct claims made < 200ms after the cards changed, in a row (§5.3 봇 의심). */
fastHits: number;
}
/** Per-player judgement of their latest claim (shown only to that player). */
export interface Feedback {
seq: number;
kind: 'ok' | 'wrong' | 'late' | 'locked';
/** Icon id. */
art: number | null;
at: number;
}
export interface LastHit {
seq: number;
player: PlayerId;
target: PlayerId | 'center';
art: number;
at: number;
}
export type LogEntry =
| { n: number; t: 'hit'; p: PlayerId; target: PlayerId | 'center'; art: number }
| { n: number; t: 'done'; p: PlayerId; place: number }
| { n: number; t: 'round'; round: number; loser: PlayerId | null; cards: number }
| { n: number; t: 'out'; p: PlayerId; why: 'forfeit' | 'left' }
| { n: number; t: 'hint' };
export interface RankRow {
rank: number;
id: PlayerId;
cards?: number;
penalty?: number;
points: number;
status: SeatStatus;
}
export interface DobbleFinal {
miniGame: MiniGame;
ranking: RankRow[];
stats: Record<PlayerId, { correct: number; wrong: number }>;
summary: string;
}
export interface DobbleState {
options: DobbleOptions;
/** Room host at game start (players[0]); only they see the bot-suspicion marks (§5.3). */
host: PlayerId;
/** Card id → symbol numbers (8 each). Immutable after setup. */
cards: number[][];
/** Card id → layout. Immutable after setup. */
layouts: CardLayout[];
/** Symbol number → icon id (shuffled per game). Immutable after setup. */
symbolArt: number[];
order: PlayerId[];
status: Record<PlayerId, SeatStatus>;
/** Player piles, top = last. Well: the whole face-down pile, only the top is face up. */
piles: Record<PlayerId, number[]>;
/** Public centre pile, top = last. */
center: number[];
/** Hidden draw pile, next card = last. */
drawPile: number[];
/** Well leftovers, 55-deck removals, forfeited / left players' returned cards. */
unused: number[];
/** Hot potato: cards each round's loser keeps as penalty. */
taken: Record<PlayerId, number[]>;
lockedUntil: Record<PlayerId, number>;
pendingWindow: PendingWindow | null;
/** Hot potato round (1-based); other modes stay 1. */
round: number;
penalty: Record<PlayerId, number>;
outThisRound: PlayerId[];
finishedOrder: PlayerId[];
phase: Phase;
phaseEndsAt: number;
/** Last time the public cards changed (stall timer, bot suspicion). */
changedAt: number;
/** §8.5 stall: the common symbol is shown until this time. */
hintUntil: number;
/** Throttles away detection (one early tick per away step). */
awayCheckAt: number;
/** Rotates `timeoutPlayers` after each away strike so several away players are all noticed. */
awayRot: number;
/** Consecutive early ticks per player (= the room timed them out while disconnected) and the last one. */
away: Record<PlayerId, { strikes: number; last: number }>;
rtt: Record<PlayerId, number | null>;
stats: Record<PlayerId, Stats>;
suspect: PlayerId[];
feedback: Record<PlayerId, Feedback>;
lastHit: LastHit | null;
/** Hot potato: the latest finished round. */
lastRound: { round: number; loser: PlayerId | null; cards: number } | null;
seq: number;
logNo: number;
log: LogEntry[];
final: DobbleFinal | null;
}
// ---------------------------------------------------------------- view
export interface ItemView {
/** Icon id. */
art: number;
x: number;
y: number;
r: number;
scale: number;
rot: number;
}
export interface CardView {
id: number;
/** Opaque public key (`secretsOf` uses the same form for hidden cards). */
key: string;
items: ItemView[];
}
export interface SeatView {
id: PlayerId;
status: SeatStatus;
/** Cards in the pile (tower: tower height, well: cards left, gift: poison, hot potato: bundle). */
cards: number;
top: CardView | null;
penalty: number;
lockedUntil: number;
correct: number;
wrong: number;
rtt: number | null;
/** Well finishing place (1-based). */
place: number | null;
}
/** One pair of cards the viewer may claim on (rules precomputed for the screen). */
export interface ClaimPair {
target: PlayerId | null;
base: number;
mine: number | null;
}
export interface DobbleView {
options: DobbleOptions;
me: PlayerId | null;
isHost: boolean;
phase: Phase;
phaseEndsAt: number;
round: number;
rounds: number;
seats: SeatView[];
center: CardView | null;
centerCount: number;
drawCount: number;
unusedCount: number;
/** Pairs the viewer can claim on right now (empty while they cannot claim). */
pairs: ClaimPair[];
windowOpen: boolean;
/** §8.5 stall hint: icon ids common to the viewer's pairs, shown until `hintUntil`. */
hint: number[];
hintUntil: number;
lastHit: LastHit | null;
feedback: Feedback | null;
lastRound: DobbleState['lastRound'];
/** Host only (§5.3). */
suspects: PlayerId[];
log: LogEntry[];
result: DobbleFinal | null;
}

View File

@@ -22,6 +22,7 @@ import { quoridor } from './quoridor';
import { lasVegas } from './las-vegas';
import { cockroachPoker } from './cockroach-poker';
import { ninetyNine } from './ninety-nine';
import { dobble } from './dobble';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -47,4 +48,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[lasVegas.id]: lasVegas,
[cockroachPoker.id]: cockroachPoker,
[ninetyNine.id]: ninetyNine,
[dobble.id]: dobble,
};

View File

@@ -34,6 +34,7 @@ export const CATALOG: CatalogEntry[] = [
{ 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 },
{ id: 'ninety-nine', nameKo: '구십구', minPlayers: 2, maxPlayers: 10, minutes: '5~15', level: '쉬움', blurb: '카드를 내며 숫자를 더해요. 99를 넘기면 탈락!', available: true },
{ id: 'dobble', nameKo: '도블', minPlayers: 2, maxPlayers: 8, minutes: '3~10', level: '쉬움', blurb: '어느 두 카드에나 딱 하나 있는 같은 그림을 누구보다 빨리 콕!', available: true },
];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);