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:
63
packages/games/src/dobble/deck.ts
Normal file
63
packages/games/src/dobble/deck.ts
Normal 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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
849
packages/games/src/dobble/dobble.test.ts
Normal file
849
packages/games/src/dobble/dobble.test.ts
Normal 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]!));
|
||||
});
|
||||
856
packages/games/src/dobble/index.ts
Normal file
856
packages/games/src/dobble/index.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
272
packages/games/src/dobble/types.ts
Normal file
272
packages/games/src/dobble/types.ts
Normal 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;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { quoridor } from './quoridor';
|
||||
import { lasVegas } from './las-vegas';
|
||||
import { cockroachPoker } from './cockroach-poker';
|
||||
import { ninetyNine } from './ninety-nine';
|
||||
import { dobble } from './dobble';
|
||||
|
||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[omok.id]: omok,
|
||||
@@ -47,4 +48,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[lasVegas.id]: lasVegas,
|
||||
[cockroachPoker.id]: cockroachPoker,
|
||||
[ninetyNine.id]: ninetyNine,
|
||||
[dobble.id]: dobble,
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ export const CATALOG: CatalogEntry[] = [
|
||||
{ id: 'las-vegas', nameKo: '라스베가스', minPlayers: 2, maxPlayers: 5, minutes: '20~30', level: '쉬움', blurb: '주사위를 같은 숫자 카지노에 몽땅! 개수가 같으면 둘 다 꽝 · 놀이용 지폐', available: true },
|
||||
{ id: 'cockroach-poker', nameKo: '바퀴벌레 포커', minPlayers: 2, maxPlayers: 6, minutes: '15~20', level: '쉬움', blurb: '벌레 카드를 건네며 "이건 쥐야!" 진짜일까 거짓말일까?', available: true },
|
||||
{ id: 'ninety-nine', nameKo: '구십구', minPlayers: 2, maxPlayers: 10, minutes: '5~15', level: '쉬움', blurb: '카드를 내며 숫자를 더해요. 99를 넘기면 탈락!', available: true },
|
||||
{ id: 'dobble', nameKo: '도블', minPlayers: 2, maxPlayers: 8, minutes: '3~10', level: '쉬움', blurb: '어느 두 카드에나 딱 하나 있는 같은 그림을 누구보다 빨리 콕!', available: true },
|
||||
];
|
||||
|
||||
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);
|
||||
|
||||
Reference in New Issue
Block a user