Files
joke-app/packages/games/src/halli-galli/halli-galli.test.ts
EJClaw 595d3cdf4f 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>
2026-10-05 13:34:43 +09:00

708 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
}
});
});