M6: 할리갈리 규칙·화면·테스트

- packages/games/src/halli-galli: 56장 과일 카드, 차례 뒤집기(flipGuard 예약), 언제나 칠 수 있는 종
  (tableVersion으로 늦은 종 "늦었어요" 무벌칙, 서버 도착 순서 우선), 오답 벌칙·탈락, official/시간 제한 종료,
  하우스 룰(1대1 오답 패배, 3진 아웃, 벌칙 증가, 벌칙 종 밑), latencyComp light 50ms 창,
  view는 덱 내용을 본인에게도 숨김(장수만), secretsOf/legalActions 제공
- 테스트: §10 체크리스트 전부, 1,000판 시드 랜덤 플레이아웃(카드 보존·누출·onTimeout 합법·결정성) +
  엔진 randomPlayout, 서버 Room 테스트(동시 종 도착 순서, 늦은 종 거절, 자리 비움 자동 뒤집기)
- 화면: 원형 좌석 배치, 큰 종 버튼(스페이스 바), 뒤집기(Enter), SVG 과일 카드, 땡/삐 효과음·진동,
  카드 이동 애니메이션, 어린이 모드 합계 막대, 다크 모드·동작 줄이기 대응
- 공용 변경(하위 호환): GameDefinition.timeoutNotBefore 선택 훅 + Room 타이머 반영(자리 비움 시
  바쁜 반복 방지), feedback.ts에 ding/buzz 소리 추가
- 등록: GAMES, CATALOG, 웹 레지스트리·아이콘, 출시 예정 목록에서 제거, E2E 계획 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 13:34:43 +09:00
parent 6321ea8c0d
commit 595d3cdf4f
13 changed files with 2720 additions and 1 deletions

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));
}
}
});
});