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,120 @@
/** 도블 through a real Room: arrival order, "늦었어요" rejects, the latency window and away players (docs/games/dobble.md §5.3, §8.5). */
import { describe, expect, test } from 'bun:test';
import type { ServerMessage } from '@bg/shared';
import { GAMES } from '@bg/games';
import { answerArt, pairsFor, type DobbleOptions, type DobbleState } from '@bg/games/dobble';
import { openDb } from '../db';
import { Room, type Conn, type RoomConfig } from './room';
import { RoomStore } from './store';
function harness(players: string[], opts: Partial<DobbleOptions> = {}) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms), set: (t: number) => (now = t) };
const games = { dobble: GAMES.dobble! };
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
const config: RoomConfig = {
gameId: 'dobble',
options: games.dobble.optionsSchema.parse(opts),
maxPlayers: players.length,
visibility: 'private',
allowSpectators: true,
chatEnabled: true,
chatFilter: true,
graceSec: 60,
};
const room = new Room('room1', '482913', config, players[0]!, deps);
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'dobble', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
const conns: Record<string, Conn & { msgs: ServerMessage[] }> = {};
for (const p of players) {
const msgs: ServerMessage[] = [];
conns[p] = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
room.join(conns[p], 'player');
}
expect(room.start(players[0]!)).toBeNull();
room.clearTimer();
// 3s countdown → playing.
clock.advance(3000);
room.onTimer();
room.clearTimer();
const state = () => room.game!.state as DobbleState;
/** The correct claim of `p` (first pair), as the client would send it. */
const right = (p: string) => {
const pair = pairsFor(state(), p)[0]!;
return { type: 'claim', base: pair.base, symbol: answerArt(state(), p, pair)!, ...(pair.target ? { target: pair.target } : {}), ...(pair.mine !== null ? { mine: pair.mine } : {}) };
};
return { room, clock, conns, state, right };
}
describe('도블 room flow', () => {
test('같은 가운데 카드에 같은 ms 두 정답: 큐에서 먼저 처리된 사람만 가져가고, 나중 사람은 "늦었어요" 거절(잠금 없음)', () => {
const { room, conns, state, right } = harness(['A', 'B', 'C']);
const a = right('A');
const b = right('B');
const towerB = state().piles.B!.length;
room.act('B', 1, b);
room.act('A', 1, a);
room.clearTimer();
expect(state().piles.B).toHaveLength(towerB + 1);
expect(state().piles.A).toHaveLength(1);
expect(state().lockedUntil.A).toBe(0);
expect(conns.A!.msgs.filter((m) => m.t === 'reject')).toEqual([{ t: 'reject', cs: 1, reason: '늦었어요! 다른 사람이 먼저 찾았어요.' }]);
// Nobody's view carries the answer or hidden cards; the hit is public.
const last = conns.C!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>;
expect((last.view as { lastHit: { player: string } }).lastHit).toMatchObject({ player: 'B' });
});
test('클라이언트가 tick/resolve 같은 시스템 행동을 보내면 거절', () => {
const { room, conns } = harness(['A', 'B']);
room.act('A', 1, { type: 'tick' });
room.act('A', 2, { type: 'leave' });
expect(conns.A!.msgs.filter((m) => m.t === 'reject').map((m) => (m as { reason: string }).reason)).toEqual(['잘못된 요청이에요.', '잘못된 요청이에요.']);
});
test('지연 보정 light: 창(60ms)은 방 타이머가 닫고, 보정 시각이 빠른 사람이 이겨요', () => {
const { room, clock, state, right } = harness(['A', 'B'], { latencyComp: 'light' });
// RTT averages (normally fed by the claims): A 20ms → 10ms credit, B 200ms → 80ms credit (cap).
const s = state();
s.rtt.A = 20;
s.rtt.B = 200;
const a = right('A');
const b = right('B');
room.act('A', 1, a);
clock.advance(40);
room.act('B', 1, b);
expect(state().pendingWindow?.claims).toHaveLength(2);
expect(room.effectiveDeadline()).toBe(clock.now() - 40 + 60);
clock.set(room.effectiveDeadline()!);
room.onTimer();
room.clearTimer();
expect(state().pendingWindow).toBeNull();
// B arrived 40ms later but its adjusted time is 30ms earlier (credit 80 vs 10).
expect(state().lastHit).toMatchObject({ player: 'B' });
});
test('우물: 연결이 끊긴 사람은 유예(60초) 뒤 10초 간격으로 세 번 시간 초과 → 기권, 남은 사람끼리 계속(바쁜 반복 없음)', () => {
const { room, clock, conns, state, right } = harness(['A', 'B', 'C'], { miniGame: 'well' });
const t0 = clock.now();
room.disconnect(conns.C!);
room.clearTimer();
let fired = 0;
while (state().status.C === 'playing' && fired < 50) {
const d = room.effectiveDeadline()!;
if (d > clock.now()) clock.set(d);
room.onTimer();
room.clearTimer();
fired++;
// The others keep finding pictures now and then.
if (fired % 3 === 0 && pairsFor(state(), 'A').length) room.act('A', fired, right('A'));
room.clearTimer();
}
expect(state().status.C).toBe('forfeit');
const took = clock.now() - t0;
expect(took).toBeGreaterThanOrEqual(60_000 + 20_000);
expect(took).toBeLessThanOrEqual(60_000 + 45_000);
expect(fired).toBeLessThan(20);
expect(room.status).toBe('playing');
expect(state().phase).toBe('playing');
});
});

View File

@@ -0,0 +1,8 @@
import { expect, test } from 'bun:test';
import { SYMBOL_NAMES } from '@bg/games/dobble';
import { ICONS, ICON_NAMES } from './art';
test('도블 그림 57종: 그림과 이름이 규칙 모듈과 같은 순서', () => {
expect(ICONS).toHaveLength(57);
expect(ICON_NAMES).toEqual([...SYMBOL_NAMES]);
});

View File

@@ -0,0 +1,556 @@
/**
* 도블 그림 57종 (docs/games/dobble.md §3.2) — all drawn here as simple SVG in a 100×100 box.
* Every picture has its own silhouette with a dark outline, so colour is only a secondary cue (colour-blind friendly).
* Index = icon id (same order as SYMBOL_NAMES in @bg/games/dobble).
*/
import type { ReactNode } from 'react';
const O = '#1f2937';
/** Outline props for filled shapes. */
const L = { stroke: O, strokeWidth: 3.5, strokeLinejoin: 'round', strokeLinecap: 'round' } as const;
/** Thick stroked lines (no fill). */
const W = (color: string, w = 6) => ({ stroke: color, strokeWidth: w, strokeLinecap: 'round', strokeLinejoin: 'round', fill: 'none' }) as const;
const rays = (n: number, r1: number, r2: number, cx = 50, cy = 50, a0 = 0) =>
Array.from({ length: n }, (_, i) => {
const a = a0 + (i * 2 * Math.PI) / n;
return `M${(cx + Math.cos(a) * r1).toFixed(1)} ${(cy + Math.sin(a) * r1).toFixed(1)} L${(cx + Math.cos(a) * r2).toFixed(1)} ${(cy + Math.sin(a) * r2).toFixed(1)}`;
}).join(' ');
const starPts = (n: number, ro: number, ri: number, cx = 50, cy = 52) =>
Array.from({ length: n * 2 }, (_, i) => {
const a = -Math.PI / 2 + (i * Math.PI) / n;
const r = i % 2 ? ri : ro;
return `${(cx + Math.cos(a) * r).toFixed(1)},${(cy + Math.sin(a) * r).toFixed(1)}`;
}).join(' ');
/** Korean names, same order as SYMBOL_NAMES in @bg/games/dobble (kept here so the screen does not pull in the rules module; a test checks they match). */
export const ICON_NAMES = [
'사과', '해', '달', '별', '하트', '물방울', '불꽃', '눈송이', '번개', '구름',
'나무', '꽃', '나뭇잎', '선인장', '버섯', '당근', '레몬', '체리', '고양이', '강아지',
'물고기', '새', '거북이', '무당벌레', '거미', '공룡', '토끼', '펭귄', '열쇠', '자물쇠',
'시계', '전구', '연필', '가위', '안경', '우산', '모자', '왕관', '종', '닻',
'자전거', '자동차', '로켓', '배', '공', '주사위', '음표', '카메라', '집', '성',
'선물상자', '촛불', '망치', '느낌표', '물음표', '눈사람', '무지개',
];
export const ICONS: (() => ReactNode)[] = [
// 0 사과
() => (
<>
<path d="M50 30 C36 18 13 26 14 52 C15 76 33 93 50 84 C67 93 85 76 86 52 C87 26 64 18 50 30 Z" fill="#ef4444" {...L} />
<path d="M50 30 L55 10" {...W('#78350f', 5)} />
<path d="M57 18 C64 6 78 8 80 12 C74 22 64 22 57 18 Z" fill="#22c55e" {...L} strokeWidth={2.5} />
<path d="M28 44 C30 38 34 35 38 34" {...W('#fecaca', 4)} />
</>
),
// 1 해
() => (
<>
<path d={rays(8, 28, 44)} {...W('#f59e0b', 7)} />
<circle cx="50" cy="50" r="22" fill="#fbbf24" {...L} />
</>
),
// 2 달
() => <path d="M60 10 A40 40 0 1 0 90 64 A31 31 0 1 1 60 10 Z" fill="#fde047" {...L} />,
// 3 별
() => <polygon points={starPts(5, 44, 19)} fill="#a78bfa" {...L} />,
// 4 하트
() => <path d="M50 86 C20 64 8 48 10 32 C12 16 32 8 50 26 C68 8 88 16 90 32 C92 48 80 64 50 86 Z" fill="#ec4899" {...L} />,
// 5 물방울
() => (
<>
<path d="M50 8 C44 20 22 44 22 64 A28 28 0 0 0 78 64 C78 44 56 20 50 8 Z" fill="#38bdf8" {...L} />
<path d="M36 62 C36 70 40 76 46 78" {...W('#e0f2fe', 5)} />
</>
),
// 6 불꽃
() => (
<>
<path d="M50 92 C26 92 16 74 22 56 C26 44 34 40 34 26 C44 32 46 40 46 46 C52 36 54 22 50 8 C70 20 84 42 80 64 C78 82 66 92 50 92 Z" fill="#f97316" {...L} />
<path d="M50 88 C38 88 34 78 38 70 C41 64 46 62 46 54 C54 60 62 68 60 78 C59 84 55 88 50 88 Z" fill="#fde047" />
</>
),
// 7 눈송이
() => (
<>
<path d={rays(6, 0, 42, 50, 50, Math.PI / 2)} {...W('#0284c7', 6)} />
<path d={[0, 1, 2, 3, 4, 5].map((i) => {
const a = Math.PI / 2 + (i * Math.PI) / 3;
const bx = 50 + Math.cos(a) * 26;
const by = 50 + Math.sin(a) * 26;
const p = (d: number) => `${(bx + Math.cos(a + d) * 12).toFixed(1)} ${(by + Math.sin(a + d) * 12).toFixed(1)}`;
return `M${p(0.9)} L${bx.toFixed(1)} ${by.toFixed(1)} L${p(-0.9)}`;
}).join(' ')} {...W('#0284c7', 5)} />
</>
),
// 8 번개
() => <polygon points="60,6 20,56 46,56 36,94 82,40 56,40 70,6" fill="#eab308" {...L} />,
// 9 구름
() => <path d="M24 76 C10 76 6 60 18 54 C14 40 30 30 40 38 C44 22 66 20 72 36 C86 32 96 48 88 58 C96 66 90 78 78 76 Z" fill="#cbd5e1" {...L} />,
// 10 나무 (pine)
() => (
<>
<rect x="44" y="76" width="12" height="18" fill="#92400e" {...L} />
<polygon points="50,6 24,40 38,40 18,62 34,62 12,80 88,80 66,62 82,62 62,40 76,40" fill="#16a34a" {...L} />
</>
),
// 11 꽃
() => (
<>
{[0, 1, 2, 3, 4].map((i) => {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
return <circle key={i} cx={50 + Math.cos(a) * 24} cy={50 + Math.sin(a) * 24} r="17" fill="#f472b6" stroke={O} strokeWidth="3" />;
})}
<circle cx="50" cy="50" r="13" fill="#fde047" stroke={O} strokeWidth="3" />
</>
),
// 12 나뭇잎
() => (
<>
<path d="M14 86 C14 40 40 12 88 12 C88 60 62 86 14 86 Z" fill="#4ade80" {...L} />
<path d="M14 86 L70 30 M40 60 L40 44 M54 46 L54 32 M40 60 L56 60 M54 46 L68 46" {...W('#166534', 3.5)} />
</>
),
// 13 선인장
() => (
<>
<path d="M50 90 L50 16 M50 58 L28 58 L28 36 M50 48 L72 48 L72 28" {...W(O, 22)} />
<path d="M50 90 L50 16 M50 58 L28 58 L28 36 M50 48 L72 48 L72 28" {...W('#22c55e', 15)} />
<path d="M44 30 L40 28 M56 40 L60 38 M44 72 L40 70 M56 78 L60 76" {...W(O, 2.5)} />
<rect x="30" y="86" width="40" height="10" rx="3" fill="#c2410c" {...L} />
</>
),
// 14 버섯
() => (
<>
<path d="M40 54 L36 88 C36 92 64 92 64 88 L60 54 Z" fill="#fef3c7" {...L} />
<path d="M8 56 C8 26 28 12 50 12 C72 12 92 26 92 56 Z" fill="#dc2626" {...L} />
<circle cx="32" cy="34" r="6" fill="#fff" />
<circle cx="56" cy="24" r="5" fill="#fff" />
<circle cx="70" cy="42" r="6" fill="#fff" />
<circle cx="46" cy="44" r="4" fill="#fff" />
</>
),
// 15 당근
() => (
<>
<path d="M50 30 C44 18 34 10 26 10 M50 30 C50 16 52 8 56 4 M50 30 C58 18 70 12 78 14" {...W('#16a34a', 7)} />
<path d="M32 32 C40 26 60 26 68 32 L52 94 C51 97 49 97 48 94 Z" fill="#f97316" {...L} />
<path d="M40 46 L48 46 M54 60 L60 60 M44 72 L50 72" {...W('#9a3412', 3)} />
</>
),
// 16 레몬
() => (
<>
<path d="M6 50 C14 46 18 22 50 20 C82 22 86 46 94 50 C86 54 82 78 50 80 C18 78 14 54 6 50 Z" fill="#fde047" {...L} />
<path d="M30 40 C34 34 40 32 46 31" {...W('#fefce8', 5)} />
</>
),
// 17 체리
() => (
<>
<path d="M30 66 C34 40 46 22 62 10 M70 62 C66 40 64 22 62 10" {...W('#15803d', 5)} />
<path d="M62 10 C72 4 86 8 88 14 C78 20 68 18 62 10 Z" fill="#22c55e" {...L} strokeWidth={2.5} />
<circle cx="28" cy="72" r="18" fill="#dc2626" stroke={O} strokeWidth="3.5" />
<circle cx="72" cy="70" r="18" fill="#b91c1c" stroke={O} strokeWidth="3.5" />
<circle cx="22" cy="66" r="4" fill="#fecaca" />
<circle cx="66" cy="64" r="4" fill="#fecaca" />
</>
),
// 18 고양이
() => (
<>
<path d="M16 40 L18 8 L40 26 C46 24 54 24 60 26 L82 8 L84 40 C90 52 88 82 50 86 C12 82 10 52 16 40 Z" fill="#fb923c" {...L} />
<ellipse cx="36" cy="52" rx="5" ry="7" fill={O} />
<ellipse cx="64" cy="52" rx="5" ry="7" fill={O} />
<path d="M46 64 L54 64 L50 69 Z" fill="#be185d" />
<path d="M8 60 L32 64 M8 72 L32 70 M92 60 L68 64 M92 72 L68 70" {...W(O, 2.5)} />
</>
),
// 19 강아지
() => (
<>
<ellipse cx="50" cy="54" rx="28" ry="32" fill="#d6a46b" {...L} />
<path d="M26 30 C10 30 6 52 12 70 C20 72 26 60 28 46 Z M74 30 C90 30 94 52 88 70 C80 72 74 60 72 46 Z" fill="#78350f" {...L} />
<circle cx="40" cy="50" r="4.5" fill={O} />
<circle cx="60" cy="50" r="4.5" fill={O} />
<ellipse cx="50" cy="66" rx="8" ry="6" fill={O} />
<path d="M50 72 L50 78 M42 80 C46 84 54 84 58 80" {...W(O, 3)} />
</>
),
// 20 물고기
() => (
<>
<path d="M8 50 C24 24 58 22 74 50 C58 78 24 76 8 50 Z" fill="#14b8a6" {...L} />
<path d="M72 50 L94 30 L94 70 Z" fill="#0f766e" {...L} />
<circle cx="26" cy="46" r="5" fill="#fff" stroke={O} strokeWidth="2.5" />
<path d="M40 36 C46 44 46 56 40 64" {...W('#99f6e4', 3.5)} />
</>
),
// 21 새
() => (
<>
<path d="M20 62 C20 36 40 24 58 30 C64 20 76 18 84 26 L96 30 L84 36 C84 64 66 80 44 80 L14 86 L26 72 C22 70 20 66 20 62 Z" fill="#60a5fa" {...L} />
<circle cx="74" cy="30" r="3.5" fill={O} />
<path d="M34 54 C44 46 58 50 62 60 C52 66 40 64 34 54 Z" fill="#1d4ed8" {...L} strokeWidth={2.5} />
</>
),
// 22 거북이
() => (
<>
<circle cx="86" cy="58" r="9" fill="#86efac" stroke={O} strokeWidth="3" />
<path d="M20 70 L16 84 M40 72 L38 86 M62 72 L64 86 M78 70 L82 82" {...W(O, 11)} />
<path d="M20 70 L16 84 M40 72 L38 86 M62 72 L64 86 M78 70 L82 82" {...W('#86efac', 6)} />
<path d="M10 70 C10 36 30 22 48 22 C68 22 84 36 84 70 Z" fill="#15803d" {...L} />
<path d="M48 32 L60 42 L56 58 L40 58 L36 42 Z M48 32 L48 24 M60 42 L76 40 M56 58 L64 70 M40 58 L32 70 M36 42 L20 42" {...W('#bbf7d0', 3)} />
</>
),
// 23 무당벌레
() => (
<>
<path d="M28 22 L20 10 M72 22 L80 10" {...W(O, 3.5)} />
<circle cx="50" cy="28" r="16" fill={O} />
<circle cx="50" cy="58" r="34" fill="#ef4444" stroke={O} strokeWidth="3.5" />
<path d="M50 26 L50 92" {...W(O, 3.5)} />
<circle cx="34" cy="48" r="6" fill={O} />
<circle cx="66" cy="48" r="6" fill={O} />
<circle cx="30" cy="70" r="5" fill={O} />
<circle cx="70" cy="70" r="5" fill={O} />
</>
),
// 24 거미
() => (
<>
<path d="M50 4 L50 30" {...W('#9ca3af', 2)} />
<path d="M40 50 L22 34 L12 44 M40 56 L16 52 L8 64 M42 62 L22 72 L18 88 M44 66 L36 82 L36 94 M60 50 L78 34 L88 44 M60 56 L84 52 L92 64 M58 62 L78 72 L82 88 M56 66 L64 82 L64 94" {...W(O, 4)} />
<circle cx="50" cy="38" r="10" fill="#374151" stroke={O} strokeWidth="3" />
<ellipse cx="50" cy="62" rx="15" ry="18" fill="#4b5563" stroke={O} strokeWidth="3" />
<circle cx="46" cy="36" r="2.5" fill="#fff" />
<circle cx="54" cy="36" r="2.5" fill="#fff" />
</>
),
// 25 공룡
() => (
<path
d="M88 16 C80 8 70 10 68 18 L64 46 C50 40 32 42 22 54 C14 62 8 64 2 62 C8 72 18 74 26 70 L26 88 L36 88 L38 74 L54 74 L56 88 L66 88 L66 66 C72 58 74 46 76 26 L86 26 C92 24 92 20 88 16 Z"
fill="#65a30d"
{...L}
/>
),
// 26 토끼
() => (
<>
<path d="M38 46 C28 30 26 6 34 4 C42 2 46 26 46 44 Z M62 46 C72 30 74 6 66 4 C58 2 54 26 54 44 Z" fill="#f5f5f4" {...L} />
<path d="M38 40 C33 28 32 14 35 10 M62 40 C67 28 68 14 65 10" {...W('#f9a8d4', 4)} />
<circle cx="50" cy="64" r="26" fill="#f5f5f4" stroke={O} strokeWidth="3.5" />
<circle cx="40" cy="60" r="4" fill={O} />
<circle cx="60" cy="60" r="4" fill={O} />
<path d="M46 70 L54 70 L50 75 Z" fill="#ec4899" />
</>
),
// 27 펭귄
() => (
<>
<ellipse cx="50" cy="54" rx="30" ry="40" fill="#111827" stroke={O} strokeWidth="3" />
<ellipse cx="50" cy="60" rx="20" ry="30" fill="#f8fafc" />
<circle cx="42" cy="34" r="4" fill="#fff" />
<circle cx="58" cy="34" r="4" fill="#fff" />
<circle cx="42" cy="34" r="2" fill={O} />
<circle cx="58" cy="34" r="2" fill={O} />
<path d="M44 42 L56 42 L50 50 Z" fill="#f97316" {...L} strokeWidth={2} />
<path d="M32 94 L44 94 L40 88 Z M56 94 L68 94 L60 88 Z" fill="#f97316" {...L} strokeWidth={2} />
</>
),
// 28 열쇠
() => (
<>
<circle cx="28" cy="50" r="18" fill="#f59e0b" stroke={O} strokeWidth="3.5" />
<circle cx="28" cy="50" r="7" fill="#fffbeb" stroke={O} strokeWidth="3" />
<path d="M46 44 L92 44 L92 56 L84 56 L84 66 L76 66 L76 56 L68 56 L68 64 L60 64 L60 56 L46 56 Z" fill="#f59e0b" {...L} />
</>
),
// 29 자물쇠
() => (
<>
<path d="M30 46 L30 32 C30 6 70 6 70 32 L70 46" {...W(O, 14)} />
<path d="M30 46 L30 32 C30 6 70 6 70 32 L70 46" {...W('#94a3b8', 8)} />
<rect x="18" y="44" width="64" height="48" rx="8" fill="#eab308" {...L} />
<circle cx="50" cy="62" r="7" fill={O} />
<path d="M50 64 L50 80" {...W(O, 6)} />
</>
),
// 30 시계
() => (
<>
<circle cx="50" cy="50" r="42" fill="#3b82f6" stroke={O} strokeWidth="3.5" />
<circle cx="50" cy="50" r="33" fill="#fff" stroke={O} strokeWidth="2.5" />
<path d={rays(12, 27, 31)} {...W(O, 3)} />
<path d="M50 50 L50 28 M50 50 L66 58" {...W(O, 5)} />
<circle cx="50" cy="50" r="4" fill="#dc2626" />
</>
),
// 31 전구
() => (
<>
<path d="M38 70 C38 58 18 50 20 32 C22 14 36 6 50 6 C64 6 78 14 80 32 C82 50 62 58 62 70 Z" fill="#fde047" {...L} />
<path d="M44 66 L44 50 L50 56 L56 50 L56 66" {...W('#ca8a04', 3)} />
<rect x="36" y="70" width="28" height="18" rx="3" fill="#9ca3af" {...L} />
<path d="M36 76 L64 76 M36 82 L64 82" {...W(O, 2.5)} />
<path d="M44 88 L56 88 L52 96 L48 96 Z" fill={O} />
</>
),
// 32 연필
() => (
<g transform="rotate(-45 50 50)">
<rect x="14" y="38" width="16" height="24" rx="4" fill="#f9a8d4" {...L} />
<rect x="28" y="38" width="8" height="24" fill="#9ca3af" {...L} />
<rect x="36" y="38" width="36" height="24" fill="#facc15" {...L} />
<path d="M72 38 L94 50 L72 62 Z" fill="#fde68a" {...L} />
<path d="M86 46 L94 50 L86 54 Z" fill={O} />
</g>
),
// 33 가위
() => (
<>
<path d="M36 62 L82 12 M64 62 L18 12" {...W(O, 10)} />
<path d="M36 62 L82 12 M64 62 L18 12" {...W('#cbd5e1', 5)} />
<circle cx="30" cy="76" r="13" fill="none" stroke={O} strokeWidth="9" />
<circle cx="30" cy="76" r="13" fill="none" stroke="#ef4444" strokeWidth="5" />
<circle cx="70" cy="76" r="13" fill="none" stroke={O} strokeWidth="9" />
<circle cx="70" cy="76" r="13" fill="none" stroke="#ef4444" strokeWidth="5" />
<circle cx="50" cy="45" r="4" fill={O} />
</>
),
// 34 안경
() => (
<>
<path d="M8 40 L4 30 M92 40 L96 30 M42 50 C46 44 54 44 58 50" {...W(O, 4)} />
<circle cx="26" cy="52" r="17" fill="#bae6fd" stroke={O} strokeWidth="5" />
<circle cx="74" cy="52" r="17" fill="#bae6fd" stroke={O} strokeWidth="5" />
<path d="M18 46 L24 40 M66 46 L72 40" {...W('#fff', 3.5)} />
</>
),
// 35 우산
() => (
<>
<path d="M50 50 L50 82 C50 92 36 92 36 84" {...W(O, 6)} />
<path d="M6 52 C6 26 28 10 50 10 C72 10 94 26 94 52 C88 46 80 46 72 52 C66 46 56 46 50 52 C44 46 34 46 28 52 C20 46 12 46 6 52 Z" fill="#8b5cf6" {...L} />
<path d="M50 10 L50 50 M28 52 C30 30 40 16 50 10 M72 52 C70 30 60 16 50 10" {...W('#ddd6fe', 2.5)} />
</>
),
// 36 모자 (top hat)
() => (
<>
<path d="M28 70 L32 14 L68 14 L72 70 Z" fill="#1f2937" {...L} />
<path d="M30 56 L70 56 L71 68 L29 68 Z" fill="#dc2626" />
<ellipse cx="50" cy="74" rx="44" ry="10" fill="#374151" {...L} />
</>
),
// 37 왕관
() => (
<>
<path d="M10 30 L28 54 L50 18 L72 54 L90 30 L82 80 L18 80 Z" fill="#facc15" {...L} />
<rect x="18" y="80" width="64" height="10" fill="#eab308" {...L} />
<circle cx="50" cy="62" r="7" fill="#dc2626" stroke={O} strokeWidth="2.5" />
<circle cx="30" cy="68" r="5" fill="#2563eb" stroke={O} strokeWidth="2.5" />
<circle cx="70" cy="68" r="5" fill="#2563eb" stroke={O} strokeWidth="2.5" />
</>
),
// 38 종
() => (
<>
<circle cx="50" cy="86" r="8" fill="#92400e" stroke={O} strokeWidth="3" />
<path d="M14 80 C22 72 22 60 24 44 C26 24 38 14 50 14 C62 14 74 24 76 44 C78 60 78 72 86 80 Z" fill="#f59e0b" {...L} />
<path d="M50 14 L50 6" {...W(O, 6)} />
<path d="M34 66 C34 50 36 36 44 28" {...W('#fde68a', 4)} />
</>
),
// 39 닻
() => (
<>
<circle cx="50" cy="16" r="9" fill="none" stroke="#1e3a8a" strokeWidth="6" />
<path d="M50 25 L50 88 M32 38 L68 38 M14 58 C16 78 32 88 50 88 C68 88 84 78 86 58 M14 58 L8 66 M14 58 L22 64 M86 58 L92 66 M86 58 L78 64" {...W('#1e3a8a', 7)} />
</>
),
// 40 자전거
() => (
<>
<circle cx="22" cy="66" r="17" fill="none" stroke={O} strokeWidth="6" />
<circle cx="78" cy="66" r="17" fill="none" stroke={O} strokeWidth="6" />
<path d="M22 66 L40 40 L66 40 L78 66 M40 40 L50 66 L66 40 M50 66 L22 66 M36 30 L48 30 M40 40 L37 30 M66 40 L62 26 L72 24" {...W('#e11d48', 5)} />
<circle cx="50" cy="66" r="4" fill={O} />
</>
),
// 41 자동차
() => (
<>
<path d="M6 66 L8 50 L24 46 L34 28 L68 28 L80 46 L94 50 L94 66 Z" fill="#2563eb" {...L} />
<path d="M38 34 L62 34 L72 46 L30 46 Z" fill="#bae6fd" {...L} strokeWidth={2.5} />
<path d="M50 34 L50 46" {...W(O, 2.5)} />
<circle cx="26" cy="68" r="11" fill="#374151" stroke={O} strokeWidth="3" />
<circle cx="74" cy="68" r="11" fill="#374151" stroke={O} strokeWidth="3" />
<circle cx="26" cy="68" r="4" fill="#d1d5db" />
<circle cx="74" cy="68" r="4" fill="#d1d5db" />
</>
),
// 42 로켓
() => (
<>
<path d="M36 66 L18 84 L20 62 Z M64 66 L82 84 L80 62 Z" fill="#dc2626" {...L} />
<path d="M42 80 L50 96 L58 80 Z" fill="#f97316" {...L} strokeWidth={2.5} />
<path d="M50 4 C66 18 70 40 66 80 L34 80 C30 40 34 18 50 4 Z" fill="#e5e7eb" {...L} />
<path d="M50 4 C58 10 62 18 64 26 L36 26 C38 18 42 10 50 4 Z" fill="#dc2626" {...L} />
<circle cx="50" cy="46" r="9" fill="#38bdf8" stroke={O} strokeWidth="3" />
</>
),
// 43 배 (sailboat)
() => (
<>
<path d="M50 8 L50 66" {...W(O, 4)} />
<path d="M54 12 L84 58 L54 58 Z" fill="#f8fafc" {...L} />
<path d="M46 22 L22 58 L46 58 Z" fill="#fbbf24" {...L} />
<path d="M6 66 L94 66 L80 86 L20 86 Z" fill="#b45309" {...L} />
</>
),
// 44 공 (soccer ball)
() => (
<>
<circle cx="50" cy="50" r="42" fill="#fff" stroke={O} strokeWidth="3.5" />
<polygon points={starPts(5, 14, 14, 50, 50).split(' ').filter((_, i) => i % 2 === 0).join(' ')} fill={O} />
<path d={rays(5, 14, 30, 50, 50, -Math.PI / 2)} {...W(O, 3)} />
{[0, 1, 2, 3, 4].map((i) => {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
return <circle key={i} cx={50 + Math.cos(a) * 36} cy={50 + Math.sin(a) * 36} r="7" fill={O} />;
})}
</>
),
// 45 주사위
() => (
<>
<rect x="12" y="12" width="76" height="76" rx="14" fill="#fff" {...L} />
{[
[30, 30],
[70, 30],
[50, 50],
[30, 70],
[70, 70],
].map(([x, y]) => (
<circle key={`${x}${y}`} cx={x} cy={y} r="7.5" fill="#dc2626" />
))}
</>
),
// 46 음표
() => (
<>
<path d="M62 72 L62 10 C70 22 86 26 84 46 C80 34 72 32 62 30" {...W(O, 7)} />
<ellipse cx="46" cy="74" rx="18" ry="13" transform="rotate(-24 46 74)" fill={O} />
</>
),
// 47 카메라
() => (
<>
<path d="M34 24 L40 14 L60 14 L66 24 Z" fill="#4b5563" {...L} />
<rect x="8" y="24" width="84" height="58" rx="10" fill="#6b7280" {...L} />
<circle cx="50" cy="53" r="20" fill="#e5e7eb" stroke={O} strokeWidth="3.5" />
<circle cx="50" cy="53" r="11" fill="#1e3a8a" />
<circle cx="46" cy="49" r="3" fill="#fff" />
<rect x="72" y="32" width="12" height="7" rx="2" fill="#fde047" stroke={O} strokeWidth="2" />
</>
),
// 48 집
() => (
<>
<rect x="20" y="46" width="60" height="44" fill="#fde68a" {...L} />
<path d="M8 50 L50 12 L92 50 Z" fill="#dc2626" {...L} />
<rect x="42" y="64" width="16" height="26" fill="#92400e" {...L} />
<rect x="26" y="56" width="12" height="12" fill="#bae6fd" stroke={O} strokeWidth="2.5" />
<rect x="62" y="56" width="12" height="12" fill="#bae6fd" stroke={O} strokeWidth="2.5" />
</>
),
// 49 성
() => (
<>
<path d="M8 92 L8 30 L14 30 L14 22 L22 22 L22 30 L28 30 L28 22 L36 22 L36 46 L64 46 L64 22 L72 22 L72 30 L78 30 L78 22 L86 22 L86 30 L92 30 L92 92 Z" fill="#a8a29e" {...L} />
<path d="M40 92 L40 72 C40 62 60 62 60 72 L60 92 Z" fill="#57534e" {...L} />
<path d="M50 46 L50 8 M50 8 L66 14 L50 20" fill="#ef4444" {...L} strokeWidth={3} />
<rect x="18" y="44" width="8" height="12" fill={O} />
<rect x="74" y="44" width="8" height="12" fill={O} />
</>
),
// 50 선물상자
() => (
<>
<rect x="14" y="40" width="72" height="50" fill="#16a34a" {...L} />
<rect x="8" y="28" width="84" height="16" fill="#22c55e" {...L} />
<path d="M44 28 L56 28 L56 90 L44 90 Z" fill="#facc15" {...L} strokeWidth={2.5} />
<path d="M50 28 C38 8 18 14 26 26 C30 30 40 30 50 28 C60 30 70 30 74 26 C82 14 62 8 50 28 Z" fill="#facc15" {...L} />
</>
),
// 51 촛불
() => (
<>
<path d="M50 8 C40 20 40 32 50 34 C60 32 60 20 50 8 Z" fill="#f97316" {...L} strokeWidth={2.5} />
<path d="M50 34 L50 40" {...W(O, 3)} />
<rect x="36" y="40" width="28" height="48" rx="3" fill="#f8fafc" {...L} />
<path d="M36 50 C42 50 42 60 46 60 C50 60 50 50 54 50" {...W('#e0e7ff', 4)} />
<ellipse cx="50" cy="90" rx="30" ry="6" fill="#64748b" {...L} />
</>
),
// 52 망치
() => (
<g transform="rotate(-35 50 50)">
<rect x="44" y="32" width="12" height="62" rx="4" fill="#b45309" {...L} />
<path d="M18 14 L76 14 L82 20 L82 32 L18 32 C12 32 10 26 12 22 Z" fill="#6b7280" {...L} />
</g>
),
// 53 느낌표
() => (
<>
<path d="M38 8 L62 8 L56 64 L44 64 Z" fill="#ef4444" {...L} />
<circle cx="50" cy="82" r="10" fill="#ef4444" stroke={O} strokeWidth="3.5" />
</>
),
// 54 물음표
() => (
<>
<path d="M26 34 C26 14 40 6 52 6 C68 6 78 18 76 32 C74 46 56 48 56 64" {...W(O, 18)} />
<path d="M26 34 C26 14 40 6 52 6 C68 6 78 18 76 32 C74 46 56 48 56 64" {...W('#3b82f6', 11)} />
<circle cx="56" cy="84" r="10" fill="#3b82f6" stroke={O} strokeWidth="3.5" />
</>
),
// 55 눈사람
() => (
<>
<path d="M24 60 L6 48 M76 60 L94 48 M10 51 L8 42 M90 51 L92 42" {...W('#78350f', 4)} />
<circle cx="50" cy="70" r="24" fill="#f8fafc" stroke={O} strokeWidth="3.5" />
<circle cx="50" cy="30" r="17" fill="#f8fafc" stroke={O} strokeWidth="3.5" />
<circle cx="44" cy="26" r="2.6" fill={O} />
<circle cx="56" cy="26" r="2.6" fill={O} />
<path d="M50 32 L64 36 L50 37 Z" fill="#f97316" />
<circle cx="50" cy="62" r="3" fill={O} />
<circle cx="50" cy="74" r="3" fill={O} />
<path d="M36 44 C44 50 56 50 64 44" {...W('#dc2626', 6)} />
</>
),
// 56 무지개
() => (
<>
{['#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#8b5cf6'].map((c, i) => (
<path key={c} d={`M${8 + i * 6} 78 A${42 - i * 6} ${42 - i * 6} 0 0 1 ${92 - i * 6} 78`} {...W(c, 6.5)} strokeLinecap="butt" />
))}
<path d="M2 80 C2 70 18 68 22 76 C30 74 34 84 26 88 L6 88 C0 88 0 82 2 80 Z" fill="#f8fafc" {...L} strokeWidth={2.5} />
<path d="M98 80 C98 70 82 68 78 76 C70 74 66 84 74 88 L94 88 C100 88 100 82 98 80 Z" fill="#f8fafc" {...L} strokeWidth={2.5} />
</>
),
];
/** One icon in its own 100×100 SVG. */
export function SymbolArt({ art, className }: { art: number; className?: string }) {
const draw = ICONS[art];
return (
<svg viewBox="0 0 100 100" className={className} aria-hidden focusable="false">
{draw ? draw() : null}
</svg>
);
}

View File

@@ -0,0 +1,854 @@
/** 도블 화면 (docs/games/dobble.md §9): two round cards, tap the one picture they share. */
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import type { CardView, ClaimPair, DobbleOptions, DobbleView, LogEntry, MiniGame, SeatView } from '@bg/games/dobble';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar } from '../../components/ui';
import { useServerNow } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback';
import { socket, useRoom } from '../../net/socket';
import type { BoardProps, GameUI, OptionsProps } from '../types';
import { ICON_NAMES, SymbolArt } from './art';
/** Taps this soon after one of the two cards changed are dropped as late (they were aimed at the previous card). */
const TAP_GRACE_MS = 250;
const MINI_KO: Record<MiniGame, string> = { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' };
const artName = (art: number | null | undefined) => (art === null || art === undefined ? '' : (ICON_NAMES[art] ?? ''));
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser =>
players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
const who = (players: Record<string, PublicUser>, me: string | null, id: string) => (id === me ? '내가' : `${nick(players, id)}님이`);
const CSS = `
.db { width: 100%; max-width: 1060px; display: flex; flex-direction: column; gap: 10px; align-self: center; --cd: min(440px, 50vh, calc(50% - 14px)); }
.db-top { display: flex; flex-wrap: wrap; gap: 6px 8px; align-items: center; justify-content: space-between; font-weight: 700; }
.db-top .grp { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.db-pill { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .9rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
.db-pill.warn { background: var(--warning-soft); color: var(--warning); }
.db-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none; }
.db-strip { display: flex; gap: 8px; list-style: none; margin: 0; padding: 2px 2px 4px; overflow-x: auto; justify-content: center; }
.db-seat { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); border: 3px solid transparent; color: var(--text); font: inherit; text-align: left; min-height: 48px; }
button.db-seat { cursor: pointer; }
.db-seat[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
.db-seat[data-out='true'] { opacity: .55; }
.db-seat:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.db-seat .who { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.db-seat .nm { font-weight: 800; font-size: .88rem; max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 4px; }
.db-seat .meta { font-size: .78rem; font-weight: 700; color: var(--text-sub); font-variant-numeric: tabular-nums; white-space: nowrap; }
.db-seat .meta.rtt { display: inline-flex; gap: 3px; align-items: center; }
.db-tag { border-radius: 999px; padding: 0 6px; font-size: .72rem; font-weight: 800; line-height: 1.6; white-space: nowrap; color: #fff; background: #6b7280; }
.db-tag.ok { background: #15803d; }
.db-tag.warn { background: #f59e0b; color: #111827; }
.db-table { position: relative; display: flex; gap: 24px; justify-content: center; align-items: flex-start; padding: 14px; border-radius: 26px; background: radial-gradient(ellipse at center, rgba(255,255,255,.12), rgba(255,255,255,0) 65%), var(--felt); border: 6px solid var(--wood); box-shadow: var(--shadow); touch-action: manipulation; }
.db-slot { display: flex; flex-direction: column; align-items: center; gap: 6px; width: var(--cd); }
.db-slot .cap { color: #fff; font-weight: 800; font-size: .95rem; text-shadow: 0 1px 2px rgba(0,0,0,.4); display: flex; gap: 6px; align-items: center; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.db-card { position: relative; width: 100%; aspect-ratio: 1; border-radius: 50%; background: #fffdf6; border: 4px solid #d6c7a1; box-shadow: 0 6px 0 rgba(0,0,0,.18), 0 10px 22px rgba(0,0,0,.28); }
.db-card.empty { background: rgba(255,255,255,.1); border: 3px dashed rgba(255,255,255,.55); box-shadow: none; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; text-align: center; padding: 18%; }
.db-card[data-shake='true'] { animation: db-shake .4s ease; }
.db-sym { position: absolute; border-radius: 50%; border: 0; padding: 0; margin: 0; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
.db-sym:disabled { cursor: default; }
@media (hover: hover) { .db-sym:hover:not(:disabled) { background: rgba(37, 99, 235, .08); } }
.db-sym:focus-visible { outline: 4px solid var(--focus); outline-offset: -2px; z-index: 2; }
.db-sym .ic { width: 80%; height: 80%; display: block; pointer-events: none; transform: rotate(var(--rot)) scale(var(--sc)); }
.db-sym .ic svg { width: 100%; height: 100%; display: block; }
.db-sym .ic.in { animation: db-in .25s ease-out; }
.db-sym[data-hint='true'] { box-shadow: 0 0 0 4px #f59e0b; animation: db-pulse .8s ease-in-out infinite; }
.db-sym[data-hit='true'] { box-shadow: 0 0 0 5px #16a34a; background: rgba(22,163,74,.14); }
.db-sym[data-hit='true'] .ic { transform: rotate(var(--rot)) scale(calc(var(--sc) * 1.25)); transition: transform .15s ease; }
.db-thumb { width: 44px; height: 44px; flex: none; position: relative; border-radius: 50%; background: #fffdf6; border: 2px solid #d6c7a1; }
.db-thumb .db-sym { cursor: inherit; }
.db-lock { position: absolute; inset: -8px; border-radius: 50%; pointer-events: none; display: flex; align-items: center; justify-content: center; z-index: 3; }
.db-lock b { background: rgba(17,24,39,.82); color: #fff; border-radius: 999px; padding: 6px 14px; font-size: 1.05rem; }
.db-count { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 7; background: rgba(0,0,0,.4); color: #fff; border-radius: 20px;
font-size: 5rem; font-weight: 900; text-shadow: 0 3px 8px rgba(0,0,0,.5); pointer-events: none; flex-direction: column; text-align: center; }
.db-count small { font-size: 1.1rem; padding: 0 12px; }
.db-bubble { position: absolute; left: 50%; top: 10px; transform: translateX(-50%); z-index: 6; background: #fff; color: #111827; border-radius: 16px; padding: 8px 16px;
font-weight: 900; font-size: 1.15rem; box-shadow: 0 6px 18px rgba(0,0,0,.3); animation: db-pop .3s ease; max-width: 92%; text-align: center; pointer-events: none; display: flex; align-items: center; gap: 8px; }
.db-bubble .bi { width: 34px; height: 34px; flex: none; }
.db-bubble.ok { border: 4px solid #16a34a; }
.db-bubble.bad { border: 4px solid #dc2626; }
.db-bubble.late { border: 4px solid #6b7280; color: #374151; font-size: .98rem; }
.db-bubble.info { border: 4px solid #2563eb; }
.db-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 14px; display: flex; gap: 10px; align-items: center; justify-content: center; flex-wrap: wrap; font-weight: 700; text-align: center; }
.db-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; }
.db-sus { color: var(--warning); font-weight: 800; }
@keyframes db-shake { 0%,100% { transform: none; } 20% { transform: translateX(-10px); } 40% { transform: translateX(9px); } 60% { transform: translateX(-6px); } 80% { transform: translateX(4px); } }
@keyframes db-in { from { opacity: 0; transform: rotate(var(--rot)) scale(calc(var(--sc) * .5)); } to { opacity: 1; transform: rotate(var(--rot)) scale(var(--sc)); } }
@keyframes db-pop { 0% { transform: translateX(-50%) scale(.6); opacity: 0; } 70% { transform: translateX(-50%) scale(1.06); opacity: 1; } 100% { transform: translateX(-50%) scale(1); } }
@keyframes db-pulse { 0%,100% { box-shadow: 0 0 0 4px rgba(245,158,11,.9); } 50% { box-shadow: 0 0 0 8px rgba(245,158,11,.35); } }
@media (max-width: 719px) {
.db { --cd: min(80vw, calc(50vh - 158px), 330px); gap: 6px; }
.db-slot { position: relative; }
.db-slot .cap { position: absolute; top: -2px; left: 50%; transform: translateX(-50%); z-index: 2; background: rgba(0,0,0,.6); border-radius: 999px; padding: 1px 10px; font-size: .78rem; }
.db-table { flex-direction: column; align-items: center; gap: 8px; padding: 8px; border-width: 5px; border-radius: 22px; }
.db-pill { font-size: .8rem; padding: 2px 8px; }
.db-strip { justify-content: flex-start; }
.db-seat { padding: 3px 8px; gap: 6px; }
.db-seat .meta.rtt, .db-pill.mode { display: none; }
.db-seat .who { flex-direction: row; align-items: center; gap: 5px; }
.db-seat .nm { max-width: 72px; font-size: .8rem; }
.db-thumb { width: 38px; height: 38px; }
.db-bubble { font-size: .92rem; padding: 4px 12px; top: 50%; transform: translate(-50%, -50%); animation: none; width: max-content; }
.db-bubble .bi { width: 26px; height: 26px; }
.db-count { font-size: 4rem; }
}
@media (prefers-reduced-motion: reduce) {
.db-card[data-shake='true'], .db-sym .ic.in, .db-bubble, .db-sym[data-hint='true'] { animation: none; }
.db-sym[data-hit='true'] .ic { transition: none; }
}
`;
// ---------------------------------------------------------------- card
function usePhone(): boolean {
const q = '(max-width: 719px)';
const [m, setM] = useState(() => typeof window !== 'undefined' && window.matchMedia(q).matches);
useEffect(() => {
const mq = window.matchMedia(q);
const on = () => setM(mq.matches);
mq.addEventListener('change', on);
return () => mq.removeEventListener('change', on);
}, []);
return m;
}
/**
* A round card with 8 pictures. Buttons are keyed by slot so keyboard focus stays put when the card changes
* (the picture inside is keyed by art and pops in). `onPick` null = look only.
*/
function DobbleCard({
card,
label,
side,
onPick,
hint,
hit,
shake,
children,
}: {
card: CardView;
label: string;
side: string;
onPick: ((art: number) => void) | null;
hint?: number[];
hit?: number | null;
shake?: boolean;
children?: ReactNode;
}) {
return (
<div className="db-card" role="group" aria-label={label} data-card={side} data-key={card.key} data-shake={!!shake}>
{card.items.map((it, i) => {
const d = it.r * 100;
return (
<button
key={i}
type="button"
className="db-sym"
data-art={it.art}
data-hint={!!hint?.includes(it.art)}
data-hit={hit === it.art}
disabled={!onPick}
aria-label={`${artName(it.art)}${onPick ? ' 누르기' : ''}`}
style={{ left: `${50 + it.x * 50 - d / 2}%`, top: `${50 + it.y * 50 - d / 2}%`, width: `${d}%`, height: `${d}%` }}
onPointerDown={(e) => {
// Fire on press, not release: every millisecond counts.
if (!onPick || e.button !== 0) return;
e.preventDefault();
onPick(it.art);
}}
onClick={(e) => {
// Keyboard activation; pointer presses were handled on pointerdown.
if (onPick && e.detail === 0) onPick(it.art);
}}
>
<span key={it.art} className="ic in" style={{ '--rot': `${it.rot}deg`, '--sc': it.scale } as CSSProperties}>
<SymbolArt art={it.art} />
</span>
</button>
);
})}
{children}
</div>
);
}
/** Small look-only card (target picker, rules). */
function Thumb({ card }: { card: CardView | null }) {
if (!card) return <span className="db-thumb" aria-hidden style={{ opacity: 0.4 }} />;
return (
<span className="db-thumb" aria-hidden>
{card.items.map((it, i) => {
const d = it.r * 100;
return (
<span key={i} className="db-sym" style={{ left: `${50 + it.x * 50 - d / 2}%`, top: `${50 + it.y * 50 - d / 2}%`, width: `${d}%`, height: `${d}%` }}>
<span className="ic" style={{ '--rot': `${it.rot}deg`, '--sc': it.scale } as CSSProperties}>
<SymbolArt art={it.art} />
</span>
</span>
);
})}
</span>
);
}
// ---------------------------------------------------------------- seats
function countText(mode: MiniGame, s: SeatView): string {
switch (mode) {
case 'tower':
return `탑 ${s.cards}장`;
case 'well':
return s.status === 'finished' ? '다 비웠어요' : `남은 카드 ${s.cards}장`;
case 'poisonedGift':
return `받은 카드 ${s.cards}장`;
case 'hotPotato':
return `${s.cards}장 · 벌점 ${s.penalty}`;
}
}
function statusTag(s: SeatView): { text: string; cls: string } | null {
if (s.status === 'finished') return { text: s.place ? `${s.place}등 완주` : '완주', cls: 'ok' };
if (s.status === 'escaped') return { text: '탈출', cls: 'ok' };
if (s.status === 'forfeit') return { text: '기권', cls: '' };
if (s.status === 'left') return { text: '나감', cls: '' };
return null;
}
function Rtt({ ms }: { ms: number | null }) {
if (ms === null) return null;
const color = ms < 80 ? '#16a34a' : ms < 200 ? '#eab308' : '#dc2626';
return (
<span className="meta rtt" aria-label={`연결 지연 ${ms}밀리초`}>
<i className="db-dot" style={{ background: color }} aria-hidden />
{ms}ms
</span>
);
}
function seatLabel(v: DobbleView, s: SeatView, players: Record<string, PublicUser>, me: string | null): string {
const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`, countText(v.options.miniGame, s)];
const tag = statusTag(s);
if (tag) parts.push(tag.text);
return parts.join(', ');
}
// ---------------------------------------------------------------- board
interface Bubble {
key: string;
kind: 'ok' | 'bad' | 'late' | 'info';
text: string;
art?: number | null;
}
function DobbleBoard({ view: v, me, players, act, pending }: BoardProps<DobbleView>) {
const now = useServerNow(100);
const phone = usePhone();
const rtt = useRoom((s) => s.rttMs);
const [sel, setSel] = useState<string | null>(null);
const [bubble, setBubble] = useState<Bubble | null>(null);
const [shake, setShake] = useState(false);
const [hitArt, setHitArt] = useState<number | null>(null);
const [announce, setAnnounce] = useState('');
const prev = useRef<DobbleView | null>(null);
const changed = useRef({ sig: '', at: 0 });
const mode = v.options.miniGame;
const targetMode = mode === 'hotPotato' || mode === 'poisonedGift';
const mine = v.seats.find((s) => s.id === me) ?? null;
const others = v.seats.filter((s) => s.id !== me);
const pair: ClaimPair | null = targetMode ? (v.pairs.find((p) => p.target === sel) ?? v.pairs[0] ?? null) : (v.pairs[0] ?? null);
// The opponent whose card is shown big (also for spectators / while waiting).
const targetId = targetMode ? (pair?.target ?? (others.find((s) => s.id === sel && s.top) ?? others.find((s) => s.status === 'playing' && s.top) ?? others.find((s) => s.top))?.id ?? null) : null;
const targetSeat = targetId ? (v.seats.find((s) => s.id === targetId) ?? null) : null;
// Clamped to the lock length: the client clock estimate may be a little off.
const lockedLeft = mine ? Math.min(v.options.wrongLockoutMs, Math.max(0, mine.lockedUntil - now)) : 0;
const playing = v.phase === 'playing';
const canTap = !!pair && playing && lockedLeft === 0 && !pending;
const claim = (art: number) => {
if (!pair) return;
// A tap right after a compared card changed was aimed at the old card (nobody reacts that fast): treat it as late, no penalty.
if (performance.now() - changed.current.at < TAP_GRACE_MS) {
setBubble({ key: `gr${Date.now()}`, kind: 'late', text: '늦었어요! 그 사이에 카드가 바뀌었어요.' });
return;
}
if (!canTap) {
if (lockedLeft > 0) setBubble({ key: `lk${Date.now()}`, kind: 'late', text: `잠깐! ${Math.ceil(lockedLeft / 1000)}초 뒤에 다시 눌러요` });
return;
}
haptic(15);
act({
type: 'claim',
base: pair.base,
symbol: art,
...(pair.target ? { target: pair.target } : {}),
...(pair.mine !== null ? { mine: pair.mine } : {}),
...(rtt !== null ? { rtt } : {}),
});
};
// "늦었어요" rejects come back as `reject` messages.
useEffect(
() =>
socket.subscribe((m) => {
if (m.t === 'reject' && m.reason.startsWith('늦었어요')) {
setBubble({ key: `late${Date.now()}`, kind: 'late', text: m.reason });
setAnnounce(m.reason);
}
}),
[],
);
// Diff against the previous view: my judgement, other people's finds, rounds.
useEffect(() => {
const p = prev.current;
prev.current = v;
if (!p) return;
const f = v.feedback;
if (f && f.seq !== p.feedback?.seq) {
if (f.kind === 'ok') {
sounds.ding();
haptic([30, 30, 60]);
setHitArt(f.art);
const b: Bubble = { key: `f${f.seq}`, kind: 'ok', text: '찾았어요!', art: f.art };
setBubble(b);
setAnnounce(`찾았어요! ${artName(f.art)}`);
} else if (f.kind === 'wrong') {
sounds.buzz();
haptic([90, 50, 90]);
setShake(true);
const lock = v.options.wrongLockoutMs ? ` · ${v.options.wrongLockoutMs / 1000}초 잠금` : '';
const b: Bubble = { key: `f${f.seq}`, kind: 'bad', text: `틀렸어요!${lock}`, art: f.art };
setAnnounce(`틀렸어요! ${artName(f.art)}는 같은 그림이 아니에요${lock}`);
setBubble(b);
} else if (f.kind === 'late') {
setBubble({ key: `f${f.seq}`, kind: 'late', text: '늦었어요! 다른 사람이 먼저 찾았어요.' });
setAnnounce('늦었어요');
} else if (f.kind === 'locked') {
setBubble({ key: `f${f.seq}`, kind: 'late', text: '잠깐! 조금 뒤에 다시 눌러요' });
}
return;
}
const h = v.lastHit;
if (h && h.seq !== p.lastHit?.seq && h.player !== me) {
sounds.stone();
let text = `${nick(players, h.player)}님이 찾았어요`;
if (mode === 'poisonedGift' && h.target === me) text = `${nick(players, h.player)}님이 나에게 카드를 줬어요`;
if (mode === 'hotPotato' && h.target === me) text = `${nick(players, h.player)}님이 카드 묶음을 넘겼어요!`;
setBubble({ key: `h${h.seq}`, kind: 'info', text, art: h.art });
setAnnounce(`${text} · ${artName(h.art)}`);
}
const r = v.lastRound;
if (r && r.round !== p.lastRound?.round) {
const text = r.loser ? `${r.round}라운드 끝! ${r.loser === me ? '내가' : `${nick(players, r.loser)}님이`} 카드 ${r.cards}장을 벌점으로 받아요` : `${r.round}라운드는 무효예요`;
setBubble({ key: `r${r.round}`, kind: r.loser === me ? 'bad' : 'info', text });
setAnnounce(text);
}
}, [v]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!bubble) return;
const t = setTimeout(() => setBubble(null), bubble.kind === 'late' ? 1300 : 1700);
return () => clearTimeout(t);
}, [bubble]);
useEffect(() => {
if (!shake) return;
const t = setTimeout(() => setShake(false), 450);
return () => clearTimeout(t);
}, [shake]);
useEffect(() => {
if (hitArt === null) return;
const t = setTimeout(() => setHitArt(null), 500);
return () => clearTimeout(t);
}, [hitArt]);
const countdown = v.phase === 'countdown' || v.phase === 'roundEnd' ? Math.max(0, Math.ceil((v.phaseEndsAt - now) / 1000)) : 0;
const hint = v.hintUntil > now ? v.hint : [];
const tapper = canTap || (!!pair && playing && lockedLeft > 0) ? claim : null;
// Which two cards are compared (§5.2): A = top/left, B = bottom/right.
const theirCap = (s: SeatView | null) => (s ? `${nick(players, s.id)}님 카드` : '상대 카드');
let a: { card: CardView | null; cap: string; side: string };
let b: { card: CardView | null; cap: string; side: string };
if (mode === 'hotPotato') {
a = { card: targetSeat?.top ?? null, cap: theirCap(targetSeat), side: 'target' };
b = { card: mine?.top ?? null, cap: '내 카드', side: 'mine' };
} else if (mode === 'poisonedGift') {
a = { card: v.center, cap: '가운데 카드', side: 'center' };
b = { card: targetSeat?.top ?? null, cap: `${theirCap(targetSeat)}에 줄 선물`, side: 'target' };
} else {
a = { card: v.center, cap: '가운데 카드', side: 'center' };
b = { card: mine?.top ?? null, cap: mine ? '내 카드' : '관전 중', side: 'mine' };
}
const emptyText = (side: string): string => {
if (v.phase === 'countdown') return '곧 공개돼요';
if (side === 'mine') {
if (!mine) return '관전 중이에요';
if (mine.status === 'finished') return `다 비웠어요! ${mine.place ?? ''}등`;
if (mine.status === 'escaped') return '이번 라운드 탈출!';
if (mine.status === 'forfeit' || mine.status === 'left') return '게임에서 빠졌어요';
}
if (v.phase === 'roundEnd') return '다음 라운드를 준비해요';
return '카드가 없어요';
};
const slot = (x: typeof a, withLock: boolean) => (
<div className="db-slot">
<span className="cap">{x.cap}</span>
{x.card ? (
<DobbleCard card={x.card} label={x.cap} side={x.side} onPick={tapper} hint={hint} hit={hitArt} shake={withLock && shake}>
{withLock && lockedLeft > 0 && (
<span
className="db-lock"
aria-hidden
style={{ background: `conic-gradient(rgba(17,24,39,.45) ${(lockedLeft / Math.max(1, v.options.wrongLockoutMs)) * 360}deg, transparent 0)` }}
>
<b>잠깐! {Math.ceil(lockedLeft / 1000)}초</b>
</span>
)}
</DobbleCard>
) : (
<div className="db-card empty" data-card={x.side}>
{emptyText(x.side)}
</div>
)}
</div>
);
const sig = `${a.card?.key ?? '-'}|${b.card?.key ?? '-'}`;
if (changed.current.sig !== sig) changed.current = { sig, at: performance.now() };
const remaining = mode === 'well' ? null : mode === 'hotPotato' ? `${v.round}/${v.rounds}라운드` : `남은 카드 ${v.drawCount + (v.center ? 1 : 0)}장`;
const pickable = new Set(v.pairs.map((p) => p.target));
return (
<div className="db">
<style>{CSS}</style>
<div className="db-top">
<div className="grp">
<span className="db-pill mode">{MINI_KO[mode]}</span>
{remaining && <span className="db-pill">{remaining}</span>}
{mine && <span className="db-pill">내 카드 · {countText(mode, mine)}</span>}
</div>
<div className="grp">
{v.options.showRtt && rtt !== null && (
<span className={`db-pill${rtt >= 200 ? ' warn' : ''}`} aria-label={`내 연결 지연 ${rtt}밀리초`}>
<i className="db-dot" style={{ background: rtt < 80 ? '#16a34a' : rtt < 200 ? '#eab308' : '#dc2626' }} aria-hidden />
{rtt >= 200 ? `연결이 느려요 · ${rtt}ms` : `핑 ${rtt}ms`}
</span>
)}
</div>
</div>
{others.length > 0 && (
<ul className="db-strip" aria-label={targetMode ? '누구 카드에서 찾을지 고르기' : '다른 사람'}>
{others.map((s) => {
const tag = statusTag(s);
const canPick = targetMode && pickable.has(s.id);
const inner = (
<>
{targetMode && <Thumb card={s.top} />}
{!targetMode && <Avatar user={userOf(players, s.id)} size={28} />}
<span className="who">
<span className="nm">
{targetMode && <Avatar user={userOf(players, s.id)} size={16} />}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{nick(players, s.id)}</span>
</span>
<span className="meta">
{countText(mode, s)}
{s.lockedUntil > now && s.status === 'playing' ? ' · 잠금' : ''}
</span>
{v.options.showRtt && <Rtt ms={s.rtt} />}
</span>
{tag && <span className={`db-tag ${tag.cls}`}>{tag.text}</span>}
</>
);
return (
<li key={s.id}>
{canPick ? (
<button
type="button"
className="db-seat"
data-seat={s.id}
aria-pressed={targetId === s.id}
aria-label={`${seatLabel(v, s, players, me)} · 이 사람 카드 고르기`}
onClick={() => setSel(s.id)}
>
{inner}
</button>
) : (
<div className="db-seat" data-seat={s.id} data-out={s.status !== 'playing'} aria-label={seatLabel(v, s, players, me)} aria-pressed={targetMode ? targetId === s.id : undefined}>
{inner}
</div>
)}
</li>
);
})}
</ul>
)}
<div className="db-table" role="region" aria-label="카드 테이블">
{slot(a, false)}
{slot(b, true)}
{bubble && (
<div key={bubble.key} className={`db-bubble ${bubble.kind}`} data-bubble={bubble.key} aria-hidden>
{bubble.art !== undefined && bubble.art !== null && <SymbolArt art={bubble.art} className="bi" />}
<span>{bubble.text}</span>
</div>
)}
{countdown > 0 && (
<div className="db-count" aria-hidden>
{countdown}
<small>{v.phase === 'roundEnd' ? '다음 라운드를 준비해요' : '두 카드에 똑같이 있는 그림을 찾아요!'}</small>
</div>
)}
</div>
{targetMode && v.pairs.length > 1 && playing && (
<div className="db-panel small" style={{ fontWeight: 600 }}>
{mode === 'poisonedGift' ? '위에서 선물을 줄 사람을 골라요.' : '위에서 카드를 넘길 사람을 골라요.'} 지금: <strong>{targetSeat ? nick(players, targetSeat.id) : ''}님</strong>
</div>
)}
{!phone && pair && playing && (
<span className="small muted" style={{ textAlign: 'center' }}>
Tab 키로 그림을 고르고 Enter 키로 눌러요
</span>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
// ---------------------------------------------------------------- side
function logText(e: LogEntry, mode: MiniGame, players: Record<string, PublicUser>, me: string | null): string {
switch (e.t) {
case 'hit': {
const n = who(players, me, e.p);
const t = e.target === 'center' ? '' : e.target === me ? '나' : `${nick(players, e.target)}님`;
if (mode === 'tower') return `${n} '${artName(e.art)}'를 찾아 가운데 카드를 가져갔어요`;
if (mode === 'well') return `${n} '${artName(e.art)}'를 찾아 카드를 가운데에 내려놓았어요`;
if (mode === 'poisonedGift') return `${n} '${artName(e.art)}'를 찾아 ${t}에게 카드를 줬어요`;
return `${n} '${artName(e.art)}'를 찾아 ${t}에게 카드 묶음을 넘겼어요`;
}
case 'done':
return `${who(players, me, e.p)} 카드를 다 비웠어요 · ${e.place}등`;
case 'round':
return e.loser ? `${e.round}라운드 끝 · ${who(players, me, e.loser)} 벌점 ${e.cards}장을 받았어요` : `${e.round}라운드는 무효예요`;
case 'out':
return e.why === 'left' ? `${who(players, me, e.p)} 나갔어요` : `${who(players, me, e.p)} 오래 자리를 비워 기권했어요`;
case 'hint':
return '45초 동안 아무도 못 찾아서 같은 그림을 살짝 보여 줬어요';
}
}
function rulesLine(o: DobbleOptions): string {
const parts = [MINI_KO[o.miniGame], `${o.deckSize}장`];
if (o.miniGame === 'hotPotato') parts.push(`${o.hotPotatoRounds}라운드`);
parts.push(o.wrongLockoutMs ? `틀리면 ${o.wrongLockoutMs / 1000}초 잠금` : '틀려도 잠금 없음');
if (o.symbolStyle === 'easy') parts.push('그림 크기 같게');
if (o.latencyComp === 'light') parts.push('지연 보정');
if (o.miniGame === 'well' && o.wellEndAtFirst) parts.push('첫 완주에 끝');
if (o.miniGame === 'tower' && o.wrongGivesCard) parts.push('틀리면 1장 반납');
return parts.join(' · ');
}
function DobbleSide({ view: v, me, players }: BoardProps<DobbleView>) {
const mode = v.options.miniGame;
return (
<>
<style>{CSS}</style>
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<strong>도블{v.phase === 'ended' ? ' · 게임 끝' : ''}</strong>
<span className="small muted">{rulesLine(v.options)}</span>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
{v.seats.map((s) => {
const sus = v.suspects.includes(s.id);
const tag = statusTag(s);
return (
<li
key={s.id}
className="row"
style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'forfeit' || s.status === 'left' ? 0.55 : 1 }}
aria-label={`${seatLabel(v, s, players, me)}, 찾기 ${s.correct}번, 틀림 ${s.wrong}번${sus ? ', 너무 빨리 맞혀서 확인이 필요해요' : ''}`}
>
<Avatar user={userOf(players, s.id)} size={24} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
{nick(players, s.id)}
{s.id === me ? ' (나)' : ''}
{tag ? ` · ${tag.text}` : ''}
</span>
{sus && (
<span className="db-sus small" aria-hidden title="카드가 바뀌고 0.2초 안에 5번 연속으로 맞혔어요">
⚠ 의심
</span>
)}
{v.options.showRtt && s.rtt !== null && (
<span className="small" aria-hidden style={{ display: 'inline-flex', gap: 3, alignItems: 'center' }}>
<i className="db-dot" style={{ background: s.rtt < 80 ? '#16a34a' : s.rtt < 200 ? '#eab308' : '#dc2626' }} />
{s.rtt}
</span>
)}
<span className="small" aria-hidden>
✓{s.correct} ✗{s.wrong}
</span>
<strong style={{ fontVariantNumeric: 'tabular-nums' }} aria-hidden>
{mode === 'hotPotato' ? `벌점 ${s.penalty}` : `${s.cards}장`}
</strong>
</li>
);
})}
</ol>
{v.suspects.length > 0 && <span className="small muted">⚠ 의심: 카드가 바뀌고 0.2초 안에 5번 연속으로 맞혔어요. 방장에게만 보여요.</span>}
</div>
{v.result && (
<div className="card stack" style={{ gap: 4, padding: 12 }}>
<strong>최종 순위</strong>
{v.result.ranking.map((r) => (
<span key={r.id}>
{r.rank}등 {nick(players, r.id)}
<span className="muted small">
{' '}
· {r.penalty !== undefined ? `벌점 ${r.penalty}장` : `${r.cards}장`} · {r.points}점 · 찾기 {v.result!.stats[r.id]?.correct ?? 0} · 틀림 {v.result!.stats[r.id]?.wrong ?? 0}
</span>
</span>
))}
<span className="small muted">{v.result.summary}</span>
</div>
)}
{v.log.length > 0 && (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
<ol className="db-log">
{[...v.log].reverse().map((e) => (
<li key={e.n} className="small">
{logText(e, mode, players, me)}
</li>
))}
</ol>
</details>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: DobbleView, me: string | null): boolean {
return !!me && v.phase === 'playing' && v.pairs.length > 0;
}
function statusText(v: DobbleView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'ended') return v.result?.summary ?? '게임이 끝났어요';
if (v.phase === 'countdown') return v.round > 1 ? `${v.round}라운드 곧 시작해요!` : '곧 시작해요! 두 카드에 똑같이 있는 그림을 찾아요';
if (v.phase === 'roundEnd') return '라운드가 끝났어요 · 다음 라운드를 준비해요';
const mine = v.seats.find((s) => s.id === me);
if (mine?.status === 'finished') return `카드를 다 비웠어요! ${mine.place ?? ''}등 · 남은 게임을 지켜봐요`;
if (mine?.status === 'escaped') return '이번 라운드는 탈출했어요 · 다음 라운드를 기다려요';
if (mine && (mine.status === 'forfeit' || mine.status === 'left')) return '게임에서 빠졌어요 · 남은 게임을 지켜봐요';
if (isMyTurn(v, me)) {
return '지금 찾아요! 같은 그림을 눌러요';
}
const leader = v.lastHit ? nick(players, v.lastHit.player) : null;
return leader ? `${leader}님이 방금 찾았어요 · 다 같이 찾는 중이에요` : '다 같이 같은 그림을 찾는 중이에요';
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number | boolean>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
return (
<div className="segmented" role="group" aria-label={label}>
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
{help && <span className="small muted">{help}</span>}
</div>
);
}
const onOff: [boolean, string][] = [
[true, '켜기'],
[false, '끄기'],
];
const MINI_HELP: Record<MiniGame, string> = {
tower: '내 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 가져와요. 카드를 가장 많이 모으면 1등!',
well: '내 카드와 가운데 카드의 같은 그림을 찾으면 내 카드를 가운데에 내려놓아요. 먼저 다 비우면 1등!',
hotPotato: '내 카드와 다른 사람 카드의 같은 그림을 찾으면 내 카드 묶음을 그 사람에게 넘겨요. 마지막에 남은 사람이 벌점!',
poisonedGift: '다른 사람 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 그 사람에게 줘요. 카드를 가장 적게 받으면 1등!',
};
function Options({ value: o, onChange }: OptionsProps<DobbleOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<DobbleOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="미니게임" help={MINI_HELP[o.miniGame]}>
<Seg
label="미니게임"
value={o.miniGame}
options={[
['tower', '탑 쌓기'],
['well', '우물'],
['hotPotato', '뜨거운 감자'],
['poisonedGift', '독이 든 선물'],
]}
onChange={(miniGame) => set({ miniGame })}
/>
</Field>
{o.miniGame === 'hotPotato' && (
<Field label="뜨거운 감자 라운드 수">
<Seg
label="뜨거운 감자 라운드 수"
value={o.hotPotatoRounds}
options={[
[3, '3라운드'],
[5, '5라운드'],
[7, '7라운드'],
]}
onChange={(hotPotatoRounds) => set({ hotPotatoRounds })}
/>
</Field>
)}
<Field label="틀린 그림을 눌렀을 때 잠금" help="잠금 동안에는 맞는 그림을 눌러도 받아 주지 않아요.">
<Seg
label="틀린 그림을 눌렀을 때 잠금"
value={o.wrongLockoutMs}
options={[
[0, '없음'],
[1000, '1초'],
[2000, '2초'],
]}
onChange={(wrongLockoutMs) => set({ wrongLockoutMs })}
/>
</Field>
<Field label="그림 크기·회전" help={o.symbolStyle === 'easy' ? '어린이 모드: 그림 크기가 같고 돌아가 있지 않아요.' : '그림마다 크기와 방향이 달라요.'}>
<Seg
label="그림 크기·회전"
value={o.symbolStyle}
options={[
['classic', '원작처럼'],
['easy', '쉽게'],
]}
onChange={(symbolStyle) => set({ symbolStyle })}
/>
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="카드 장수">
<Seg
label="카드 장수"
value={o.deckSize}
options={[
[57, '57장'],
[55, '55장'],
]}
onChange={(deckSize) => set({ deckSize })}
/>
</Field>
<Field label="지연 보정" help="켜면 0.06초 동안 모인 정답 가운데, 연결 지연의 절반(최대 0.08초)을 빼 준 시각이 가장 빠른 사람이 이겨요.">
<Seg
label="지연 보정"
value={o.latencyComp}
options={[
['off', '끄기'],
['light', '가볍게'],
]}
onChange={(latencyComp) => set({ latencyComp })}
/>
</Field>
<Field label="각자 핑 표시">
<Seg label="각자 핑 표시" value={o.showRtt} options={onOff} onChange={(showRtt) => set({ showRtt })} />
</Field>
{o.miniGame === 'well' && (
<Field label="우물: 처음 다 비운 사람이 나오면 바로 끝내기">
<Seg label="우물 첫 완주에 끝내기" value={o.wellEndAtFirst} options={onOff} onChange={(wellEndAtFirst) => set({ wellEndAtFirst })} />
</Field>
)}
{o.miniGame === 'tower' && (
<Field label="탑 쌓기: 틀리면 내 탑 맨 위 카드를 반납">
<Seg label="틀리면 카드 반납" value={o.wrongGivesCard} options={onOff} onChange={(wrongGivesCard) => set({ wrongGivesCard })} />
</Field>
)}
</>
)}
</div>
);
}
function optionsSummary(o: DobbleOptions): string {
return rulesLine(o);
}
// ---------------------------------------------------------------- rules
/** Two tiny sample cards that share only the 고양이 (slots of layout template 0). */
const SLOTS0: [number, number][] = [[-0.448, 0.516], [-0.683, -0.028], [0.011, -0.014], [0.18, -0.659], [0.628, -0.271], [0.603, 0.322], [-0.404, -0.552], [0.124, 0.672]];
const sample = (id: number, arts: number[]): CardView => ({ id, key: `r${id}`, items: arts.map((art, i) => ({ art, x: SLOTS0[i]![0], y: SLOTS0[i]![1], r: 0.287, scale: 1, rot: 0 })) });
const SAMPLE: CardView[] = [sample(-1, [0, 1, 18, 31, 44, 11, 28, 56]), sample(-2, [3, 41, 20, 38, 18, 50, 35, 17])];
function Rules({ options }: { options?: DobbleOptions }) {
const lock = options?.wrongLockoutMs ?? 1000;
return (
<div className="stack">
<style>{CSS}</style>
<p>
동그란 카드 57장에는 그림이 8개씩 있어요. 어느 두 카드를 골라도 <strong>똑같은 그림이 딱 하나</strong> 있어요. 그 그림을 누구보다 빨리 찾아 눌러요. 서버에 먼저 도착한 사람이 이겨요.
</p>
<div className="row" style={{ gap: 10, alignItems: 'center' }}>
<span style={{ width: 96, display: 'inline-block' }}>
<DobbleCard card={SAMPLE[0]!} label="예시 카드 1" side="r1" onPick={null} hint={[18]} />
</span>
<span style={{ width: 96, display: 'inline-block' }}>
<DobbleCard card={SAMPLE[1]!} label="예시 카드 2" side="r2" onPick={null} hint={[18]} />
</span>
<span>→ 같은 그림은 고양이!</span>
</div>
<ul>
<li>
<strong>탑 쌓기</strong>: {MINI_HELP.tower}
</li>
<li>
<strong>우물</strong>: {MINI_HELP.well}
</li>
<li>
<strong>뜨거운 감자</strong>: {MINI_HELP.hotPotato} 정한 라운드가 끝나면 벌점이 가장 적은 사람이 1등이에요.
</li>
<li>
<strong>독이 든 선물</strong>: {MINI_HELP.poisonedGift}
</li>
</ul>
<p>
틀린 그림을 누르면 {lock ? `${lock / 1000}초 동안 누를 수 없어요` : '벌칙은 없어요'}. 다른 사람이 먼저 찾아 카드가 바뀌었으면 "늦었어요"가 뜨고 벌칙은 없어요. 뜨거운 감자·독이 든 선물은 위에서 상대를 먼저 고르고 그림을 눌러요(상대가 1명이면 저절로 골라져요).
</p>
<p>PC에서는 Tab 키로 그림을 옮겨 다니고 Enter 키로 누를 수 있어요. 순위 점수는 1등 3점, 2등 2점, 3등 1점이에요.</p>
</div>
);
}
const ui: GameUI<DobbleView, DobbleOptions> = {
Board: DobbleBoard,
Side: DobbleSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -25,6 +25,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
'las-vegas': () => import('./las-vegas'), 'las-vegas': () => import('./las-vegas'),
'cockroach-poker': () => import('./cockroach-poker'), 'cockroach-poker': () => import('./cockroach-poker'),
'ninety-nine': () => import('./ninety-nine'), 'ninety-nine': () => import('./ninety-nine'),
dobble: () => import('./dobble'),
}; };
const cache = new Map<string, GameUI>(); const cache = new Map<string, GameUI>();

View File

@@ -22,6 +22,7 @@ export const GAME_ICON: Record<string, string> = {
'las-vegas': '⚅', 'las-vegas': '⚅',
'cockroach-poker': '🐞', 'cockroach-poker': '🐞',
'ninety-nine': '💯', 'ninety-nine': '💯',
dobble: '👀',
}; };
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */

View File

@@ -641,6 +641,36 @@ async function ninetyNineStep(p: Page, stats: Record<string, number>): Promise<b
return true; return true;
} }
/**
* 도블: one real tap on `p`'s own screen. Reads the pictures of the two compared cards from the DOM (data-art), taps the shared one
* (or, when `wrong`, a picture of my card that is not on the other). PC: focus + Enter, phone: tap. Returns the judgement shown.
*/
async function dobbleTap(p: Page, wrong: boolean): Promise<string | null> {
const arts = (side: string) => p.locator(`[data-card="${side}"] .db-sym`).evaluateAll((els) => els.map((e) => Number(e.getAttribute('data-art'))));
const mine = p.locator('[data-card="mine"] .db-sym:not([disabled])');
if ((await mine.count()) === 0) return null;
const [a, b] = await Promise.all([arts('center'), arts('mine')]);
const shared = b.filter((x) => a.includes(x));
if (shared.length !== 1) return null; // the cards changed between the two reads
const art = wrong ? b.find((x) => x !== shared[0])! : shared[0]!;
const key = await p.locator('[data-card="center"]').getAttribute('data-key');
const old = (await p.locator('.db-bubble').getAttribute('data-bubble', { timeout: 100 }).catch(() => null)) ?? '';
const btn = p.locator(`[data-card="mine"] .db-sym[data-art="${art}"]`);
if (tag(p) === 'pc') {
await btn.focus();
await p.keyboard.press('Enter');
} else await btn.tap();
// My own judgement is a new bubble (another one may still be showing).
const bubble = await p
.locator(`.db-bubble:not([data-bubble="${old}"])`)
.first()
.innerText({ timeout: 1500 })
.then((t) => t.trim())
.catch(() => '');
if (!wrong) await p.waitForFunction((k) => document.querySelector('[data-card="center"]')?.getAttribute('data-key') !== k, key, { timeout: 1500 }).catch(() => {});
return bubble;
}
const PLANS: Record<string, Plan> = { const PLANS: Record<string, Plan> = {
rummikub: { rummikub: {
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
@@ -1606,6 +1636,67 @@ const PLANS: Record<string, Plan> = {
console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`); console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`);
}, },
}, },
dobble: {
// PC host + phone guest, 탑 쌓기 with the 55-card deck and a 1-second lock. Both players find the shared picture of their own card and the centre
// card on their own screen (PC: keyboard focus + Enter, phone: tap), one deliberate wrong tap (shake + lock), until the draw pile runs out.
lobby: async (host) => {
await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
await host.getByRole('group', { name: '카드 장수' }).getByRole('button', { name: '55장' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^탑 쌓기 · 55장 · 틀리면 1초 잠금/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
await host.locator('[data-card="mine"] .db-sym:not([disabled])').first().waitFor({ timeout: 10_000 });
const st = { ok: 0, wrong: 0, late: 0, other: 0 };
let n = 0;
let shots = 0;
const until = Date.now() + 180_000;
while (Date.now() < until && !(await over())) {
const p = pages[n++ % pages.length]!;
const wantWrong = st.wrong === 0 && st.ok >= 3 && p === guest;
const text = await dobbleTap(p, wantWrong);
if (text === null) {
await host.waitForTimeout(150);
continue;
}
if (text.startsWith('찾았어요')) st.ok++;
else if (text.includes('틀렸어요')) {
st.wrong++;
await guest.screenshot({ path: join(SHOTS, 'dobble-wrong-phone.png') });
if (!(await guest.locator('.db-lock').count())) throw new Error('no lock after a wrong tap');
} else if (text.includes('늦었어요')) st.late++;
else {
st.other++;
if (st.other <= 3) console.log(` dobble: other judgement ${JSON.stringify(text)}`);
}
// Human pace: a tap right after the cards changed counts as aimed at the old card (the screen drops it as late).
await host.waitForTimeout(300);
if (shots === 0 && st.ok >= 6) {
shots = 1;
await host.waitForTimeout(500);
await host.screenshot({ path: join(SHOTS, 'dobble-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'dobble-mid-phone.png') });
await guest.screenshot({ path: join(SHOTS, 'dobble-mid-phone-full.png'), fullPage: true });
await host.emulateMedia({ colorScheme: 'dark' });
await guest.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(400);
await host.screenshot({ path: join(SHOTS, 'dobble-mid-pc-dark.png') });
await guest.screenshot({ path: join(SHOTS, 'dobble-mid-phone-dark.png') });
await host.emulateMedia({ colorScheme: 'light' });
await guest.emulateMedia({ colorScheme: 'light' });
}
}
if (!(await over())) throw new Error(`game did not finish ${JSON.stringify(st)}`);
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
await host.waitForTimeout(600);
await host.screenshot({ path: join(SHOTS, 'dobble-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'dobble-result-phone.png') });
if (st.ok < 20 || st.wrong < 1) throw new Error(`too few real finds / wrong taps: ${JSON.stringify(st)}`);
console.log(` dobble: finished after ${st.ok} correct taps, ${st.wrong} wrong, ${st.late} late, ${st.other} other`);
},
},
}; };
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);

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 { lasVegas } from './las-vegas';
import { cockroachPoker } from './cockroach-poker'; import { cockroachPoker } from './cockroach-poker';
import { ninetyNine } from './ninety-nine'; import { ninetyNine } from './ninety-nine';
import { dobble } from './dobble';
export const GAMES: Record<string, AnyGameDefinition> = { export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok, [omok.id]: omok,
@@ -47,4 +48,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[lasVegas.id]: lasVegas, [lasVegas.id]: lasVegas,
[cockroachPoker.id]: cockroachPoker, [cockroachPoker.id]: cockroachPoker,
[ninetyNine.id]: ninetyNine, [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: '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: '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: '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); export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);