Files
joke-app/packages/games/src/catchmind/catchmind.test.ts
EJClaw 16d3c86486 M8: 그림 맞히기 규칙·화면·테스트
- packages/games/src/catchmind: 규칙 모듈(출제 순서·단어 풀 RNG, 고르기/그리기/공개 단계,
  정답 정규화(NFKC·제로폭·끝말·초성 차단·정답 포함 차단·자모 거리 "아까워요"), multi/firstOnly 점수,
  힌트 50%/75%, 신고 과반, 출제자 연결 끊김 10초, 자리 비움 건너뛰기와 "돌아왔어요", 결과 순위),
  단어 740개(words.ko.json, 10개 분야·쉬움/보통, 어린이 안전), 테스트 34개(§10 전부 + 무작위 1,000판)
- 그림 스트리밍: draw 액션은 canvasOp 이벤트만 'stream' 메시지로 중계하고 view에는 개수만,
  (재)접속·sync 때만 syncView로 전체 그림 기록 전송. 스트림 액션 로그는 묶어서(최대 1초) 저장
- 엔진/서버 최소 확장(하위 호환): validate(..., ctx?: {now}), isStreamAction, syncView,
  disconnectGraceMs 선택 훅, ServerMessage 'stream', RoomStore.save 로그 배열 허용
- apps/web/src/games/catchmind: 캔버스 그리기(포인터·coalesced 이벤트, 색 12·굵기 4·지우개·되돌리기·
  전체 지우기), 게이트웨이 한도 아래로 맞춘 배치 전송, 중간점 곡선 렌더링, 힌트 줄·시간 막대·점수판,
  정답/채팅 입력과 "아까워요"·"정답!" 표시, 단어 고르기, 그림 PNG 저장, 옵션·규칙
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘), 방 단위 테스트 4개, E2E 계획(PC+폰 실제 그리기·맞히기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:38:30 +09:00

670 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.
/** 그림 맞히기 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<CatchmindOptions> = {}) => catchmindOptionsSchema.parse(o);
function make(n = 4, o: Partial<CatchmindOptions> = {}, 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);
});