/** 그림 맞히기 rules tests (docs/games/catchmind.md §10) + seeded random playouts (docs/09 §7). */ import { describe, expect, test } from 'bun:test'; import { SeededRng, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine'; import { BUILTIN_WORDS, CHOOSE_MS, REVEAL_MS, buildPool, catchmind as cm, catchmindOptionsSchema, chosung, judge, normalize, type CanvasOp, type CatchmindAction, type CatchmindOptions, type CatchmindState, type CatchmindView, type WordEntry, } from './index'; // ---------------------------------------------------------------- helpers const T0 = 1_000_000; const rng = () => SeededRng.fromSeed([3, 1, 4, 1]); const P = (n: number) => Array.from({ length: n }, (_, i) => `p${i + 1}`); const opts = (o: Partial = {}) => catchmindOptionsSchema.parse(o); function make(n = 4, o: Partial = {}, seed = 1): CatchmindState { return cm.setup({ players: P(n), options: opts(o), rng: SeededRng.fromSeed(seedN(seed)), now: T0 }); } function applyE(s: CatchmindState, p: PlayerId, a: CatchmindAction, now: number): { state: CatchmindState; events: GameEvent[] } { const v = cm.validate(s, p, a, { now }); if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`); return cm.apply(s, p, a, { rng: rng(), now }); } const act = (s: CatchmindState, p: PlayerId, a: CatchmindAction, now: number) => applyE(s, p, a, now).state; const reason = (s: CatchmindState, p: PlayerId, a: CatchmindAction, now?: number) => { const v = cm.validate(s, p, a, now === undefined ? undefined : { now }); return v.ok ? null : v.reason; }; const entry = (word: string): WordEntry => BUILTIN_WORDS.find((w) => w.word === word) ?? { word, aliases: [], category: 'custom', difficulty: 'normal' }; /** Puts `word` in front of the current drawer and picks it; returns the drawing state. */ function drawWith(s0: CatchmindState, word: string, now = T0 + 1000): CatchmindState { const s = structuredClone(s0); expect(s.turn.phase).toBe('choosing'); s.turn.choices = [entry(word), ...s.turn.choices.slice(1)]; return act(s, s.turn.drawer, { type: 'chooseWord', index: 0 }, now); } const guessers = (s: CatchmindState) => s.order.filter((p) => p !== s.turn.drawer); const guess = (text: string) => ({ type: 'guess', text }) as const; const draw = (op: CanvasOp) => ({ type: 'draw', op }) as const; const view = (s: CatchmindState, p: PlayerId | null): CatchmindView => cm.view(s, p); // ---------------------------------------------------------------- §5.3 text describe('정답 판정(§5.3)', () => { const ice = ['아이스크림']; test('정규화: 띄어쓰기·문장부호, 끝말, NFD, 전각 영문, 제로폭 문자 모두 정답', () => { expect(judge('아이스 크림!', ice, true)).toBe('correct'); expect(judge('아이스크림요', ice, true)).toBe('correct'); expect(judge('아이스크림'.normalize('NFD'), ice, true)).toBe('correct'); expect('아이스크림'.normalize('NFD')).not.toBe('아이스크림'); expect(judge('ABC', ['ABC'], true)).toBe('correct'); expect(judge('abc', ['ABC'], true)).toBe('correct'); expect(judge('아이​스‍크림', ice, true)).toBe('correct'); expect(judge(' 아이스크림입니다~ ', ice, true)).toBe('correct'); expect(judge('아이스크림인가요?', ice, true)).toBe('correct'); expect(judge('🍦아이스크림🍦', ice, true)).toBe('correct'); expect(normalize('A B·C…')).toBe('abc'); }); test('초성만(ㅇㅇㅅㅋㄹ)은 정답 아님 + 비공개, 낱자 ㅅㅏㄱㅘ도 정답 아님', () => { expect(judge('ㅇㅇㅅㅋㄹ', ice, true)).toBe('chosung'); expect(judge('ㅇ ㅇ ㅅ ㅋ ㄹ', ice, true)).toBe('chosung'); expect('ㅅㅏㄱㅘ'.normalize('NFKC')).toBe('사과'); // why the raw check must come first expect(judge('ㅅㅏㄱㅘ', ['사과'], true)).not.toBe('correct'); expect(judge('ㅋㅋㅋ', ['사과'], true)).toBe('plain'); expect(chosung('아이스크림')).toBe('ㅇㅇㅅㅋㄹ'); }); test('끝말 허용이 정답 자체를 깎지 않음: 바다 ← "바" 오답, "바다요" 정답', () => { expect(judge('바', ['바다'], true)).not.toBe('correct'); expect(judge('바다요', ['바다'], true)).toBe('correct'); expect(judge('바다다', ['바다'], true)).toBe('correct'); expect(judge('바다야', ['바다'], true)).not.toBe('correct'); }); test('별칭도 정답', () => { expect(judge('핸드폰', ['휴대폰', '핸드폰', '휴대전화', '스마트폰'], true)).toBe('correct'); }); test('정답이 들어간 말은 contains, 아까워요는 자모 거리 1(3글자 이하)/2(4글자 이상)', () => { expect(judge('사과나무', ['사과'], true)).toBe('contains'); expect(judge('사과나무 아님', ['사과'], true)).toBe('contains'); expect(judge('코끼라', ['코끼리'], true)).toBe('close'); expect(judge('코끼라', ['코끼리'], false)).toBe('plain'); expect(judge('코기리', ['코끼리'], true)).toBe('close'); expect(judge('고기리', ['코끼리'], true)).toBe('plain'); // distance 2 on a 3-letter word expect(judge('아이스크룸', ['아이스크림'], true)).toBe('close'); expect(judge('아이스크름', ['아이스크림'], true)).toBe('close'); expect(judge('하마', ['코끼리'], true)).toBe('plain'); }); }); // ---------------------------------------------------------------- chat visibility describe('채팅 공개 범위(§5.3, §8.3)', () => { test('초성 입력은 본인만 보고, 정답 포함 메시지는 막고 안내, 아까워요는 본인에게만', () => { let s = drawWith(make(4), '아이스크림'); const [a, b, c] = guessers(s) as [string, string, string]; s = act(s, a, guess('ㅇㅇㅅㅋㄹ'), T0 + 2000); s = act(s, b, guess('아이스크림 케이크'), T0 + 2100); s = act(s, c, guess('아이스크룸'), T0 + 2200); const va = view(s, a); const vb = view(s, b); const vc = view(s, c); const vd = view(s, s.turn.drawer); // a sees own blocked chosung line; nobody else does. expect(va.chat.find((m) => m.from === a)).toMatchObject({ kind: 'blocked', note: 'chosung' }); for (const v of [vb, vc, vd, view(s, null)]) expect(v.chat.some((m) => m.from === a)).toBe(false); expect(vb.chat.find((m) => m.from === b)).toMatchObject({ kind: 'blocked', note: 'contains', text: '아이스크림 케이크' }); for (const v of [va, vc, vd]) expect(v.chat.some((m) => m.from === b)).toBe(false); // close: sender sees the "아까워요" kind, others see a normal chat line. expect(vc.chat.find((m) => m.from === c)).toMatchObject({ kind: 'close', text: '아이스크룸' }); for (const v of [va, vb, vd, view(s, null)]) expect(v.chat.find((m) => m.from === c)).toMatchObject({ kind: 'chat', text: '아이스크룸' }); expect(s.turn.solvers).toHaveLength(0); }); test('정답은 "맞혔어요" 줄로 바뀌고, 맞힌 사람의 채팅은 출제자·맞힌 사람에게만', () => { let s = drawWith(make(4), '사과'); const [a, b, c] = guessers(s) as [string, string, string]; s = act(s, a, guess('사과요'), T0 + 2000); expect(s.turn.solvers.map((x) => x.id)).toEqual([a]); expect(view(s, b).chat.at(-1)).toMatchObject({ from: a, kind: 'correct', text: '' }); s = act(s, a, guess('빨간 거 맞죠'), T0 + 2500); expect(view(s, b).chat.some((m) => m.text === '빨간 거 맞죠')).toBe(false); expect(view(s, null).chat.some((m) => m.text === '빨간 거 맞죠')).toBe(false); expect(view(s, s.turn.drawer).chat.find((m) => m.text === '빨간 거 맞죠')).toMatchObject({ solversOnly: true }); expect(view(s, a).word).toBe('사과'); expect(view(s, b).word).toBeNull(); expect(view(s, a).role).toBe('solver'); s = act(s, b, guess('사과'), T0 + 3000); expect(view(s, b).chat.find((m) => m.text === '빨간 거 맞죠')).toMatchObject({ solversOnly: true }); expect(view(s, c).word).toBeNull(); s = act(s, c, guess('사과'), T0 + 3500); // Everyone got it → reveal: everyone sees the word and the solvers' chat. expect(s.turn.phase).toBe('reveal'); expect(view(s, null).word).toBe('사과'); expect(view(s, null).chat.some((m) => m.text === '빨간 거 맞죠')).toBe(true); }); test('출제자는 그리는 동안 정답 입력·채팅 불가, 반응 버튼만', () => { const s = drawWith(make(3), '사과'); expect(reason(s, s.turn.drawer, guess('사과'))).toContain('출제자'); expect(reason(s, s.turn.drawer, { type: 'chat', text: '안녕' })).not.toBeNull(); const r = act(s, s.turn.drawer, { type: 'reaction', code: 'almost' }, T0 + 2000); expect(view(r, guessers(r)[0]!).chat.at(-1)).toMatchObject({ kind: 'reaction', text: 'almost' }); }); test('글자 수 제한·초당 3개 제한', () => { const s = drawWith(make(3), '사과'); const g = guessers(s)[0]!; expect(reason(s, g, guess('가'.repeat(41)))).toContain('40자'); expect(reason(s, g, guess(' '))).not.toBeNull(); let t = s; for (let i = 0; i < 3; i++) t = act(t, g, guess(`배${i}`), T0 + 2000 + i * 100); expect(reason(t, g, guess('배3'), T0 + 2300)).toContain('천천히'); expect(reason(t, g, guess('배3'), T0 + 3000)).toBeNull(); }); }); // ---------------------------------------------------------------- §6 scores describe('점수(§6)', () => { test('multi: 4명 순서대로 맞히면 10/8/6/4, 출제자 8점', () => { let s = drawWith(make(5), '기린'); const d = s.turn.drawer; const gs = guessers(s); gs.forEach((g, i) => (s = act(s, g, guess('기린'), T0 + 2000 + i * 500))); expect(gs.map((g) => s.scores[g])).toEqual([10, 8, 6, 4]); expect(s.scores[d]).toBe(8); expect(s.turn.phase).toBe('reveal'); expect(s.turn.endReason).toBe('allSolved'); expect(view(s, null).turnPoints).toMatchObject({ [d]: 8, [gs[0]!]: 10, [gs[3]!]: 4 }); }); test('multi: 다섯 번째부터 4점, 출제자 최대 12점', () => { let s = drawWith(make(8), '기린'); const d = s.turn.drawer; const gs = guessers(s); gs.forEach((g, i) => (s = act(s, g, guess('기린'), T0 + 2000 + i * 400))); expect(gs.map((g) => s.scores[g])).toEqual([10, 8, 6, 4, 4, 4, 4]); expect(s.scores[d]).toBe(12); }); test('multi: 아무도 못 맞히면 출제자 0점, 시간 만료로 공개', () => { let s = drawWith(make(3), '기린'); const d = s.turn.drawer; s = act(s, d, { type: 'tick' }, s.turn.endsAt); expect(s.turn.phase).toBe('reveal'); expect(s.turn.endReason).toBe('time'); expect(s.scores[d]).toBe(0); expect(view(s, guessers(s)[0]!).word).toBe('기린'); }); test('firstOnly: 첫 정답 즉시 턴 종료, 정답자 10·출제자 5', () => { let s = drawWith(make(4, { guessMode: 'firstOnly' }), '기린'); const d = s.turn.drawer; const [a, b] = guessers(s) as [string, string]; s = act(s, a, guess('기린'), T0 + 2000); expect(s.turn.phase).toBe('reveal'); expect(s.turn.endReason).toBe('first'); expect(s.scores[a]).toBe(10); expect(s.scores[d]).toBe(5); expect(reason(s, b, guess('기린'))).not.toBeNull(); }); test('speedBonus: 남은 시간 비율 × 5점 추가', () => { let s = drawWith(make(3, { speedBonus: true }), '기린', T0); const g = guessers(s)[0]!; const dur = s.turn.endsAt - s.turn.startedAt; s = act(s, g, guess('기린'), T0 + dur / 2); expect(s.scores[g]).toBe(10 + 3); // round(2.5) }); test('신고 과반 → 즉시 종료, 출제자 0점(맞힌 사람 점수 유지)', () => { let s = drawWith(make(5), '기린'); const d = s.turn.drawer; const [a, b, c] = guessers(s) as [string, string, string]; s = act(s, a, guess('기린'), T0 + 2000); // 4 voters → 3 needed (more than half), solvers count too. expect(view(s, b).reportsNeeded).toBe(3); s = act(s, a, { type: 'report' }, T0 + 2100); s = act(s, b, { type: 'report' }, T0 + 2200); expect(reason(s, b, { type: 'report' })).toContain('이미'); expect(s.turn.phase).toBe('drawing'); s = act(s, c, { type: 'report' }, T0 + 2300); expect(s.turn.phase).toBe('reveal'); expect(s.turn.endReason).toBe('report'); expect(s.scores[d]).toBe(0); expect(s.scores[a]).toBe(10); expect(reason(drawWith(make(3, { letterReport: false }), '기린'), 'p1', { type: 'report' })).not.toBeNull(); }); }); // ---------------------------------------------------------------- §5.4 hints describe('힌트(§5.4)', () => { test('50%에 초성, 75%에 한 글자(RNG 위치)', () => { let s = drawWith(make(3, { drawSeconds: 80 }), '아이스크림', T0); const g = guessers(s)[0]!; const d = s.turn.drawer; expect(view(s, g).hint).toEqual(['○', '○', '○', '○', '○']); expect(cm.deadline!(s)).toBe(T0 + 40_000); s = act(s, d, { type: 'tick' }, T0 + 40_000); expect(view(s, g).hint).toEqual(['ㅇ', 'ㅇ', 'ㅅ', 'ㅋ', 'ㄹ']); expect(cm.deadline!(s)).toBe(T0 + 60_000); s = act(s, d, { type: 'tick' }, T0 + 60_000); const h = view(s, g).hint!; const i = s.turn.revealIndex!; expect(h[i]).toBe('아이스크림'[i]); expect(h.filter((x, k) => k !== i)).toEqual(['ㅇ', 'ㅇ', 'ㅅ', 'ㅋ', 'ㄹ'].filter((_, k) => k !== i)); expect(cm.deadline!(s)).toBe(T0 + 80_000); expect(s.turn.phase).toBe('drawing'); }); test('2글자 단어는 75% 단계 생략, 띄어쓰기 위치 표시', () => { let s = drawWith(make(3), '기린', T0); s = act(s, s.turn.drawer, { type: 'tick' }, T0 + 40_000); expect(view(s, null).hint).toEqual(['ㄱ', 'ㄹ']); expect(cm.deadline!(s)).toBe(s.turn.endsAt); const sp = drawWith(make(3), '사과 나무', T0); expect(view(sp, null).hint).toEqual(['○', '○', ' ', '○', '○']); }); test('basic은 글자 수만, none은 없음', () => { let s = drawWith(make(3, { hints: 'basic' }), '아이스크림', T0); expect(cm.deadline!(s)).toBe(s.turn.endsAt); expect(view(s, null).hint).toEqual(['○', '○', '○', '○', '○']); s = drawWith(make(3, { hints: 'none' }), '아이스크림', T0); expect(view(s, null).hint).toBeNull(); }); }); // ---------------------------------------------------------------- §5.5 drawing describe('그림 검증(§5.5)', () => { const begin = (s: number): CanvasOp => ({ k: 'b', s, c: 0, w: 1, e: 0 }); test('출제자 아닌 사람, 좌표 1001, 점 65개, 획 번호 건너뛰기, 초당 31번째 메시지 거부', () => { let s = drawWith(make(3), '기린', T0); const d = s.turn.drawer; const g = guessers(s)[0]!; expect(reason(s, g, draw(begin(1)))).toContain('출제자'); expect(reason(s, d, draw(begin(2)))).toContain('획 번호'); s = act(s, d, draw(begin(1)), T0 + 10); expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: [1001, 5] }))).not.toBeNull(); expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: [999, 5, 2, 0] }))).not.toBeNull(); // 1001 via delta expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: Array(130).fill(1) }))).toContain('점 개수'); expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: Array(128).fill(1) }))).toBeNull(); expect(reason(s, d, draw({ k: 'p', s: 1, q: 1, d: [5, 5] }))).toContain('순서'); expect(reason(s, d, draw({ k: 'p', s: 2, q: 0, d: [5, 5] }))).toContain('획 번호'); s = act(s, d, draw({ k: 'p', s: 1, q: 0, d: [0, 1000, 3, -4] }), T0 + 20); expect(s.turn.totalPoints).toBe(2); s = act(s, d, draw({ k: 'e', s: 1 }), T0 + 30); // 30 messages within one second are fine, the 31st is not. let t = s; let k = 2; for (; t.turn.drawTimes.length < 30; k++) t = act(t, d, draw(begin(k)), T0 + 100 + k); expect(reason(t, d, draw(begin(k)), T0 + 1009)).toContain('너무 빨리'); expect(reason(t, d, draw(begin(k)), T0 + 1010)).toBeNull(); // Not while choosing / reveal. expect(reason(make(3), 'p1', draw(begin(1)))).not.toBeNull(); }); test('턴당 누적 점 30,000 초과 거부', () => { let s = drawWith(make(3), '기린', T0); const d = s.turn.drawer; s = act(s, d, draw(begin(1)), T0); s = { ...s, turn: { ...s.turn, totalPoints: 29_999 } }; expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: [1, 1, 1, 1] }))).toContain('다 썼어요'); expect(reason(s, d, draw({ k: 'p', s: 1, q: 0, d: [1, 1] }))).toBeNull(); }); test('되돌리기·전체 지우기, canvasOp 이벤트만 나가고 view에는 개수만(전체 그림은 syncView)', () => { let s = drawWith(make(3), '기린', T0); const d = s.turn.drawer; expect(reason(s, d, draw({ k: 'u' }))).not.toBeNull(); const before = s; const r = applyE(s, d, draw(begin(1)), T0 + 1); expect(r.events).toEqual([{ type: 'canvasOp', turn: s.turn.no, n: 0, op: begin(1) }]); expect(before.turn.canvas).toHaveLength(0); // previous state untouched s = r.state; s = act(s, d, draw({ k: 'p', s: 1, q: 0, d: [10, 10, 5, 5] }), T0 + 2); s = act(s, d, draw({ k: 'e', s: 1 }), T0 + 3); s = act(s, d, draw(begin(2)), T0 + 4); s = act(s, d, draw({ k: 'u' }), T0 + 5); expect(s.turn.live).toBe(1); s = act(s, d, draw({ k: 'x' }), T0 + 6); expect(s.turn.live).toBe(0); expect(reason(s, d, draw({ k: 'x' }))).not.toBeNull(); expect(cm.isStreamAction!(draw({ k: 'x' }))).toBe(true); expect(cm.isStreamAction!(guess('a'))).toBe(false); const g = guessers(s)[0]!; expect(view(s, g).canvas).toBeNull(); expect(view(s, g).canvasCount).toBe(6); expect(cm.syncView!(s, g).canvas).toEqual(s.turn.canvas); expect(cm.syncView!(s, null).canvas).toHaveLength(6); }); }); // ---------------------------------------------------------------- flow describe('진행(§5.1, §5.2, §8.5)', () => { test('고르기 10초 초과 → 첫 단어, 그리기·공개·다음 출제자, 라운드 진행과 종료', () => { let s = make(2, { rounds: 1 }); const first = s.turn.drawer; expect(s.turn.choices).toHaveLength(3); expect(cm.deadline!(s)).toBe(T0 + CHOOSE_MS); const c0 = s.turn.choices[0]!.word; s = act(s, first, { type: 'tick' }, T0 + CHOOSE_MS); expect(s.turn.phase).toBe('drawing'); expect(s.turn.word!.word).toBe(c0); expect(s.away).toEqual([]); s = act(s, first, { type: 'tick' }, s.turn.endsAt); expect(s.turn.phase).toBe('reveal'); expect(cm.deadline!(s)).toBe(s.turn.startedAt + REVEAL_MS); s = act(s, first, { type: 'tick' }, s.turn.endsAt); expect(s.turn.drawer).not.toBe(first); expect(s.turn.no).toBe(2); const second = s.turn.drawer; s = act(s, second, { type: 'chooseWord', index: 2 }, s.turn.startedAt + 1000); s = act(s, second, { type: 'tick' }, s.turn.endsAt); s = act(s, second, { type: 'tick' }, s.turn.endsAt); expect(s.finished).toBe(true); const r = cm.result(s)!; expect(r.ranking.flat().sort()).toEqual(['p1', 'p2']); expect(r.summary).toContain('1등'); expect(s.final!.turns.map((t) => t.drawer)).toEqual([first, second]); }); test('wordChoice off → 바로 그리기(무작위 1개)', () => { const s = make(3, { wordChoice: false }); expect(s.turn.phase).toBe('drawing'); expect(s.turn.word).not.toBeNull(); expect(view(s, s.turn.drawer).choices).toBeNull(); }); test('출제자 연결 끊김 10초 → 턴 종료(출제자 0점, 정답 공개), 다음 출제 차례는 건너뜀', () => { let s = make(3, { rounds: 2 }); const d = s.turn.drawer; // The room times out a disconnected drawer at once while choosing (disconnectGraceMs 0) → auto pick + away mark. expect(cm.disconnectGraceMs!(s)).toBe(0); s = act(s, d, { type: 'tick' }, T0 + 500); expect(s.turn.phase).toBe('drawing'); expect(s.away).toEqual([d]); expect(cm.disconnectGraceMs!(s)).toBe(10_000); expect(cm.timeoutNotBefore!(s)).toBe(T0 + 500 + 10_000); s = act(s, guessers(s)[0]!, guess(s.turn.word!.word), T0 + 3000); s = act(s, d, { type: 'tick' }, T0 + 10_500); expect(s.turn.phase).toBe('reveal'); expect(s.turn.endReason).toBe('drawerAway'); expect(s.scores[d]).toBe(0); expect(view(s, guessers(s)[1]!).word).toBe(s.turn.word!.word); // Skipped while away: the next drawers are the others. const seen: string[] = []; for (let i = 0; i < 2; i++) { s = act(s, s.turn.drawer, { type: 'tick' }, s.turn.endsAt); seen.push(s.turn.drawer); s = act(s, s.turn.drawer, { type: 'chooseWord', index: 0 }, s.turn.startedAt + 100); s = act(s, s.turn.drawer, { type: 'tick' }, s.turn.endsAt); } expect(seen).not.toContain(d); // "돌아왔어요" clears the mark. expect(reason(s, d, { type: 'back' })).toBeNull(); s = act(s, d, { type: 'back' }, s.turn.endsAt - 1); expect(s.away).toEqual([]); expect(reason(s, d, { type: 'back' })).not.toBeNull(); }); test('나간 사람: 출제자가 나가면 턴 종료, 2명 미만이면 게임 종료', () => { let s = drawWith(make(3), '기린'); const d = s.turn.drawer; expect(cm.onLeave!(s, d)).toEqual({ type: 'leave' }); s = act(s, d, { type: 'leave' }, T0 + 3000); expect(s.turn.phase).toBe('reveal'); expect(s.turn.endReason).toBe('drawerLeft'); expect(cm.onLeave!(s, d)).toBeNull(); s = act(s, s.turn.drawer, { type: 'tick' }, s.turn.endsAt); expect(s.turn.drawer).not.toBe(d); const other = s.order.find((p) => p !== d && p !== s.turn.drawer)!; s = act(s, other, { type: 'leave' }, s.turn.startedAt + 100); expect(s.finished).toBe(true); const r = cm.result(s)!; expect(r.ranking.at(-1)).toContain(d); }); test('남은 맞히는 사람이 모두 맞힌 상태에서 못 맞힌 사람이 나가면 바로 공개', () => { let s = drawWith(make(4), '기린'); const [a, b, c] = guessers(s) as [string, string, string]; s = act(s, a, guess('기린'), T0 + 2000); s = act(s, b, guess('기린'), T0 + 2100); s = act(s, c, { type: 'leave' }, T0 + 2200); expect(s.turn.phase).toBe('reveal'); expect(s.turn.endReason).toBe('allSolved'); }); test('answererDraws(firstOnly): 정답자가 다음 출제자', () => { let s = drawWith(make(4, { guessMode: 'firstOnly', answererDraws: true }), '기린'); const g = guessers(s)[2]!; s = act(s, g, guess('기린'), T0 + 2000); s = act(s, s.turn.drawer, { type: 'tick' }, s.turn.endsAt); expect(s.turn.drawer).toBe(g); }); test('최종 순위: 총점 → 정답 횟수 → 공동 순위', () => { const s = structuredClone(make(4)); s.scores = { p1: 20, p2: 20, p3: 20, p4: 5 }; s.correctCounts = { p1: 1, p2: 2, p3: 1, p4: 0 }; s.turn.phase = 'reveal'; s.turnsPlayed = s.totalTurns - 1; s.round = s.options.rounds; s.turn.drawer = s.order.at(-1)!; const r = act(s, s.turn.drawer, { type: 'tick' }, s.turn.endsAt); expect(r.finished).toBe(true); expect(cm.result(r)!.ranking.map((g) => [...g].sort())).toEqual([['p2'], ['p1', 'p3'], ['p4']]); expect(r.final!.ranking.map((x) => x.rank)).toEqual([1, 2, 2, 4]); }); test('단어 풀: 분야·난이도·추가 단어(필터), 부족하면 시작 거절', () => { expect(buildPool(opts()).length).toBe(BUILTIN_WORDS.length); expect(BUILTIN_WORDS.length).toBeGreaterThanOrEqual(600); const animalsEasy = buildPool(opts({ categories: { ...opts().categories, food: false, object: false, vehicle: false, nature: false, place: false, job: false, sport: false, body: false, season: false }, difficulty: 'easy' })); expect(animalsEasy.every((w) => w.category === 'animal' && w.difficulty === 'easy')).toBe(true); const custom = opts({ customOnly: true, customWords: '우리집 강아지\n돌멩이\n돌멩이\nABC\n시발\n가나다라마바사아자차카\n붕어빵' }); expect(buildPool(custom).map((w) => w.word)).toEqual(['돌멩이', 'ABC', '붕어빵']); expect(cm.playerCountError!(custom, 3)).toContain('추가 단어'); expect(cm.playerCountError!(opts(), 10)).toBeNull(); expect(cm.view(make(2, { customWords: '돌멩이' }), 'p1').options.customWordCount).toBe(1); }); }); // ---------------------------------------------------------------- leaks /** Secrets a non-solver guesser / spectator must never see during drawing (§8.3, §10). */ function hiddenFrom(s: CatchmindState): string[] { const t = s.turn; // The next few pool words (checking all ~700 on every step would dominate the run time). const out = s.wordPool.slice(0, 12).map((w) => w.word); if (t.phase === 'choosing') out.push(...t.choices.map((w) => w.word)); if (t.phase === 'drawing' && t.word) out.push(t.word.word, ...t.word.aliases); return out; } function checkLeaks(s: CatchmindState, events: GameEvent[] = []): void { if (s.finished || s.turn.phase === 'reveal') return; const secret = hiddenFrom(s); const solversChat = s.chat.filter((m) => m.turn === s.turn.no && m.audience === 'solvers').map((m) => m.id); const evJson = JSON.stringify(events); for (const w of secret) if (evJson.includes(JSON.stringify(w))) throw new Error(`event leaks ${w}`); const outsiders: (PlayerId | null)[] = [null, ...s.order.filter((p) => p !== s.turn.drawer && !s.turn.solvers.some((x) => x.id === p))]; for (const p of outsiders) { for (const v of [cm.view(s, p), cm.syncView!(s, p)]) { const json = JSON.stringify(v); for (const w of secret) { // Their own chat lines may legitimately contain anything they typed. if (json.includes(JSON.stringify(w))) throw new Error(`view of ${p} leaks "${w}"`); } if (v.word !== null || v.choices !== null || v.category !== null) throw new Error(`view of ${p} has the answer`); if (v.chat.some((m) => solversChat.includes(m.id))) throw new Error(`view of ${p} has solvers chat`); if (v.chat.some((m) => m.from !== p && (m.kind === 'close' || m.kind === 'blocked'))) throw new Error(`view of ${p} has others' private lines`); } } } // ---------------------------------------------------------------- random playouts interface Run { state: CatchmindState; trace: { p: PlayerId; a: CatchmindAction; now: number }[]; steps: number; } const CHEAT = ['ㅇ', '아까', '모르겠어요', '정답?']; /** Fast loop: random actor (anyone, not just active), random legal action incl. near-misses; 0.2–3s per step. */ function fastPlayout(players: PlayerId[], options: CatchmindOptions, seed: RngState, maxSteps: number, leaks: boolean): Run { const rng = SeededRng.fromSeed(seed); const pick = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]); let now = 1_700_000_000_000; let s = cm.setup({ players, options, rng, now }); const poolSize = s.wordPool.length + s.turn.choices.length + (s.turn.word ? 1 : 0); const trace: Run['trace'] = []; for (let step = 0; step < maxSteps; step++) { invariants(s, poolSize); if (cm.result(s)) return { state: s, trace, steps: step }; // Timer: the drawer's tick when due (as the room would), otherwise a random actor. const due = cm.deadline!(s)!; let p: PlayerId; let a: CatchmindAction; if (now >= due || pick.int(12) === 0) { p = cm.timeoutPlayers!(s)[0]!; a = cm.onTimeout(s, p); now = Math.max(now, Math.min(due, now + 12_000)); } else { p = players[pick.int(players.length)]!; const legal = cm.legalActions!(s, p); if (legal.length === 0) continue; a = legal[pick.int(legal.length)]!; if (a.type === 'guess' && pick.int(3) === 0) a = { type: 'guess', text: CHEAT[pick.int(CHEAT.length)]! }; } const v = cm.validate(s, p, a, { now }); if (!v.ok) { // Rate limits / time-up may reject; nothing else should. if (/천천히|너무 빨리|시간이 다/.test(v.reason)) { now += 400; continue; } throw new Error(`rejected ${p} ${JSON.stringify(a)}: ${v.reason}`); } const r = cm.apply(s, p, a, { rng, now }); if (leaks) checkLeaks(r.state, r.events); s = r.state; trace.push({ p, a, now }); now += 200 + pick.int(2800); } return { state: s, trace, steps: maxSteps }; } function invariants(s: CatchmindState, poolSize: number): void { const t = s.turn; // Word conservation: every pool word is in exactly one place. const used = s.history.map((h) => h.word).filter(Boolean); const current = t.phase !== 'reveal' && t.word ? [t.word.word] : []; const all = [...s.wordPool.map((w) => w.word), ...s.spare.map((w) => w.word), ...t.choices.map((w) => w.word), ...current, ...used]; if (all.length !== poolSize) throw new Error(`word count ${all.length} != ${poolSize}`); if (new Set(all).size !== all.length) throw new Error('word duplicated'); // Score conservation. const total = Object.values(s.scores).reduce((x, y) => x + y, 0); let expected = t.phase === 'reveal' || s.finished ? 0 : t.solvers.reduce((x, y) => x + y.points, 0); for (const h of s.history) expected += h.drawerPoints + h.solverPoints.reduce((x, y) => x + y, 0); if (total !== expected) throw new Error(`scores ${total} != ${expected}`); const correct = Object.values(s.correctCounts).reduce((x, y) => x + y, 0); if (correct !== s.history.reduce((x, h) => x + h.solvers.length, 0) + (t.phase === 'reveal' || s.finished ? 0 : t.solvers.length)) throw new Error('correct count'); if (t.totalPoints > 30_000 || t.live < 0) throw new Error('canvas limits'); if (s.round > s.options.rounds) throw new Error('round overflow'); } describe('무작위 대국(§10, docs/09 §7)', () => { test('1,000판: 예외 없음, 단어·점수 보존, 종료, 결정성', () => { const t0 = performance.now(); let finished = 0; for (let i = 0; i < 1000; i++) { const pk = SeededRng.fromSeed(seedN(9000 + i)); const n = 2 + pk.int(9); const options = opts({ guessMode: pk.int(3) === 0 ? 'firstOnly' : 'multi', rounds: (1 + pk.int(3)) as 1 | 2 | 3, drawSeconds: ([60, 80, 100, 120] as const)[pk.int(4)]!, wordChoice: pk.int(4) !== 0, hints: (['none', 'basic', 'full'] as const)[pk.int(3)]!, letterReport: pk.int(3) !== 0, answererDraws: pk.int(2) === 0, speedBonus: pk.int(3) === 0, difficulty: (['easy', 'normal', 'mixed'] as const)[pk.int(3)]!, }); const run = fastPlayout(P(n), options, seedN(i), 4000, i < 150); if (run.state.finished) finished++; else throw new Error(`seed ${i}: not finished after ${run.steps} steps`); if (i % 100 === 0) { const again = fastPlayout(P(n), options, seedN(i), 4000, false); expect(JSON.stringify(again.state)).toBe(JSON.stringify(run.state)); } } expect(finished).toBe(1000); expect(performance.now() - t0).toBeLessThan(55_000); }, 60_000); test('엔진 randomPlayout(매 단계 직렬화·secretsOf 누출 검사)', () => { for (let i = 0; i < 12; i++) { const n = 2 + (i % 9); const r = randomPlayout(cm, { players: P(n), options: opts({ rounds: 1 }), seed: seedN(700 + i), maxSteps: 5000, maxApplyMs: 20 }); expect(r.finished).toBe(true); } }, 60_000); test('같은 시드 + 액션 로그 재생 → 점수·그림 기록 동일', () => { const options = opts({ rounds: 2 }); const run = fastPlayout(P(5), options, seedN(77), 4000, false); const rngR = SeededRng.fromSeed(seedN(77)); let s = cm.setup({ players: P(5), options, rng: rngR, now: 1_700_000_000_000 }); const canvases: string[] = []; for (const { p, a, now } of run.trace) { expect(cm.validate(s, p, a, { now }).ok).toBe(true); s = cm.apply(s, p, a, { rng: rngR, now }).state; canvases.push(JSON.stringify(s.turn.canvas)); } expect(s.scores).toEqual(run.state.scores); expect(JSON.stringify(s)).toBe(JSON.stringify(run.state)); expect(canvases.some((c) => c.length > 50)).toBe(true); }); test('같은 단어는 한 게임에서 두 번 나오지 않음(풀이 딱 맞을 때 포함)', () => { const words = Array.from({ length: 6 }, (_, i) => `단어${'가나다라마바'[i]}`).join('\n'); for (let i = 0; i < 40; i++) { const run = fastPlayout(P(3), opts({ customOnly: true, customWords: words, rounds: 2 }), seedN(300 + i), 4000, false); const answers = run.state.history.map((h) => h.word).filter(Boolean); expect(new Set(answers).size).toBe(answers.length); expect(run.state.finished).toBe(true); } }); test('onTimeout은 모든 상태에서 합법', () => { for (let i = 0; i < 40; i++) { const run = fastPlayout(P(2 + (i % 5)), opts({ rounds: 1, guessMode: i % 2 ? 'firstOnly' : 'multi' }), seedN(50 + i), 4000, false); const rngR = SeededRng.fromSeed(seedN(50 + i)); let s = cm.setup({ players: P(2 + (i % 5)), options: opts({ rounds: 1, guessMode: i % 2 ? 'firstOnly' : 'multi' }), rng: rngR, now: 1_700_000_000_000 }); for (const { p, a, now } of run.trace) { for (const q of cm.timeoutPlayers!(s)) expect(cm.validate(s, q, cm.onTimeout(s, q), { now }).ok).toBe(true); s = cm.apply(s, p, a, { rng: rngR, now }).state; } } }, 30_000); });