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>
This commit is contained in:
669
packages/games/src/catchmind/catchmind.test.ts
Normal file
669
packages/games/src/catchmind/catchmind.test.ts
Normal file
@@ -0,0 +1,669 @@
|
||||
/** 그림 맞히기 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);
|
||||
});
|
||||
Reference in New Issue
Block a user