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 = { S: 'strawberry', B: 'banana', L: 'lime', P: 'plum' }; function make(opts: Partial = {}, 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; faceUp?: Record; pot?: Spec[]; turn?: string; now?: number }, opts: Partial = {}, ): HalliGalliState { const s = make(opts, players); const now = r.now ?? START; const used = new Set(); 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 = {}; const piles: Record = {}; const pre = new Map(); 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, 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[] = [ {}, { 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)); } } }); });