Merge branch 'm5/halli-galli' into codex/owner/grp_discord_joke-app

# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
This commit is contained in:
EJClaw
2026-10-05 14:07:23 +09:00
13 changed files with 2722 additions and 0 deletions

View File

@@ -57,6 +57,11 @@ export interface GameDefinition<S, A, V, O> {
deadline?(state: S): number | null;
/** Players the deadline applies to (default: activePlayers). */
timeoutPlayers?(state: S): PlayerId[];
/**
* Earliest time any timeout may fire, even for away players (whose deadline the room otherwise
* pulls forward to the end of the grace period). Real-time games use it to keep a minimum pace.
*/
timeoutNotBefore?(state: S): number | null;
onTimeout(state: S, player: PlayerId): A;
onLeave?(state: S, player: PlayerId): A | null;
result(state: S): GameResult | null;

View File

@@ -0,0 +1,707 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, checkNoLeak, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import {
AWAY_FLIP_MS,
CARDS,
COUNTDOWN_MS,
DECK_SIZE,
FRUITS,
RING_WINDOW_MS,
allCardIds,
cardOf,
fiveFruit,
halliGalli as hg,
type Fruit,
type HalliGalliAction,
type HalliGalliOptions,
type HalliGalliState,
} from './index';
// ---------------------------------------------------------------- helpers
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
const T0 = 1_000_000;
const START = T0 + COUNTDOWN_MS;
const FRUIT_OF: Record<string, Fruit> = { S: 'strawberry', B: 'banana', L: 'lime', P: 'plum' };
function make(opts: Partial<HalliGalliOptions> = {}, players = ['A', 'B', 'C'], seed = 1): HalliGalliState {
const options = hg.optionsSchema.parse(opts);
return hg.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: T0 });
}
function applyE(s: HalliGalliState, p: string, a: HalliGalliAction, now: number): { state: HalliGalliState; events: GameEvent[] } {
const v = hg.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return hg.apply(s, p, a, { rng: rng(), now });
}
const act = (s: HalliGalliState, p: string, a: HalliGalliAction, now: number) => applyE(s, p, a, now).state;
const ringA = (s: HalliGalliState): HalliGalliAction => ({ type: 'ring', version: s.tableVersion });
type Spec = string | number; // "S2" = strawberry ×2, or a count of filler cards
/**
* Seats in the given order, playing phase, `turn` first player. Decks and piles are listed bottom → top.
* Every card not placed goes to `removed`, so the 56-card invariant still holds.
*/
function rig(
players: string[],
r: { decks?: Record<string, Spec[]>; faceUp?: Record<string, Spec[]>; pot?: Spec[]; turn?: string; now?: number },
opts: Partial<HalliGalliOptions> = {},
): HalliGalliState {
const s = make(opts, players);
const now = r.now ?? START;
const used = new Set<number>();
const take = (spec: string): number => {
const fruit = FRUIT_OF[spec[0]!]!;
const count = Number(spec.slice(1));
const c = CARDS.find((x) => !used.has(x.id) && x.fruit === fruit && x.count === count);
if (!c) throw new Error(`no card left for ${spec}`);
used.add(c.id);
return c.id;
};
const named = (list: Spec[] = []) => list.filter((x): x is string => typeof x === 'string').map(take);
// Named cards first so fillers never steal them.
const decks: Record<string, number[]> = {};
const piles: Record<string, number[]> = {};
const pre = new Map<Spec[], number[]>();
for (const list of [...Object.values(r.decks ?? {}), ...Object.values(r.faceUp ?? {}), r.pot ?? []]) pre.set(list, named(list));
// Fillers: plums ×1..2 never form a five with the rigged cards unless intended (tests keep piles explicit).
const filler = (): number => {
const c = CARDS.find((x) => !used.has(x.id));
if (!c) throw new Error('out of cards');
used.add(c.id);
return c.id;
};
const build = (list: Spec[] = []): number[] => {
const ids = [...(pre.get(list) ?? [])];
const out: number[] = [];
for (const x of list) {
if (typeof x === 'string') out.push(ids.shift()!);
else for (let i = 0; i < x; i++) out.push(filler());
}
return out;
};
for (const p of players) {
decks[p] = build(r.decks?.[p]);
piles[p] = build(r.faceUp?.[p]);
}
s.order = [...players];
s.active = [...players];
s.decks = decks;
s.faceUp = piles;
s.pot = build(r.pot);
s.removed = CARDS.map((c) => c.id).filter((id) => !used.has(id));
s.phase = 'playing';
s.turn = r.turn ?? players[0]!;
s.turnStartedAt = now;
s.turnDeadline = now + s.options.flipSeconds * 1000 * (s.options.kidMode ? 8 / s.options.flipSeconds : 1);
s.flipAllowedAt = now;
s.pendingFlip = null;
s.tableVersion = 5;
s.endsAt = s.options.timeLimitMin === null ? null : now + s.options.timeLimitMin * 60_000;
return s;
}
const ids = (s: HalliGalliState, p: string) => s.decks[p]!;
const top = (s: HalliGalliState, p: string) => {
const pile = s.faceUp[p]!;
const c = pile.length ? cardOf(pile[pile.length - 1]!) : null;
return c ? `${c.fruit[0]!.toUpperCase()}${c.count}` : null;
};
function conserved(s: HalliGalliState): void {
const all = allCardIds(s);
if (all.length !== DECK_SIZE) throw new Error(`card count ${all.length}`);
if (new Set(all).size !== DECK_SIZE) throw new Error('duplicate card');
}
// ---------------------------------------------------------------- §3, §4
describe('덱과 분배 (§3, §4)', () => {
test('56장: 과일별 14장, 개수별 5/3/3/2/1장, 과일 개수 총합 33', () => {
expect(CARDS).toHaveLength(56);
expect(new Set(CARDS.map((c) => c.id)).size).toBe(56);
for (const f of FRUITS) {
const mine = CARDS.filter((c) => c.fruit === f);
expect(mine).toHaveLength(14);
expect([1, 2, 3, 4, 5].map((n) => mine.filter((c) => c.count === n).length)).toEqual([5, 3, 3, 2, 1]);
expect(mine.reduce((a, c) => a + c.count, 0)).toBe(33);
}
});
test.each([
[2, 28, 0],
[3, 18, 2],
[4, 14, 0],
[5, 11, 1],
[6, 9, 2],
])('%i명: 1인당 %i장, 남는 카드 %i장 (pot / remove)', (n, per, rest) => {
const players = Array.from({ length: n }, (_, i) => `P${i}`);
const s = make({}, players);
for (const p of players) expect(s.decks[p]).toHaveLength(per);
expect(s.pot).toHaveLength(rest);
expect(s.removed).toHaveLength(0);
conserved(s);
const r = make({ leftoverCards: 'remove' }, players);
expect(r.pot).toHaveLength(0);
expect(r.removed).toHaveLength(rest);
conserved(r);
});
test('좌석·첫 플레이어·셔플은 시드로 결정되고 3초 카운트다운 후 시작', () => {
const a = make({}, ['A', 'B', 'C', 'D'], 3);
const b = make({}, ['A', 'B', 'C', 'D'], 3);
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
const orders = new Set(Array.from({ length: 20 }, (_, i) => make({}, ['A', 'B', 'C', 'D'], i).order.join('')));
expect(orders.size).toBeGreaterThan(5);
expect(a.phase).toBe('countdown');
expect(hg.validate(a, a.turn, { type: 'flip' }).ok).toBe(false);
expect(hg.validate(a, a.turn, { type: 'ring', version: 0 }).ok).toBe(false);
expect(hg.deadline!(a)).toBe(START);
const early = act(a, a.turn, { type: 'tick' }, START - 1);
expect(early.phase).toBe('countdown');
const s = act(a, a.turn, { type: 'tick' }, START);
expect(s.phase).toBe('playing');
expect(s.turnDeadline).toBe(START + 3000);
expect(s.endsAt).toBe(START + 10 * 60_000);
});
});
// ---------------------------------------------------------------- §5.2 judgment
describe('종 판정 (§5.2)', () => {
const table = (piles: Record<string, Spec[]>, players = ['A', 'B', 'C']) => rig(players, { decks: { A: [3], B: [3], C: [3] }, faceUp: piles });
test('딸기 2+3 → 정답, 딸기 2+3+1 → 오답(6개)', () => {
expect(fiveFruit(table({ A: ['S2'], B: ['S3'] }))).toBe('strawberry');
expect(fiveFruit(table({ A: ['S2'], B: ['S3'], C: ['S1'] }))).toBeNull();
expect(fiveFruit(table({ A: ['S4'] }))).toBeNull();
});
test('다른 과일이 섞여도 한 과일이 정확히 5면 정답', () => {
expect(fiveFruit(table({ A: ['S2'], B: ['S3'], C: ['B4'] }))).toBe('strawberry');
expect(fiveFruit(table({ A: ['L1'], B: ['P5'], C: ['L3'] }))).toBe('plum');
expect(fiveFruit(table({ A: ['B5'] }))).toBe('banana');
});
test('맨 위 카드만 센다(아래 카드 무시)', () => {
expect(fiveFruit(table({ A: ['S5', 'B1'], B: ['L2'] }))).toBeNull();
expect(fiveFruit(table({ A: ['S5', 'B1'], B: ['B4'] }))).toBe('banana');
});
test('탈락자의 공개 더미 맨 위도 합산', () => {
const s = table({ A: ['L2'], C: ['L3'] });
s.active = ['A', 'B'];
s.eliminated = ['C'];
s.outReason = { C: 'empty' };
expect(fiveFruit(s)).toBe('lime');
const out = act(s, 'B', ringA(s), START + 100);
expect(out.decks.B).toHaveLength(3 + 2);
expect(out.faceUp.C).toEqual([]);
});
});
// ---------------------------------------------------------------- collecting
describe('정답 수거 (§5.2)', () => {
test('좌석 순으로 각 공개 더미를 아래부터, 종 밑 카드는 마지막 → 덱 맨 아래, 정답자가 다음 차례', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [2], B: [2], C: [2] }, faceUp: { A: ['P1', 'B3'], B: ['S2'], C: ['L1', 'L4', 'B2'] }, pot: ['P2', 'P3'], turn: 'C' });
const before = { A: [...s.faceUp.A!], B: [...s.faceUp.B!], C: [...s.faceUp.C!], pot: [...s.pot], deck: [...s.decks.B!] };
const { state: n, events } = applyE(s, 'B', ringA(s), START + 500);
expect(n.decks.B).toEqual([...before.A, ...before.B, ...before.C, ...before.pot, ...before.deck]);
expect(n.pot).toEqual([]);
for (const p of ['A', 'B', 'C']) expect(n.faceUp[p]).toEqual([]);
expect(n.turn).toBe('B');
expect(n.turnStartedAt).toBe(START + 500);
expect(n.flipAllowedAt).toBe(START + 500 + 600);
expect(n.tableVersion).toBe(s.tableVersion + 1);
expect(n.correctCount.B).toBe(1);
expect(n.lastBell).toMatchObject({ player: 'B', correct: true, fruit: 'banana', cards: 8 });
expect(events).toContainEqual({ type: 'ring', player: 'B', correct: true, fruit: 'banana', cards: 8 });
conserved(n);
});
test('동시 종: 같은 버전 정답 종 2개 → 먼저 도착한 사람만 수거, 두 번째는 "늦었어요" 무벌칙', () => {
// §5.5: A 바나나 3, B 딸기 2, C 바나나 2 → 바나나 5.
const s = rig(['A', 'B', 'C'], { decks: { A: [5], B: [5], C: [5] }, faceUp: { A: ['B3'], B: ['S2'], C: ['B2'] }, pot: [2], turn: 'A' });
const v = s.tableVersion;
const afterB = act(s, 'B', { type: 'ring', version: v }, START + 1000);
expect(afterB.decks.B).toHaveLength(5 + 3 + 2);
const late = hg.validate(afterB, 'A', { type: 'ring', version: v });
expect(late).toEqual({ ok: false, reason: '늦었어요! 다른 사람이 먼저 종을 쳤어요.' });
expect(afterB.decks.A).toHaveLength(5);
expect(afterB.wrongCount.A).toBe(0);
// A ring for a table that changed by a flip instead.
const flipped = act(afterB, 'B', { type: 'flip' }, START + 1000 + 600);
expect(hg.validate(flipped, 'A', { type: 'ring', version: v + 1 })).toEqual({ ok: false, reason: '늦었어요! 그 사이에 카드가 바뀌었어요.' });
});
test('§5.5 예시: 이후 A가 바나나 4 상태에서 착각해 종 → 오답 → B와 C에게 1장씩', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [6], B: [6], C: [6] }, faceUp: { A: ['B4'] }, turn: 'B' });
const n = act(s, 'A', ringA(s), START + 100);
expect(n.decks.A).toHaveLength(4);
expect(n.decks.B).toHaveLength(7);
expect(n.decks.C).toHaveLength(7);
expect(n.turn).toBe('B'); // turn unchanged
expect(n.tableVersion).toBe(s.tableVersion); // table unchanged
// the given cards go to the bottom of the receivers' decks (from the top of the giver's deck)
expect(n.decks.B![0]).toBe(s.decks.A![5]!);
expect(n.decks.C![0]).toBe(s.decks.A![4]!);
conserved(n);
});
});
// ---------------------------------------------------------------- wrong bells and elimination
describe('오답 벌칙과 탈락 (§5.2, §5.3)', () => {
test('4인에서 덱 2장인 사람이 오답 → 시계 방향 2명에게만 1장씩 주고 즉시 탈락', () => {
const s = rig(['A', 'B', 'C', 'D'], { decks: { A: [5], B: [2], C: [5], D: [5] }, faceUp: { A: ['S1'] }, turn: 'A' });
const n = act(s, 'B', ringA(s), START + 100);
expect(n.decks.B).toEqual([]);
expect(n.decks.C).toHaveLength(6);
expect(n.decks.D).toHaveLength(6);
expect(n.decks.A).toHaveLength(5);
expect(n.active).toEqual(['A', 'C', 'D']);
expect(n.eliminated).toEqual(['B']);
expect(n.outReason.B).toBe('penalty');
conserved(n);
});
test('정확히 다 줘서 0장 → 차례가 올 때까지 참가, 차례가 오면 탈락', () => {
const s = rig(['A', 'B', 'C', 'D'], { decks: { A: [5], B: [3], C: [5], D: [5] }, faceUp: { A: ['S1'] }, turn: 'A' });
let n = act(s, 'B', ringA(s), START + 100);
expect(n.decks.B).toEqual([]);
expect(n.active).toContain('B');
n = act(n, 'A', { type: 'flip' }, START + 1000); // A → B's turn with an empty deck
expect(n.active).not.toContain('B');
expect(n.outReason.B).toBe('empty');
expect(n.turn).toBe('C');
});
test('덱 0장이어도 차례 전에 정답 종 → 카드를 얻고 계속 참가', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [4], B: [], C: [4] }, faceUp: { A: ['L2'], B: ['L3'] }, turn: 'A' });
expect(hg.legalActions!(s, 'B').some((a) => a.type === 'ring')).toBe(true);
const n = act(s, 'B', ringA(s), START + 100);
expect(n.decks.B).toHaveLength(2);
expect(n.turn).toBe('B');
expect(n.active).toContain('B');
});
test('덱 0장인 사람의 오답 → 줄 카드가 없어 즉시 탈락', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [4], B: [], C: [4] }, faceUp: { A: ['L2'] }, turn: 'A' });
const n = act(s, 'B', ringA(s), START + 100);
expect(n.active).toEqual(['A', 'C']);
expect(n.outReason.B).toBe('penalty');
});
test('차례인 사람이 오답으로 덱이 0장이 되면 탈락하고 다음 사람 차례', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [2], B: [4], C: [4] }, faceUp: { B: ['L2'] }, turn: 'A' });
const n = act(s, 'A', ringA(s), START + 100);
expect(n.active).toEqual(['B', 'C']);
expect(n.turn).toBe('B');
});
test('한 명만 남으면 lastStanding 종료: 순위는 탈락 역순, 점수 3·2·1', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [1], B: [5], C: [] }, faceUp: { A: ['S1'] }, turn: 'A' });
let n = act(s, 'A', { type: 'flip' }, START + 100); // A: 0 cards, B's turn
expect(n.turn).toBe('B');
n = act(n, 'C', ringA(n), START + 200); // C wrong with 0 cards → out
n = act(n, 'A', ringA(n), START + 300); // A wrong with 0 cards → out → B wins
expect(n.phase).toBe('ended');
const r = hg.result(n)!;
expect(r.ranking).toEqual([['B'], ['A'], ['C']]);
expect(r.scores).toEqual({ B: 3, A: 2, C: 1 });
expect(n.final!.endReason).toBe('lastStanding');
expect(r.summary).toContain('이겼어요');
});
test('틀린 종 잠금: 잠금 중 종은 무시(벌칙 없음), 잠금이 끝나면 다시 칠 수 있어요', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [6], B: [6], C: [6] }, faceUp: { A: ['S1'] }, turn: 'A' });
let n = act(s, 'B', ringA(s), START + 100);
expect(n.lockedUntil.B).toBe(START + 1100);
const { state: locked, events } = applyE(n, 'B', ringA(n), START + 600);
expect(events).toEqual([{ type: 'locked', player: 'B' }]);
expect(locked.decks.B).toHaveLength(4);
n = act(locked, 'B', ringA(locked), START + 1100);
expect(n.decks.B).toHaveLength(2);
});
});
// ---------------------------------------------------------------- flipping and timers
describe('뒤집기와 시간 (§5.1, §8.5)', () => {
test('flipGuardMs: 가드 중 뒤집기는 예약 → 가드 끝에 실행, 테이블 버전은 1번만 증가', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [3], B: [3], C: [3] }, turn: 'A' });
let n = act(s, 'A', { type: 'flip' }, START + 100);
expect(n.tableVersion).toBe(s.tableVersion + 1);
expect(n.turn).toBe('B');
expect(n.flipAllowedAt).toBe(START + 700);
const { state: q, events } = applyE(n, 'B', { type: 'flip' }, START + 300);
expect(events).toEqual([{ type: 'flipQueued', player: 'B' }]);
expect(q.tableVersion).toBe(n.tableVersion);
expect(q.pendingFlip).toBe('B');
expect(hg.validate(q, 'B', { type: 'flip' }).ok).toBe(false); // no double queue
expect(hg.deadline!(q)).toBe(START + 700);
// A bell for the table everyone still sees is judged on that table.
expect(hg.validate(q, 'C', ringA(q)).ok).toBe(true);
n = act(q, 'B', hg.onTimeout(q, 'B'), START + 700);
expect(n.tableVersion).toBe(q.tableVersion + 1);
expect(n.faceUp.B).toHaveLength(1);
expect(n.turn).toBe('C');
expect(n.pendingFlip).toBeNull();
});
test('뒤집기 시간 초과 → 서버가 자동으로 뒤집기 (kidMode는 8초)', () => {
const s = rig(['A', 'B'], { decks: { A: [3], B: [3] }, turn: 'A' });
expect(hg.deadline!(s)).toBe(START + 3000);
expect(hg.timeoutPlayers!(s)).toEqual(['A']);
expect(hg.onTimeout(s, 'A')).toEqual({ type: 'tick' });
const n = act(s, 'A', { type: 'tick' }, START + 3000);
expect(n.faceUp.A).toHaveLength(1);
expect(n.turn).toBe('B');
expect(hg.deadline!(n)).toBe(START + 6000);
const kid = make({ kidMode: true });
const k = act(kid, kid.turn, { type: 'tick' }, START);
expect(k.turnDeadline).toBe(START + 8000);
});
test('자리 비움(연결 끊김): timeoutNotBefore로 가드·1초는 지키며 계속 자동 진행', () => {
const s = rig(['A', 'B'], { decks: { A: [3], B: [3] }, turn: 'A' });
expect(hg.timeoutNotBefore!(s)).toBe(START + AWAY_FLIP_MS);
// Room decided A is away and fires at the not-before time: A's card is flipped.
let n = act(s, 'A', { type: 'tick' }, START + AWAY_FLIP_MS);
expect(n.faceUp.A).toHaveLength(1);
expect(hg.timeoutNotBefore!(n)).toBe(START + AWAY_FLIP_MS + AWAY_FLIP_MS);
n = act(n, 'B', { type: 'tick' }, START + 2 * AWAY_FLIP_MS);
expect(n.faceUp.B).toHaveLength(1);
// A queued flip only waits for the guard.
const q = act(n, 'A', { type: 'flip' }, START + 2 * AWAY_FLIP_MS + 10);
expect(hg.timeoutNotBefore!(q)).toBe(q.flipAllowedAt);
});
test('autoFlip: flip 액션은 거부, 서버가 flipSeconds 간격으로 뒤집기', () => {
const s = rig(['A', 'B'], { decks: { A: [3], B: [3] }, turn: 'A' }, { autoFlip: true, flipSeconds: 2 });
expect(hg.validate(s, 'A', { type: 'flip' })).toEqual({ ok: false, reason: '이 방은 카드가 자동으로 뒤집혀요.' });
expect(hg.legalActions!(s, 'A').some((a) => a.type === 'flip')).toBe(false);
const n = act(s, 'A', { type: 'tick' }, START + 2000);
expect(n.faceUp.A).toHaveLength(1);
expect(n.turnDeadline).toBe(START + 4000);
});
test('시간 제한 만료: 보유 카드(덱 + 자기 공개 더미) 순, 동점은 정답 횟수, 그래도 같으면 공동', () => {
const s = rig(['A', 'B', 'C', 'D'], { decks: { A: [3], B: [5], C: [4], D: [4] }, faceUp: { A: ['S1', 'S1'], C: ['P1'], D: ['P1'] }, turn: 'A' }, { timeLimitMin: 5 });
s.correctCount = { A: 0, B: 0, C: 2, D: 2 };
// A 5 (3 + 2), B 5, C 5, D 5 → A,B (0 correct) below C,D (2 correct)
expect(hg.deadline!(s)).toBe(Math.min(s.endsAt!, s.turnDeadline));
const n = act(s, 'B', ringA(s), s.endsAt!); // any action at/after the end time ends the game first
expect(n.phase).toBe('ended');
expect(n.final!.endReason).toBe('timeLimit');
expect(n.decks.B).toHaveLength(5); // the bell was not judged
const r = hg.result(n)!;
expect(r.ranking).toEqual([['C', 'D'], ['A', 'B']]);
expect(n.final!.ranking.map((x) => [x.id, x.rank, x.cards])).toEqual([
['C', 1, 5],
['D', 1, 5],
['A', 3, 5],
['B', 3, 5],
]);
expect(r.summary).toContain('시간이 다 됐어요');
});
});
// ---------------------------------------------------------------- endings and house rules
describe('official 종료와 하우스 룰 (§6, §7)', () => {
test('official 3인 → 2인 남은 뒤 첫 정답 종에서 종료', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [1, 'L1'], B: [], C: [2, 'S2'] }, faceUp: { A: ['S1'], B: ['S3'] }, turn: 'A' }, { endMode: 'official' });
let n = act(s, 'A', { type: 'flip' }, START + 100); // A: L1 → B's turn with 0 cards → out → 2 left
expect(n.active).toEqual(['A', 'C']);
expect(n.officialDuel).toBe(true);
expect(n.turn).toBe('C');
n = act(n, 'C', { type: 'flip' }, START + 800); // C: S2 + B's S3 = strawberry 5
expect(n.phase).toBe('playing');
expect(fiveFruit(n)).toBe('strawberry');
const end = act(n, 'A', ringA(n), START + 900);
expect(end.phase).toBe('ended');
expect(end.final!.endReason).toBe('official');
expect(end.final!.ranking.map((r) => [r.id, r.rank, r.cards])).toEqual([
['A', 1, 5],
['C', 2, 2],
['B', 3, 0],
]);
expect(hg.result(end)!.scores).toEqual({ A: 3, C: 2, B: 1 });
});
test('official 2인 상태 오답 → 상대가 펼쳐진 카드를 모두 가져가고 종료', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [4], B: [4], C: [] }, faceUp: { A: ['L1'], C: ['P2'] }, pot: [1], turn: 'A' }, { endMode: 'official' });
s.active = ['A', 'B'];
s.eliminated = ['C'];
s.outReason = { C: 'empty' };
s.officialDuel = true;
const n = act(s, 'A', ringA(s), START + 100);
expect(n.phase).toBe('ended');
expect(n.final!.endReason).toBe('official');
expect(n.decks.B).toHaveLength(4 + 3);
expect(n.faceUp.A).toEqual([]);
expect(hg.result(n)!.ranking).toEqual([['B'], ['A'], ['C']]);
});
test('처음부터 2인이면 official도 lastStanding처럼 진행(오답으로 끝나지 않음)', () => {
const s = rig(['A', 'B'], { decks: { A: [4], B: [4] }, faceUp: { A: ['L1'] }, turn: 'A' }, { endMode: 'official' });
const n = act(s, 'A', ringA(s), START + 100);
expect(n.phase).toBe('playing');
expect(n.officialDuel).toBe(false);
expect(n.decks.B).toHaveLength(5);
});
test('duelWrongLoses: 1대1 오답은 즉시 패배', () => {
const s = rig(['A', 'B'], { decks: { A: [4], B: [4] }, faceUp: { A: ['L1'] }, turn: 'A' }, { duelWrongLoses: true });
const n = act(s, 'B', ringA(s), START + 100);
expect(n.phase).toBe('ended');
expect(hg.result(n)!.ranking).toEqual([['A'], ['B']]);
expect(n.outReason.B).toBe('duel');
conserved(n);
});
test('strikeOut: 오답 3번이면 탈락(남은 덱은 종 밑으로)', () => {
let s = rig(['A', 'B', 'C'], { decks: { A: [10], B: [10], C: [10] }, faceUp: { A: ['L1'] }, turn: 'A' }, { strikeOut: true, wrongRingLockoutMs: 0 });
s = act(s, 'B', ringA(s), START + 1);
s = act(s, 'B', ringA(s), START + 2);
expect(s.active).toContain('B');
s = act(s, 'B', ringA(s), START + 3);
expect(s.active).not.toContain('B');
expect(s.outReason.B).toBe('strikeOut');
expect(s.pot).toHaveLength(4);
conserved(s);
});
test('penaltyEscalation: 두 번째 오답은 1인당 2장', () => {
let s = rig(['A', 'B', 'C'], { decks: { A: [10], B: [10], C: [10] }, faceUp: { A: ['L1'] }, turn: 'A' }, { penaltyEscalation: true, wrongRingLockoutMs: 0 });
s = act(s, 'B', ringA(s), START + 1);
expect(s.decks.B).toHaveLength(8);
s = act(s, 'B', ringA(s), START + 2);
expect(s.decks.B).toHaveLength(4);
expect(s.decks.C).toHaveLength(13);
expect(s.decks.A).toHaveLength(13);
});
test('wrongToPot: 벌칙 카드는 종 밑으로, 다음 정답자가 가져감', () => {
let s = rig(['A', 'B', 'C'], { decks: { A: [5], B: [5], C: [5] }, faceUp: { A: ['L1'] }, turn: 'A' }, { wrongToPot: true });
s = act(s, 'B', ringA(s), START + 1);
expect(s.pot).toHaveLength(2);
expect(s.decks.A).toHaveLength(5);
s.faceUp.C = [CARDS.find((c) => c.fruit === 'lime' && c.count === 4 && s.removed.includes(c.id))!.id];
s.removed = s.removed.filter((id) => id !== s.faceUp.C![0]);
s = act(s, 'C', ringA(s), START + 2);
expect(s.decks.C).toHaveLength(5 + 2 + 2);
});
test('latencyComp light: 50ms 창 안의 정답 종을 RTT/2(최대 80ms) 보정 시각으로 비교', () => {
let s = rig(['A', 'B', 'C'], { decks: { A: [3], B: [3], C: [3] }, faceUp: { A: ['B5'] }, turn: 'A' }, { latencyComp: 'light' });
s = act(s, 'B', { type: 'ring', version: s.tableVersion, rtt: 20 }, START + 100); // adj 90
expect(s.ringWindow).not.toBeNull();
expect(s.tableVersion).toBe(5);
expect(hg.deadline!(s)).toBe(START + 100 + RING_WINDOW_MS);
expect(hg.validate(s, 'B', ringA(s)).ok).toBe(false); // already rang
// A flip during the window is queued, not applied.
s = act(s, 'A', { type: 'flip' }, START + 110);
expect(s.pendingFlip).toBe('A');
s = act(s, 'C', { type: 'ring', version: s.tableVersion, rtt: 400 }, START + 130); // adj 130 - 80 = 50 → wins
const { state: n, events } = applyE(s, 'A', { type: 'tick' }, START + 150);
expect(n.ringWindow).toBeNull();
expect(n.decks.C).toHaveLength(4);
expect(n.turn).toBe('C');
expect(n.pendingFlip).toBeNull();
expect(events).toContainEqual({ type: 'late', player: 'B' });
});
});
// ---------------------------------------------------------------- leaving, views, determinism
describe('나가기, 비공개 정보, 결정성 (§8.3, §10)', () => {
test('나가면 덱은 종 밑으로, 공개 더미는 테이블에 남고 차례가 넘어가요', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [3], B: [4], C: [3] }, faceUp: { A: ['S2'] }, turn: 'A' });
expect(hg.onLeave!(s, 'A')).toEqual({ type: 'leave' });
const n = act(s, 'A', { type: 'leave' }, START + 10);
expect(n.pot).toHaveLength(3);
expect(n.faceUp.A).toHaveLength(1);
expect(n.outReason.A).toBe('left');
expect(n.turn).toBe('B');
expect(hg.onLeave!(n, 'A')).toBeNull();
const end = act(n, 'B', { type: 'leave' }, START + 20);
expect(end.phase).toBe('ended');
expect(hg.result(end)!.ranking).toEqual([['C'], ['B'], ['A']]);
expect(hg.result(end)!.summary).toBe('다른 사람이 모두 나가서 남은 사람이 이겼어요');
});
test('view에는 덱 내용(본인 포함)·종 밑·가려진 카드가 없다: 숨긴 카드를 섞어도 view가 같다', () => {
const r = new SeededRng([1, 2, 3, 4]);
for (let seed = 1; seed <= 30; seed++) {
let s = make({}, ['A', 'B', 'C', 'D', 'E'], seed);
s = act(s, s.turn, { type: 'tick' }, START);
for (let i = 0; i < 12 && s.phase === 'playing'; i++) s = act(s, s.turn, { type: 'flip' }, START + 1000 * (i + 1));
const shuffled = structuredClone(s);
for (const p of s.order) {
shuffled.decks[p] = r.shuffle(s.decks[p]!);
const pile = s.faceUp[p]!;
shuffled.faceUp[p] = [...r.shuffle(pile.slice(0, -1)), ...pile.slice(-1)];
}
shuffled.pot = r.shuffle(s.pot);
// Swap hidden cards between players too (same counts).
const a = shuffled.decks[s.order[0]!]!;
const b = shuffled.decks[s.order[1]!]!;
if (a.length && b.length) [a[0], b[0]] = [b[0]!, a[0]!];
for (const viewer of [null, ...s.order]) expect(JSON.stringify(hg.view(shuffled, viewer))).toBe(JSON.stringify(hg.view(s, viewer)));
for (const viewer of s.order) checkNoLeak(hg, s, viewer, s.order);
}
});
test('이벤트에는 공개 카드(방금 뒤집은 카드)만 담긴다', () => {
let s = make({}, ['A', 'B', 'C'], 5);
s = act(s, s.turn, { type: 'tick' }, START);
const p = s.turn;
const { state: n, events } = applyE(s, p, { type: 'flip' }, START + 100);
const c = cardOf(n.faceUp[p]!.at(-1)!);
expect(events).toEqual([{ type: 'flip', player: p, fruit: c.fruit, count: c.count, version: n.tableVersion }]);
});
test('같은 시드 + 액션 로그(serverTs 포함) 재생 → 같은 결과', () => {
const a = fastPlayout(['A', 'B', 'C', 'D'], hg.optionsSchema.parse({}), seedN(42), 5000);
const replay = (log: { p: PlayerId; a: HalliGalliAction; now: number }[]) => {
let s = hg.setup({ players: ['A', 'B', 'C', 'D'], options: hg.optionsSchema.parse({}), rng: SeededRng.fromSeed(seedN(42)), now: 1_700_000_000_000 });
for (const x of log) s = hg.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: HalliGalliAction; now: number }[];
final: HalliGalliState;
}
function invariants(s: HalliGalliState): void {
conserved(s);
const all = [...s.active, ...s.eliminated].sort();
if (JSON.stringify(all) !== JSON.stringify([...s.order].sort())) throw new Error('active + eliminated != players');
if (s.phase !== 'ended') {
if (!s.active.includes(s.turn)) throw new Error(`turn ${s.turn} is not active`);
if (s.phase === 'playing' && s.decks[s.turn]!.length === 0) throw new Error(`turn ${s.turn} has no cards`);
if (s.active.length < 2) throw new Error('game should have ended');
}
}
/**
* Same idea as `randomPlayout`, with time moving 0.1–1.6s per step and bells biased towards real fives
* (so collections, guards, lockouts and late bells all happen). Checks invariants and onTimeout legality every step.
*/
function fastPlayout(players: PlayerId[], options: HalliGalliOptions, seed: RngState, maxSteps: number): 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 = hg.setup({ players, options, rng: r, now });
const log: FastReport['log'] = [];
for (let step = 0; step < maxSteps; step++) {
invariants(s);
if (step % 25 === 0) for (const p of [players[step % players.length]!]) checkNoLeak(hg, s, p, players);
for (const p of hg.timeoutPlayers!(s)) {
const v = hg.validate(s, p, hg.onTimeout(s, p));
if (!v.ok) throw new Error(`onTimeout rejected for ${p}: ${v.reason}`);
}
if (hg.result(s)) return { steps: step, finished: true, log, final: s };
now += 100 + chooser.int(1500);
let actor: PlayerId;
let action: HalliGalliAction;
const five = s.phase === 'playing' && fiveFruit(s) !== null;
const roll = chooser.int(10);
if (five && roll < 7) {
actor = s.active[chooser.int(s.active.length)]!;
action = { type: 'ring', version: s.tableVersion, rtt: chooser.int(300) };
// A stale bell sometimes (rejected by validate, as the server would).
if (roll === 0 && s.tableVersion > 0) action = { type: 'ring', version: s.tableVersion - 1 };
} else if (roll < 8) {
actor = s.turn;
action = hg.onTimeout(s, actor);
} else {
const actives = hg.activePlayers(s);
actor = actives[chooser.int(actives.length)]!;
const legal = hg.legalActions!(s, actor);
action = legal.length > 0 ? legal[chooser.int(legal.length)]! : hg.onTimeout(s, actor);
}
if (chooser.int(400) === 0 && s.active.length > 2) {
actor = s.active[chooser.int(s.active.length)]!;
action = { type: 'leave' };
}
if (!hg.validate(s, actor, action).ok) {
if (action.type !== 'ring') throw new Error(`generated action rejected: ${JSON.stringify(action)} ${hg.validate(s, actor, action).ok}`);
continue;
}
const out = hg.apply(s, actor, action, { rng: r, now });
for (const e of out.events) if (JSON.stringify(e).includes('"hg')) throw new Error('event carries a card key');
s = out.state;
log.push({ p: actor, a: action, now });
}
return { steps: maxSteps, finished: false, log, final: s };
}
const PRESETS: Partial<HalliGalliOptions>[] = [
{},
{ timeLimitMin: null },
{ endMode: 'official', flipGuardMs: 1000, wrongRingLockoutMs: 2000 },
{ latencyComp: 'light', leftoverCards: 'remove', flipSeconds: 2 },
{ penaltyEscalation: true, strikeOut: true, timeLimitMin: 5 },
{ wrongToPot: true, autoFlip: true, flipGuardMs: 400, wrongRingLockoutMs: 0 },
{ duelWrongLoses: true, kidMode: true, timeLimitMin: 15 },
];
describe('random playouts (§10, docs/09 §7)', () => {
test('1,000 seeded games: finish, 56 cards conserved, no leaks, onTimeout always legal, deterministic', () => {
let finished = 0;
let collects = 0;
let wrongs = 0;
for (let i = 1; i <= 1000; i++) {
const n = 2 + (i % 5);
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = hg.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
const a = fastPlayout(players, options, seedN(i), 20_000);
if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish`);
finished++;
const res = hg.result(a.final)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
collects += Object.values(a.final.correctCount).reduce((x, y) => x + y, 0);
wrongs += Object.values(a.final.wrongCount).reduce((x, y) => x + y, 0);
if (i % 50 === 0) {
const b = fastPlayout(players, options, seedN(i), 20_000);
expect(JSON.stringify(b.log)).toBe(JSON.stringify(a.log));
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(finished).toBe(1000);
expect(collects).toBeGreaterThan(1000);
expect(wrongs).toBeGreaterThan(1000);
}, 60_000);
test.each([
[2, {}],
[3, { endMode: 'official' }],
[4, { latencyComp: 'light' }],
[6, { timeLimitMin: null, wrongToPot: 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 = hg.optionsSchema.parse(opts);
const conserve = (st: unknown) => invariants(st as HalliGalliState);
for (let i = 1; i <= 5; i++) {
const a = randomPlayout(hg, { players, options, seed: seedN(500 + i), maxSteps: 20_000, invariant: conserve, maxApplyMs: 20 });
expect(a.finished).toBe(true);
if (i === 1) {
const b = randomPlayout(hg, { players, options, seed: seedN(500 + i), maxSteps: 20_000, maxApplyMs: 20 });
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
}
}
});
});

View File

@@ -0,0 +1,609 @@
/** 할리갈리 (docs/games/halli-galli.md). Pure and deterministic; state is JSON; time only via ctx.now. */
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
import {
CARDS,
DECK_SIZE,
FRUITS,
cardOf,
halliGalliActionSchema,
halliGalliOptionsSchema,
type EndReason,
type Fruit,
type HalliGalliAction,
type HalliGalliFinal,
type HalliGalliOptions,
type HalliGalliState,
type HalliGalliView,
type LogEntry,
type OutReason,
type RankRow,
} from './types';
export * from './types';
type S = HalliGalliState;
type A = HalliGalliAction;
export const COUNTDOWN_MS = 3000;
/** `latencyComp: 'light'` bell window (§5.4). */
export const RING_WINDOW_MS = 50;
/** Max credit `latencyComp: 'light'` gives for half the RTT (§5.4). */
export const MAX_LATENCY_CREDIT_MS = 80;
/** Away players (room grace period over) flip after this much of their turn (§8.5 "1초로 줄여"). */
export const AWAY_FLIP_MS = 1000;
const LOG_KEEP = 30;
const POINTS = [3, 2, 1];
export const flipMs = (o: HalliGalliOptions): number => (o.kidMode ? 8 : o.flipSeconds) * 1000;
/** Public key of a visible card; secretsOf uses the same form so leak checks are meaningful. */
export const cardKey = (id: number): string => `hg${id}`;
// ---------------------------------------------------------------- table math
/** Sum of fruits over every face-up pile's top card (eliminated players' piles included, §5.2). */
export function fruitTotals(s: Pick<S, 'order' | 'faceUp'>): Record<Fruit, number> {
const t: Record<Fruit, number> = { strawberry: 0, banana: 0, lime: 0, plum: 0 };
for (const p of s.order) {
const pile = s.faceUp[p]!;
if (pile.length === 0) continue;
const c = cardOf(pile[pile.length - 1]!);
t[c.fruit] += c.count;
}
return t;
}
/** A fruit totalling exactly five, if any (6+ and 4- do not count). */
export function fiveFruit(s: Pick<S, 'order' | 'faceUp'>): Fruit | null {
const t = fruitTotals(s);
for (const f of FRUITS) if (t[f] === 5) return f;
return null;
}
export const cardsHeld = (s: S, p: PlayerId): number => (s.decks[p]?.length ?? 0) + (s.faceUp[p]?.length ?? 0);
/** All 56 cards, wherever they are (test invariant). */
export function allCardIds(s: S): number[] {
return [...s.order.flatMap((p) => [...s.decks[p]!, ...s.faceUp[p]!]), ...s.pot, ...s.removed];
}
// ---------------------------------------------------------------- helpers
function clone(s: S): S {
return {
...s,
order: [...s.order],
decks: Object.fromEntries(Object.entries(s.decks).map(([k, v]) => [k, [...v]])),
faceUp: Object.fromEntries(Object.entries(s.faceUp).map(([k, v]) => [k, [...v]])),
pot: [...s.pot],
removed: [...s.removed],
active: [...s.active],
eliminated: [...s.eliminated],
outReason: { ...s.outReason },
lockedUntil: { ...s.lockedUntil },
wrongCount: { ...s.wrongCount },
correctCount: { ...s.correctCount },
ringWindow: s.ringWindow ? { ...s.ringWindow, rings: s.ringWindow.rings.map((r) => ({ ...r })) } : null,
lastBell: s.lastBell ? { ...s.lastBell } : null,
log: [...s.log],
final: s.final,
};
}
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);
}
/** Other active players clockwise, starting after `p`. */
function othersClockwise(s: S, p: PlayerId): PlayerId[] {
const i = s.order.indexOf(p);
const out: PlayerId[] = [];
for (let k = 1; k < s.order.length; k++) {
const q = s.order[(i + k) % s.order.length]!;
if (s.active.includes(q)) out.push(q);
}
return out;
}
function startTurn(s: S, p: PlayerId, now: number): void {
s.turn = p;
s.turnStartedAt = now;
s.turnDeadline = now + flipMs(s.options);
s.pendingFlip = null;
}
/** Removes `p` from play; returns true if the game ended. */
function eliminate(s: S, p: PlayerId, why: OutReason, ctx: Ctx): boolean {
if (!s.active.includes(p)) return s.phase === 'ended';
s.active = s.active.filter((q) => q !== p);
s.eliminated.push(p);
s.outReason[p] = why;
if (s.ringWindow) s.ringWindow.rings = s.ringWindow.rings.filter((r) => r.player !== p);
ctx.events.push({ type: 'out', player: p, why });
log(s, { t: 'out', p, why });
if (s.active.length <= 1) {
finish(s, 'lastStanding', ctx);
return true;
}
if (s.options.endMode === 'official' && s.order.length >= 3 && s.active.length === 2) s.officialDuel = true;
return false;
}
/** Passes the turn clockwise from `from`; players whose turn comes with an empty deck are out (§5.3). */
function advanceTurn(s: S, from: PlayerId, ctx: Ctx): void {
const n = s.order.length;
const i = s.order.indexOf(from);
for (let k = 1; k <= n; k++) {
const q = s.order[(i + k) % n]!;
if (!s.active.includes(q)) continue;
if (s.decks[q]!.length > 0) {
startTurn(s, q, ctx.now);
return;
}
if (eliminate(s, q, 'empty', ctx)) return;
}
// Unreachable while ≥ 2 players are active (someone holds cards); keep the game well-formed anyway.
finish(s, 'lastStanding', ctx);
}
function doFlip(s: S, p: PlayerId, ctx: Ctx): void {
const id = s.decks[p]!.pop();
if (id === undefined) throw new Error(`halli-galli: ${p} has no card to flip`);
s.faceUp[p]!.push(id);
s.tableVersion += 1;
s.lastChange = 'flip';
s.lastFlipBy = p;
s.pendingFlip = null;
s.flipAllowedAt = ctx.now + s.options.flipGuardMs;
const c = cardOf(id);
ctx.events.push({ type: 'flip', player: p, fruit: c.fruit, count: c.count, version: s.tableVersion });
advanceTurn(s, p, ctx);
}
/** Queues or performs the turn player's flip (guard / bell window aware). */
function flipOrQueue(s: S, p: PlayerId, ctx: Ctx): void {
if (s.ringWindow || ctx.now < s.flipAllowedAt) {
if (s.pendingFlip !== p) {
s.pendingFlip = p;
ctx.events.push({ type: 'flipQueued', player: p });
}
return;
}
doFlip(s, p, ctx);
}
/** Takes every face-up pile (seat order, bottom first) and then the pot to the bottom of `p`'s deck. */
function sweepTable(s: S, p: PlayerId): number {
const taken: number[] = [];
for (const q of s.order) {
taken.push(...s.faceUp[q]!);
s.faceUp[q] = [];
}
taken.push(...s.pot);
s.pot = [];
s.decks[p] = [...taken, ...s.decks[p]!];
s.tableVersion += 1;
s.lastChange = 'collect';
return taken.length;
}
function collect(s: S, p: PlayerId, fruit: Fruit, ctx: Ctx): void {
const cards = sweepTable(s, p);
s.correctCount[p] = (s.correctCount[p] ?? 0) + 1;
s.bellSeq += 1;
s.lastBell = { seq: s.bellSeq, player: p, correct: true, fruit, cards, at: ctx.now };
s.ringWindow = null;
ctx.events.push({ type: 'ring', player: p, correct: true, fruit, cards });
log(s, { t: 'collect', p, fruit, cards });
if (s.officialDuel) {
finish(s, 'official', ctx);
return;
}
// The winner flips next; the table must stay still for the guard time first.
startTurn(s, p, ctx.now);
s.flipAllowedAt = ctx.now + s.options.flipGuardMs;
}
function wrongBell(s: S, p: PlayerId, ctx: Ctx): void {
const o = s.options;
s.wrongCount[p] = (s.wrongCount[p] ?? 0) + 1;
s.lockedUntil[p] = ctx.now + o.wrongRingLockoutMs;
s.bellSeq += 1;
const bell = { seq: s.bellSeq, player: p, correct: false, cards: 0, at: ctx.now };
s.lastBell = bell;
// Two left: official ending / 1-on-1 house rule (§6, §7).
if (s.active.length === 2 && (s.officialDuel || o.duelWrongLoses)) {
const opp = s.active.find((q) => q !== p)!;
let cards: number;
if (s.officialDuel) {
cards = sweepTable(s, opp);
} else {
cards = s.decks[p]!.length;
s.decks[opp] = [...s.decks[p]!, ...s.decks[opp]!];
s.decks[p] = [];
}
bell.cards = cards;
ctx.events.push({ type: 'ring', player: p, correct: false, gave: 0, duel: true, by: opp });
log(s, { t: 'duelWrong', p, by: opp, cards });
if (s.officialDuel) finish(s, 'official', ctx);
else eliminate(s, p, 'duel', ctx);
return;
}
// Give one card (more with penaltyEscalation) to every other active player clockwise; short → out (§5.2).
const per = o.penaltyEscalation ? s.wrongCount[p]! : 1;
const to = othersClockwise(s, p);
let gave = 0;
let short = false;
outer: for (let r = 0; r < per; r++) {
for (const q of to) {
const id = s.decks[p]!.pop();
if (id === undefined) {
short = true;
break outer;
}
if (o.wrongToPot) s.pot.push(id);
else s.decks[q]!.unshift(id);
gave++;
}
}
bell.cards = gave;
ctx.events.push({ type: 'ring', player: p, correct: false, gave, toPot: o.wrongToPot });
log(s, { t: 'wrong', p, gave, toPot: o.wrongToPot });
let out: OutReason | null = null;
if (short) out = 'penalty';
else if (o.strikeOut && s.wrongCount[p]! >= 3) {
s.pot.push(...s.decks[p]!);
s.decks[p] = [];
out = 'strikeOut';
} else if (s.decks[p]!.length === 0 && s.turn === p) out = 'empty'; // it is already their turn and nothing is left to flip
if (!out) return;
const wasTurn = s.turn === p;
if (eliminate(s, p, out, ctx)) return;
if (wasTurn) advanceTurn(s, p, ctx);
}
/** Closes the `latencyComp` bell window: earliest adjusted time wins, ties by arrival (§5.4). */
function resolveWindow(s: S, ctx: Ctx): void {
const w = s.ringWindow!;
s.ringWindow = null;
const fruit = fiveFruit(s);
if (!fruit || w.rings.length === 0) return; // everyone in the window left
let best = w.rings[0]!;
for (const r of w.rings) if (r.adjTs < best.adjTs) best = r;
for (const r of w.rings) if (r !== best) ctx.events.push({ type: 'late', player: r.player });
collect(s, best.player, fruit, ctx);
}
function ring(s: S, p: PlayerId, version: number, rtt: number | undefined, ctx: Ctx): void {
if (s.ringWindow && ctx.now >= s.ringWindow.openedAt + RING_WINDOW_MS) resolveWindow(s, ctx);
if (s.phase !== 'playing') return;
if (version !== s.tableVersion) {
ctx.events.push({ type: 'late', player: p });
return;
}
if (ctx.now < (s.lockedUntil[p] ?? 0)) {
ctx.events.push({ type: 'locked', player: p });
return;
}
const fruit = fiveFruit(s);
if (!fruit) {
wrongBell(s, p, ctx);
return;
}
if (s.options.latencyComp === 'light') {
const adjTs = ctx.now - Math.min((rtt ?? 0) / 2, MAX_LATENCY_CREDIT_MS);
if (!s.ringWindow) s.ringWindow = { version, openedAt: ctx.now, rings: [] };
if (!s.ringWindow.rings.some((r) => r.player === p)) s.ringWindow.rings.push({ player: p, adjTs });
ctx.events.push({ type: 'ringWindow', player: p });
return;
}
collect(s, p, fruit, ctx);
}
/**
* Server timer. Runs whatever is due; for the turn player (whose clock the room drives) a tick that finds
* nothing else due means "time's up / away" → flip (queued behind the guard if needed).
*/
function tick(s: S, actor: PlayerId, ctx: Ctx): void {
if (s.phase === 'countdown') {
if (ctx.now < s.countdownEndsAt) return;
s.phase = 'playing';
startTurn(s, s.turn, ctx.now);
s.flipAllowedAt = ctx.now;
ctx.events.push({ type: 'start' });
return;
}
if (s.ringWindow) {
if (ctx.now >= s.ringWindow.openedAt + RING_WINDOW_MS) resolveWindow(s, ctx);
return;
}
const due = (s.pendingFlip !== null && ctx.now >= s.flipAllowedAt) || ctx.now >= Math.max(s.turnDeadline, s.flipAllowedAt);
if (actor === s.turn || due) flipOrQueue(s, s.turn, ctx);
}
function leave(s: S, p: PlayerId, ctx: Ctx): void {
// The leaver's face-down deck goes under the bell; their face-up pile stays on the table.
s.pot.push(...s.decks[p]!);
s.decks[p] = [];
const wasTurn = s.turn === p;
if (eliminate(s, p, 'left', ctx)) return;
if (wasTurn) advanceTurn(s, p, ctx);
}
// ---------------------------------------------------------------- ending
function finish(s: S, reason: EndReason, ctx: Ctx): void {
if (s.phase === 'ended') return;
s.phase = 'ended';
s.pendingFlip = null;
s.ringWindow = null;
const row = (id: PlayerId, rank: number): RankRow => ({ rank, id, cards: cardsHeld(s, id), correct: s.correctCount[id] ?? 0, wrong: s.wrongCount[id] ?? 0 });
const ranking: RankRow[] = [];
// Still-active players: by cards held, then correct bells; equal → shared rank (§6).
const alive = [...s.active].sort((a, b) => cardsHeld(s, b) - cardsHeld(s, a) || (s.correctCount[b] ?? 0) - (s.correctCount[a] ?? 0));
alive.forEach((id, i) => {
const prev = ranking[i - 1];
const tie = prev && cardsHeld(s, prev.id) === cardsHeld(s, id) && (s.correctCount[prev.id] ?? 0) === (s.correctCount[id] ?? 0);
ranking.push(row(id, tie ? prev.rank : i + 1));
});
// Eliminated players: last out ranks highest.
for (const id of [...s.eliminated].reverse()) ranking.push(row(id, ranking.length + 1));
const winners = ranking.filter((r) => r.rank === 1).length;
let summary: string;
if (reason === 'lastStanding') {
const allLeft = s.eliminated.every((p) => s.outReason[p] === 'left');
summary = allLeft ? '다른 사람이 모두 나가서 남은 사람이 이겼어요' : '끝까지 남아 카드를 지킨 사람이 이겼어요';
} else if (reason === 'official') {
summary = '두 사람이 남은 뒤 종으로 승부가 났어요. 카드가 가장 많은 사람이 이겼어요';
} else {
summary = '시간이 다 됐어요. 카드가 가장 많은 사람이 이겼어요';
}
if (winners > 1) summary += ` (공동 1등 ${winners}명)`;
s.final = { endReason: reason, ranking, summary } satisfies HalliGalliFinal;
ctx.events.push({ type: 'gameEnd', reason });
}
// ---------------------------------------------------------------- definition
const LATE_COLLECT = '늦었어요! 다른 사람이 먼저 종을 쳤어요.';
const LATE_FLIP = '늦었어요! 그 사이에 카드가 바뀌었어요.';
export const halliGalli: GameDefinition<S, A, HalliGalliView, HalliGalliOptions> = {
id: 'halli-galli',
nameKo: '할리갈리',
minPlayers: 2,
maxPlayers: 6,
stateVersion: 1,
defaultOptions: halliGalliOptionsSchema.parse({}),
optionsSchema: halliGalliOptionsSchema as unknown as GameDefinition<S, A, HalliGalliView, HalliGalliOptions>['optionsSchema'],
actionSchema: halliGalliActionSchema as unknown as GameDefinition<S, A, HalliGalliView, HalliGalliOptions>['actionSchema'],
setup({ players, options, rng, now }) {
if (players.length < 2 || players.length > 6) throw new Error(`halli-galli: ${players.length} players not allowed`);
// §4 / §8.4: seats, first player and the deck all come from the seeded RNG.
const order = rng.shuffle(players);
const first = order[rng.int(order.length)]!;
const deck = rng.shuffle(CARDS.map((c) => c.id));
const per = Math.floor(DECK_SIZE / order.length);
const decks: Record<PlayerId, number[]> = {};
order.forEach((p, i) => (decks[p] = deck.slice(i * per, (i + 1) * per)));
const rest = deck.slice(order.length * per);
const per0 = <T>(v: T) => Object.fromEntries(order.map((p) => [p, v])) as Record<PlayerId, T>;
const countdownEndsAt = now + COUNTDOWN_MS;
return {
options,
order,
decks,
faceUp: Object.fromEntries(order.map((p) => [p, [] as number[]])),
pot: options.leftoverCards === 'pot' ? rest : [],
removed: options.leftoverCards === 'remove' ? rest : [],
active: [...order],
eliminated: [],
outReason: {},
officialDuel: false,
turn: first,
turnStartedAt: now,
turnDeadline: countdownEndsAt + flipMs(options),
flipAllowedAt: countdownEndsAt,
pendingFlip: null,
tableVersion: 0,
lastChange: null,
lastFlipBy: null,
lockedUntil: per0(0),
wrongCount: per0(0),
correctCount: per0(0),
ringWindow: null,
phase: 'countdown',
countdownEndsAt,
startedAt: now,
endsAt: options.timeLimitMin === null ? null : countdownEndsAt + options.timeLimitMin * 60_000,
lastBell: null,
bellSeq: 0,
logNo: 0,
log: [],
final: null,
};
},
validate(s, actor, a) {
if (!s.order.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase === 'ended') return fail('게임이 이미 끝났어요.');
switch (a.type) {
case 'tick':
return actor === s.turn || s.active.includes(actor) ? ok : fail('시간 초과 대상이 아니에요.');
case 'leave':
return s.active.includes(actor) ? ok : fail('이미 게임에서 빠졌어요.');
case 'flip':
if (s.phase !== 'playing') return fail('곧 시작해요. 잠시만 기다려 주세요.');
if (s.options.autoFlip) return fail('이 방은 카드가 자동으로 뒤집혀요.');
if (s.turn !== actor) return fail('지금은 내 차례가 아니에요.');
if (s.pendingFlip === actor) return fail('곧 뒤집혀요. 잠시만 기다려 주세요.');
if (s.decks[actor]!.length === 0) return fail('뒤집을 카드가 없어요.');
return ok;
case 'ring':
if (s.phase !== 'playing') return fail('곧 시작해요. 잠시만 기다려 주세요.');
if (!s.active.includes(actor)) return fail('게임에서 빠져서 종을 칠 수 없어요.');
// §5.2 늦은 종: the client saw an older table → ignored without penalty.
if (a.version !== s.tableVersion) return fail(a.version < s.tableVersion && s.lastChange === 'collect' && a.version === s.tableVersion - 1 ? LATE_COLLECT : LATE_FLIP);
if (s.ringWindow?.rings.some((r) => r.player === actor)) return fail('이미 종을 쳤어요.');
return ok;
}
},
apply(prev, actor, a, { now }) {
const s = clone(prev);
const ctx: Ctx = { now, events: [] };
// §2 timeLimitMin: once time is up nothing else happens.
if (s.phase === 'playing' && s.endsAt !== null && now >= s.endsAt) {
finish(s, 'timeLimit', ctx);
return { state: s, events: ctx.events };
}
switch (a.type) {
case 'tick':
tick(s, actor, ctx);
break;
case 'flip':
flipOrQueue(s, actor, ctx);
break;
case 'ring':
ring(s, actor, a.version, a.rtt, ctx);
break;
case 'leave':
leave(s, actor, ctx);
break;
}
return { state: s, events: ctx.events };
},
view(s, viewer) {
const me = viewer && s.order.includes(viewer) ? viewer : null;
return {
options: s.options,
me,
phase: s.phase,
seats: s.order.map((p) => {
const pile = s.faceUp[p]!;
const topId = pile[pile.length - 1];
const c = topId === undefined ? null : cardOf(topId);
const why = s.outReason[p];
return {
id: p,
deck: s.decks[p]!.length,
pile: pile.length,
top: c ? { key: cardKey(c.id), fruit: c.fruit, count: c.count } : null,
cards: cardsHeld(s, p),
status: s.active.includes(p) ? 'playing' : why === 'left' ? 'left' : 'out',
isTurn: s.phase !== 'ended' && s.turn === p,
lockedUntil: s.lockedUntil[p] ?? 0,
correct: s.correctCount[p] ?? 0,
wrong: s.wrongCount[p] ?? 0,
};
}),
turn: s.phase === 'ended' ? null : s.turn,
tableVersion: s.tableVersion,
pot: s.pot.length,
removed: s.removed.length,
countdownEndsAt: s.countdownEndsAt,
turnStartedAt: s.turnStartedAt,
turnDeadline: s.turnDeadline,
flipTotalMs: flipMs(s.options),
flipAllowedAt: s.flipAllowedAt,
pendingFlip: s.pendingFlip !== null,
endsAt: s.endsAt,
windowOpen: s.ringWindow !== null,
officialDuel: s.officialDuel,
lastFlipBy: s.lastFlipBy,
lastBell: s.lastBell,
log: s.log.slice(-12),
result: s.final,
};
},
/** Everyone still in can ring at any time; the turn player also flips (§8.5). */
activePlayers(s) {
if (s.phase === 'ended') return [];
if (s.phase === 'countdown') return [s.turn];
return [...s.active];
},
deadline(s) {
if (s.phase === 'ended') return null;
if (s.phase === 'countdown') return s.countdownEndsAt;
const c: number[] = [];
if (s.endsAt !== null) c.push(s.endsAt);
if (s.ringWindow) c.push(s.ringWindow.openedAt + RING_WINDOW_MS);
else {
if (s.pendingFlip) c.push(s.flipAllowedAt);
c.push(Math.max(s.turnDeadline, s.flipAllowedAt));
}
return Math.min(...c);
},
/** Only the turn player's clock drives the server tick (flips, guard, bell window, time limit). */
timeoutPlayers(s) {
return s.phase === 'ended' ? [] : [s.turn];
},
/**
* Away players are timed out right after the room grace period; never earlier than the flip guard
* (or 1s into their turn), so an away player's flips keep a steady pace instead of a busy loop.
*/
timeoutNotBefore(s) {
if (s.phase === 'ended') return null;
if (s.phase === 'countdown') return s.countdownEndsAt;
if (s.ringWindow) return s.ringWindow.openedAt + RING_WINDOW_MS;
const flipAt = s.pendingFlip ? s.flipAllowedAt : Math.max(s.flipAllowedAt, s.turnStartedAt + AWAY_FLIP_MS);
return s.endsAt !== null ? Math.min(s.endsAt, flipAt) : flipAt;
},
onTimeout() {
return { type: 'tick' };
},
onLeave(s, player) {
if (s.phase === 'ended' || !s.active.includes(player)) return null;
return { type: 'leave' };
},
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] = POINTS[r.rank - 1] ?? 0;
return {
ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids),
scores,
summary: f.summary,
reason: 'normal',
};
},
legalActions(s, p) {
if (s.phase === 'ended' || !s.order.includes(p)) return [];
const out: A[] = [];
if (p === s.turn) out.push({ type: 'tick' });
if (s.phase === 'playing' && s.active.includes(p)) {
out.push({ type: 'ring', version: s.tableVersion });
if (p === s.turn) out.push({ type: 'flip' });
}
return out.filter((a) => halliGalli.validate(s, p, a).ok);
},
/** Every face-down or covered card (nobody may see them, including the owner — §8.3), as view keys. */
secretsOf(s, player) {
const pile = s.faceUp[player] ?? [];
return [...(s.decks[player] ?? []), ...pile.slice(0, -1), ...s.pot, ...s.removed].map(cardKey);
},
};

View File

@@ -0,0 +1,203 @@
/** 할리갈리 options, cards, actions, state and view types (docs/games/halli-galli.md §2, §3, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
export const FRUITS = ['strawberry', 'banana', 'lime', 'plum'] as const;
export type Fruit = (typeof FRUITS)[number];
export type FruitCount = 1 | 2 | 3 | 4 | 5;
export interface Card {
id: number;
fruit: Fruit;
count: FruitCount;
}
/** Per fruit: 1×5, 2×3, 3×3, 4×2, 5×1 = 14 cards (§3). */
const COUNTS: FruitCount[] = [1, 1, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 5];
/** The 56 cards, id 0..55 (fruit-major). */
export const CARDS: readonly Card[] = FRUITS.flatMap((fruit, f) => COUNTS.map((count, i) => ({ id: f * 14 + i, fruit, count })));
export const DECK_SIZE = CARDS.length;
export const cardOf = (id: number): Card => {
const c = CARDS[id];
if (!c) throw new Error(`halli-galli: no card ${id}`);
return c;
};
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 halliGalliOptionsSchema = z.object({
endMode: z
.enum(['lastStanding', 'official'])
.default('lastStanding')
.meta({ title: '끝나는 방식', labels: { lastStanding: '한 명이 남을 때까지', official: '2명 남으면 다음 종에서 끝(원작 규칙)' } }),
timeLimitMin: lit<5 | 10 | 15 | null>(5, 10, 15, null)
.default(10)
.meta({ title: '전체 제한 시간', labels: { 5: '5분', 10: '10분', 15: '15분', null: '없음' } }),
flipSeconds: lit<2 | 3 | 5 | 8>(2, 3, 5, 8)
.default(3)
.meta({ title: '뒤집기 제한 시간', labels: { 2: '2초', 3: '3초', 5: '5초', 8: '8초' } }),
autoFlip: z.boolean().default(false).meta({ title: '카드를 자동으로 뒤집기(뒤집기 버튼 없음)' }),
flipGuardMs: lit<400 | 600 | 1000>(400, 600, 1000)
.default(600)
.meta({ title: '뒤집은 뒤 다음 뒤집기까지 최소 간격', labels: { 400: '0.4초', 600: '0.6초', 1000: '1초' } }),
wrongRingLockoutMs: lit<0 | 1000 | 2000>(0, 1000, 2000)
.default(1000)
.meta({ title: '틀린 종 뒤 종 잠금', labels: { 0: '없음', 1000: '1초', 2000: '2초' } }),
leftoverCards: z
.enum(['pot', 'remove'])
.default('pot')
.meta({ title: '나누고 남은 카드', labels: { pot: '종 밑에 두기(다음 정답자가 가져감)', remove: '게임에서 빼기' } }),
latencyComp: z
.enum(['off', 'light'])
.default('off')
.meta({ title: '지연 보정', labels: { off: '끄기(서버 도착 순서)', light: '가볍게(최대 0.08초 보정)' } }),
showRtt: z.boolean().default(true).meta({ title: '내 핑(연결 지연) 표시' }),
kidMode: z.boolean().default(false).meta({ title: '어린이 모드(8초 뒤집기, 과일 합계 보조 표시)' }),
duelWrongLoses: z.boolean().default(false).meta({ title: '1대1에서 틀린 종을 치면 바로 패배' }),
strikeOut: z.boolean().default(false).meta({ title: '틀린 종 3번이면 탈락' }),
penaltyEscalation: z.boolean().default(false).meta({ title: '틀릴 때마다 벌칙 장수 1장씩 늘리기' }),
wrongToPot: z.boolean().default(false).meta({ title: '벌칙 카드를 종 밑으로(다른 사람에게 주지 않음)' }),
});
export type HalliGalliOptions = z.infer<typeof halliGalliOptionsSchema>;
export const halliGalliActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('flip') }),
z.object({
type: z.literal('ring'),
/** Table version the client was looking at (§5.2 "늦은 종"). */
version: z.number().int().min(0).max(1_000_000),
/** Client-measured round trip, used only by `latencyComp: 'light'` (clamped, max 80ms of credit). */
rtt: z.number().min(0).max(10_000).optional(),
}),
]);
/** Client actions plus system-only ones (server timer, leaving) that are not in the schema. */
export type HalliGalliAction = z.infer<typeof halliGalliActionSchema> | { type: 'tick' } | { type: 'leave' };
export type EndReason = 'lastStanding' | 'official' | 'timeLimit';
export type OutReason = 'empty' | 'penalty' | 'strikeOut' | 'duel' | 'left';
/** Public log line (structured; the screen turns it into Korean with nicknames). */
export type LogEntry =
| { n: number; t: 'collect'; p: PlayerId; fruit: Fruit; cards: number }
| { n: number; t: 'wrong'; p: PlayerId; gave: number; toPot: boolean }
| { n: number; t: 'duelWrong'; p: PlayerId; by: PlayerId; cards: number }
| { n: number; t: 'out'; p: PlayerId; why: OutReason };
export interface RankRow {
rank: number;
id: PlayerId;
cards: number;
correct: number;
wrong: number;
}
export interface HalliGalliFinal {
endReason: EndReason;
ranking: RankRow[];
summary: string;
}
export interface RingWindow {
version: number;
openedAt: number;
rings: { player: PlayerId; adjTs: number }[];
}
export interface LastBell {
/** Increments on every judged bell (for client feedback). */
seq: number;
player: PlayerId;
correct: boolean;
fruit?: Fruit;
/** Correct: cards collected. Wrong: cards given away. */
cards: number;
at: number;
}
export interface HalliGalliState {
options: HalliGalliOptions;
order: PlayerId[];
/** Face-down decks; top = last element. Hidden from everyone (including the owner). */
decks: Record<PlayerId, number[]>;
/** Face-up piles; top = last element. Only the top card is public. */
faceUp: Record<PlayerId, number[]>;
/** Cards under the bell (leftovers, leavers' decks, `wrongToPot`); next correct bell takes them. */
pot: number[];
/** `leftoverCards: 'remove'` — out of the game for good. */
removed: number[];
active: PlayerId[];
/** Elimination order (first out first). */
eliminated: PlayerId[];
outReason: Record<PlayerId, OutReason>;
/** Players with 3+ starting seats get `official` endings; 2-player games always play to the last one (§6). */
officialDuel: boolean;
turn: PlayerId;
turnStartedAt: number;
turnDeadline: number;
flipAllowedAt: number;
pendingFlip: PlayerId | null;
tableVersion: number;
lastChange: 'flip' | 'collect' | null;
lastFlipBy: PlayerId | null;
lockedUntil: Record<PlayerId, number>;
wrongCount: Record<PlayerId, number>;
correctCount: Record<PlayerId, number>;
ringWindow: RingWindow | null;
phase: 'countdown' | 'playing' | 'ended';
countdownEndsAt: number;
startedAt: number;
endsAt: number | null;
lastBell: LastBell | null;
bellSeq: number;
logNo: number;
log: LogEntry[];
final: HalliGalliFinal | null;
}
export interface TopCard {
/** Opaque public key of the visible card (never used for face-down cards). */
key: string;
fruit: Fruit;
count: FruitCount;
}
export interface SeatView {
id: PlayerId;
deck: number;
pile: number;
top: TopCard | null;
/** deck + own face-up pile (§6 "보유 카드 수"). */
cards: number;
status: 'playing' | 'out' | 'left';
isTurn: boolean;
lockedUntil: number;
correct: number;
wrong: number;
}
export interface HalliGalliView {
options: HalliGalliOptions;
me: PlayerId | null;
phase: HalliGalliState['phase'];
seats: SeatView[];
turn: PlayerId | null;
tableVersion: number;
pot: number;
removed: number;
countdownEndsAt: number;
turnStartedAt: number;
turnDeadline: number;
flipTotalMs: number;
flipAllowedAt: number;
/** The turn player's flip is queued behind the flip guard / bell window. */
pendingFlip: boolean;
endsAt: number | null;
windowOpen: boolean;
officialDuel: boolean;
lastFlipBy: PlayerId | null;
lastBell: LastBell | null;
log: LogEntry[];
result: HalliGalliFinal | null;
}

View File

@@ -14,6 +14,7 @@ import { davinci } from './davinci-code';
import { mahjong } from './mahjong';
import { gostop } from './gostop';
import { bang } from './bang';
import { halliGalli } from './halli-galli';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -31,4 +32,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[mahjong.id]: mahjong,
[gostop.id]: gostop,
[bang.id]: bang,
[halliGalli.id]: halliGalli,
};

View File

@@ -26,6 +26,7 @@ export const CATALOG: CatalogEntry[] = [
{ id: 'bang', nameKo: '뱅!', minPlayers: 4, maxPlayers: 7, minutes: '20~40', level: '어려움', blurb: '보안관과 무법자, 숨은 정체를 추리하며 쏘고 피하기!', available: true },
{ id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true },
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
{ id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true },
];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);