Files
joke-app/packages/games/src/catchmind/index.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

790 lines
30 KiB
TypeScript

/** 그림 맞히기 (docs/games/catchmind.md). Pure and deterministic; state is JSON; time only via ctx.now. */
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
import WORDS from './words.ko.json';
import { cleanText, initialOf, judge, maskBanned, normalize, parseCustomWords } from './text';
import {
BOARD,
CATEGORIES,
COLOR_COUNT,
DRAW_MSGS_PER_SEC,
GUESS_MSGS_PER_SEC,
MAX_BATCH_POINTS,
MAX_TURN_OPS,
MAX_TURN_POINTS,
WIDTHS,
catchmindActionSchema,
catchmindOptionsSchema,
type CanvasOp,
type CatchmindAction,
type CatchmindFinal,
type CatchmindOptions,
type CatchmindState,
type CatchmindView,
type ChatEntry,
type ChatView,
type RankRow,
type Turn,
type TurnEndReason,
type WordEntry,
} from './types';
export * from './types';
export { judge, normalize, chosung, jamo, levenshtein, parseCustomWords, isCompatJamoOnly } from './text';
type S = CatchmindState;
type A = CatchmindAction;
export const CHOOSE_MS = 10_000;
export const REVEAL_MS = 5_000;
/** §5.2 / §8.5: a disconnected drawer ends the turn after this long. */
export const DRAWER_AWAY_MS = 10_000;
export const GUESS_MAX = 40;
export const CHAT_MAX = 100;
const CHAT_KEEP = 80;
const CHAT_VIEW = 50;
export const BUILTIN_WORDS = WORDS as WordEntry[];
// ---------------------------------------------------------------- word pool
/** §4.2: the words a game may use with these options (built-in filtered + host words), before shuffling. */
export function buildPool(o: CatchmindOptions): WordEntry[] {
const custom: WordEntry[] = parseCustomWords(o.customWords).map((word) => ({ word, aliases: [], category: 'custom', difficulty: 'normal' }));
if (o.customOnly) return custom;
const anyCat = CATEGORIES.some((c) => o.categories[c]);
const builtin = BUILTIN_WORDS.filter(
(w) => (!anyCat || o.categories[w.category as (typeof CATEGORIES)[number]]) && (o.difficulty === 'mixed' || w.difficulty === o.difficulty),
);
const seen = new Set(builtin.flatMap((w) => [w.word, ...w.aliases].map(normalize)));
return [...builtin, ...custom.filter((w) => !seen.has(normalize(w.word)))];
}
const turnsFor = (o: CatchmindOptions, players: number): number => o.rounds * players;
function takeWords(s: S, n: number): WordEntry[] {
const out: WordEntry[] = [];
while (out.length < n && s.wordPool.length > 0) out.push(s.wordPool.shift()!);
while (out.length < n && s.spare.length > 0) out.push(s.spare.shift()!);
if (out.length === 0) throw new Error('catchmind: word pool exhausted');
return out;
}
// ---------------------------------------------------------------- helpers
const answersOf = (w: WordEntry): string[] => [w.word, ...w.aliases];
const isSolver = (t: Turn, p: PlayerId): boolean => t.solvers.some((x) => x.id === p);
const letters = (word: string): number => [...word].filter((c) => !/\s/u.test(c)).length;
function clone(s: S): S {
const t = s.turn;
return {
...s,
scores: { ...s.scores },
correctCounts: { ...s.correctCounts },
wordPool: [...s.wordPool],
spare: [...s.spare],
turn: { ...t, choices: [...t.choices], solvers: [...t.solvers], reports: [...t.reports] },
chat: [...s.chat],
msgTimes: { ...s.msgTimes },
away: [...s.away],
left: [...s.left],
history: [...s.history],
};
}
interface Ctx {
now: number;
rng: Rng;
events: GameEvent[];
}
function addChat(s: S, e: Omit<ChatEntry, 'id' | 'turn'>): void {
s.chatSeq += 1;
s.chat.push({ id: s.chatSeq, turn: s.turn.no, ...e });
if (s.chat.length > CHAT_KEEP) s.chat.splice(0, s.chat.length - CHAT_KEEP);
}
function markAway(s: S, p: PlayerId): void {
if (!s.away.includes(p)) s.away.push(p);
}
/** Players whose guesses the drawing waits for (§5.2 multi; disconnected/away and left players excluded, §8.5). */
function waitingGuessers(s: S): PlayerId[] {
return s.order.filter((p) => p !== s.turn.drawer && !s.left.includes(p) && !s.away.includes(p));
}
export function reportsNeeded(s: S): number {
const voters = s.order.filter((p) => p !== s.turn.drawer && !s.left.includes(p)).length;
return Math.floor(voters / 2) + 1;
}
/** Next hint time while drawing (`hints: full` only): 50% → initials, 75% → one letter (skipped for ≤ 2 letters). */
export function nextHintAt(s: S): number | null {
const t = s.turn;
if (s.finished || t.phase !== 'drawing' || s.options.hints !== 'full' || !t.word) return null;
const dur = t.endsAt - t.startedAt;
if (t.hintStage === 0) return t.startedAt + Math.round(dur * 0.5);
if (t.hintStage === 1 && letters(t.word.word) > 2) return t.startedAt + Math.round(dur * 0.75);
return null;
}
function rateOk(times: readonly number[] | undefined, perSec: number, now: number | undefined): boolean {
if (now === undefined || !times || times.length < perSec) return true;
return now - times[times.length - perSec]! >= 1000;
}
function stamp(s: S, p: PlayerId, now: number): void {
s.msgTimes[p] = [...(s.msgTimes[p] ?? []).slice(-(GUESS_MSGS_PER_SEC - 1)), now];
}
// ---------------------------------------------------------------- turn flow
function newTurn(no: number, drawer: PlayerId, choices: WordEntry[], now: number): Turn {
return {
no,
phase: 'choosing',
drawer,
choices,
word: null,
startedAt: now,
endsAt: now + CHOOSE_MS,
hintStage: 0,
revealIndex: null,
solvers: [],
reports: [],
canvas: [],
totalPoints: 0,
lastStroke: 0,
lastBatch: -1,
open: false,
live: 0,
drawTimes: [],
drawerPoints: 0,
endReason: null,
};
}
function startTurn(s: S, drawer: PlayerId, ctx: Ctx): void {
const choices = takeWords(s, s.options.wordChoice ? 3 : 1);
s.turn = newTurn(s.turnsPlayed + 1, drawer, choices, ctx.now);
ctx.events.push({ type: 'turn', no: s.turn.no, drawer, round: s.round });
if (!s.options.wordChoice) beginDrawing(s, 0, ctx);
}
function beginDrawing(s: S, index: number, ctx: Ctx): void {
const t = s.turn;
t.word = t.choices[index]!;
s.spare.push(...t.choices.filter((_, i) => i !== index));
t.choices = [];
t.phase = 'drawing';
t.startedAt = ctx.now;
t.endsAt = ctx.now + s.options.drawSeconds * 1000;
t.hintStage = 0;
ctx.events.push({ type: 'drawing', no: t.no, drawer: t.drawer });
}
function advanceHint(s: S, ctx: Ctx): void {
const t = s.turn;
if (t.hintStage === 0) {
t.hintStage = 1;
} else if (t.hintStage === 1) {
const idx = [...t.word!.word].map((c, i) => (/\s/u.test(c) ? -1 : i)).filter((i) => i >= 0);
t.revealIndex = idx[ctx.rng.int(idx.length)]!;
t.hintStage = 2;
}
ctx.events.push({ type: 'hint', stage: t.hintStage });
}
/** Drawing (or choosing) is over: drawer points, reveal for 5 s (§5.1, §6). */
function endDrawing(s: S, reason: TurnEndReason, ctx: Ctx): void {
const t = s.turn;
const o = s.options;
let dp = 0;
if (reason !== 'report' && reason !== 'drawerAway' && reason !== 'drawerLeft') {
dp = o.guessMode === 'multi' ? Math.min(12, 2 * t.solvers.length) : t.solvers.length > 0 ? 5 : 0;
}
if (t.phase === 'choosing') {
// Nobody saw these candidates being drawn; keep them for later turns.
s.spare.push(...t.choices);
t.choices = [];
}
s.scores[t.drawer] = (s.scores[t.drawer] ?? 0) + dp;
t.drawerPoints = dp;
t.endReason = reason;
t.phase = 'reveal';
t.open = false;
t.startedAt = ctx.now;
t.endsAt = ctx.now + REVEAL_MS;
s.history.push({ no: t.no, drawer: t.drawer, word: t.word?.word ?? '', solvers: t.solvers.map((x) => x.id), solverPoints: t.solvers.map((x) => x.points), drawerPoints: dp, reason });
ctx.events.push({ type: 'reveal', no: t.no, word: t.word?.word ?? null, reason, drawerPoints: dp });
}
/** Next drawer clockwise after `from`, skipping players who left and (if anyone else is there) away players. */
function nextInOrder(s: S, from: PlayerId): { id: PlayerId; wrapped: boolean } | null {
const n = s.order.length;
const i = s.order.indexOf(from);
for (const skipAway of [true, false]) {
for (let k = 1; k <= n; k++) {
const q = s.order[(i + k) % n]!;
if (s.left.includes(q) || (skipAway && s.away.includes(q))) continue;
return { id: q, wrapped: i + k >= n };
}
}
return null;
}
function nextTurn(s: S, ctx: Ctx): void {
const o = s.options;
s.turnsPlayed += 1;
const remaining = s.order.filter((p) => !s.left.includes(p));
if (remaining.length < 2) return finish(s, ctx);
let next: PlayerId;
if (o.guessMode === 'firstOnly' && o.answererDraws) {
if (s.turnsPlayed >= s.totalTurns) return finish(s, ctx);
const first = s.turn.solvers[0]?.id;
next = first && !s.left.includes(first) && !s.away.includes(first) ? first : nextInOrder(s, s.turn.drawer)!.id;
s.round = Math.min(o.rounds, Math.floor(s.turnsPlayed / s.order.length) + 1);
} else {
const r = nextInOrder(s, s.turn.drawer)!;
if (r.wrapped) {
if (s.round >= o.rounds) return finish(s, ctx);
s.round += 1;
}
next = r.id;
}
startTurn(s, next, ctx);
}
function finish(s: S, ctx: Ctx): void {
if (s.finished) return;
s.finished = true;
s.turn.open = false;
const by = (a: PlayerId, b: PlayerId) => (s.scores[b] ?? 0) - (s.scores[a] ?? 0) || (s.correctCounts[b] ?? 0) - (s.correctCounts[a] ?? 0);
const stay = s.order.filter((p) => !s.left.includes(p)).sort(by);
const gone = s.order.filter((p) => s.left.includes(p)).sort(by);
const ranking: RankRow[] = [];
for (const group of [stay, gone]) {
group.forEach((id, i) => {
const prev = ranking[ranking.length - 1];
const tie = i > 0 && prev && prev.score === (s.scores[id] ?? 0) && prev.correct === (s.correctCounts[id] ?? 0);
ranking.push({ rank: tie ? prev.rank : ranking.length + 1, id, score: s.scores[id] ?? 0, correct: s.correctCounts[id] ?? 0 });
});
}
const top = ranking.filter((r) => r.rank === 1);
const most = Math.max(0, ...ranking.map((r) => r.correct));
let summary = top.length > 1 ? `공동 1등 ${top.length}명, ${top[0]!.score}점이에요!` : `1등은 ${top[0]?.score ?? 0}점이에요!`;
if (most > 0) summary += top.length === 1 && top[0]!.correct === most ? ` 1등이 가장 많이 맞혔어요(${most}번).` : ` 가장 많이 맞힌 횟수는 ${most}번이에요.`;
else summary += ' 이번 게임에서는 아무도 맞히지 못했어요.';
s.final = {
ranking,
turns: s.history.map((h) => ({ drawer: h.drawer, word: h.word, solvers: h.solvers })),
summary,
} satisfies CatchmindFinal;
ctx.events.push({ type: 'gameEnd' });
}
// ---------------------------------------------------------------- actions
function guess(s: S, p: PlayerId, raw: string, ctx: Ctx): void {
const t = s.turn;
const text = cleanText(raw);
stamp(s, p, ctx.now);
if (isSolver(t, p)) {
addChat(s, { from: p, text: maskBanned(text), at: ctx.now, audience: 'solvers', kind: 'chat' });
return;
}
const j = judge(text, answersOf(t.word!), s.options.closeHint);
if (j === 'correct') {
const k = t.solvers.length + 1;
let points = s.options.guessMode === 'multi' ? Math.max(10 - 2 * (k - 1), 4) : 10;
if (s.options.speedBonus) points += Math.round((5 * Math.max(0, t.endsAt - ctx.now)) / Math.max(1, t.endsAt - t.startedAt));
t.solvers.push({ id: p, at: ctx.now, points });
s.scores[p] = (s.scores[p] ?? 0) + points;
s.correctCounts[p] = (s.correctCounts[p] ?? 0) + 1;
addChat(s, { from: p, text: '', at: ctx.now, audience: 'all', kind: 'correct' });
ctx.events.push({ type: 'correct', player: p, points, rank: k });
if (s.options.guessMode === 'firstOnly') endDrawing(s, 'first', ctx);
else if (waitingGuessers(s).every((q) => isSolver(t, q))) endDrawing(s, 'allSolved', ctx);
return;
}
if (j === 'chosung' || j === 'contains') {
addChat(s, { from: p, text: maskBanned(text), at: ctx.now, audience: 'self', kind: 'blocked', note: j });
return;
}
addChat(s, { from: p, text: maskBanned(text), at: ctx.now, audience: 'all', kind: j === 'close' ? 'close' : 'chat' });
}
function tick(s: S, ctx: Ctx): void {
const t = s.turn;
if (t.phase === 'choosing') {
// Only a disconnected drawer is timed out before the 10 s are up (room grace hook): skip them next time.
if (ctx.now < t.endsAt) markAway(s, t.drawer);
beginDrawing(s, 0, ctx);
return;
}
if (t.phase === 'drawing') {
let hinted = false;
for (let h = nextHintAt(s); h !== null && ctx.now >= h; h = nextHintAt(s)) {
advanceHint(s, ctx);
hinted = true;
}
if (ctx.now >= t.endsAt) return endDrawing(s, 'time', ctx);
if (hinted) return;
// Nothing was due: the drawer has been disconnected for DRAWER_AWAY_MS (§5.2) → 0 points, answer revealed.
markAway(s, t.drawer);
return endDrawing(s, 'drawerAway', ctx);
}
nextTurn(s, ctx);
}
function leave(s: S, p: PlayerId, ctx: Ctx): void {
const t = s.turn;
s.left.push(p);
s.away = s.away.filter((q) => q !== p);
ctx.events.push({ type: 'left', player: p });
const remaining = s.order.filter((q) => !s.left.includes(q));
if (remaining.length < 2) {
if (t.phase !== 'reveal') endDrawing(s, p === t.drawer ? 'drawerLeft' : 'time', ctx);
return finish(s, ctx);
}
if (t.phase === 'reveal') return;
if (p === t.drawer) return endDrawing(s, 'drawerLeft', ctx);
if (t.phase === 'drawing') {
if (s.options.letterReport && t.reports.length >= reportsNeeded(s)) return endDrawing(s, 'report', ctx);
const waiting = waitingGuessers(s);
if (s.options.guessMode === 'multi' && t.solvers.length > 0 && waiting.every((q) => isSolver(t, q))) endDrawing(s, 'allSolved', ctx);
}
}
// ---------------------------------------------------------------- drawing ops
/** Absolute points of a `p` op (first absolute, then deltas), or null when malformed / off the board. */
export function decodePoints(d: readonly number[]): number[] | null {
if (d.length < 2 || d.length % 2 !== 0 || d.length > MAX_BATCH_POINTS * 2) return null;
const out: number[] = [];
let x = 0;
let y = 0;
for (let i = 0; i < d.length; i += 2) {
x = i === 0 ? d[0]! : x + d[i]!;
y = i === 0 ? d[1]! : y + d[i + 1]!;
if (x < 0 || x > BOARD || y < 0 || y > BOARD) return null;
out.push(x, y);
}
return out;
}
function validateDraw(s: S, actor: PlayerId, op: CanvasOp, now: number | undefined): Validation {
const t = s.turn;
if (actor !== t.drawer) return fail('그림은 출제자만 그릴 수 있어요.');
if (t.phase !== 'drawing') return fail('지금은 그릴 수 없어요.');
if (!rateOk(t.drawTimes, DRAW_MSGS_PER_SEC, now)) return fail('그림을 너무 빨리 보내고 있어요. 잠시 뒤에 이어서 그려 주세요.');
if (t.canvas.length >= MAX_TURN_OPS) return fail('이번 차례에 그릴 수 있는 양을 다 썼어요.');
switch (op.k) {
case 'b':
if (op.s !== t.lastStroke + 1) return fail('획 번호가 맞지 않아요.');
if (op.c < 0 || op.c >= COLOR_COUNT || op.w < 0 || op.w >= WIDTHS.length) return fail('없는 색이나 굵기예요.');
return ok;
case 'p': {
if (!t.open || op.s !== t.lastStroke) return fail('획 번호가 맞지 않아요.');
if (op.q !== t.lastBatch + 1) return fail('점 묶음 순서가 맞지 않아요.');
const pts = decodePoints(op.d);
if (!pts) return fail(op.d.length > MAX_BATCH_POINTS * 2 || op.d.length % 2 !== 0 ? '한 번에 보낼 수 있는 점 개수를 넘었어요.' : '그림판 밖의 점이에요.');
if (t.totalPoints + pts.length / 2 > MAX_TURN_POINTS) return fail('이번 차례에 그릴 수 있는 양을 다 썼어요.');
return ok;
}
case 'e':
if (!t.open || op.s !== t.lastStroke) return fail('획 번호가 맞지 않아요.');
return ok;
case 'u':
case 'x':
if (t.live <= 0) return fail('지울 그림이 없어요.');
return ok;
}
}
/** Shallow update: the canvas grows by one op; nothing else is deep-copied (§5.5 "깊은 복사하지 않는다"). */
function applyDraw(s0: S, actor: PlayerId, op: CanvasOp, now: number): { state: S; events: GameEvent[] } {
const t0 = s0.turn;
const t: Turn = { ...t0, canvas: [...t0.canvas, op], drawTimes: [...t0.drawTimes.slice(-(DRAW_MSGS_PER_SEC - 1)), now] };
switch (op.k) {
case 'b':
t.lastStroke = op.s;
t.lastBatch = -1;
t.open = true;
t.live += 1;
break;
case 'p':
t.lastBatch = op.q;
t.totalPoints += op.d.length / 2;
break;
case 'e':
t.open = false;
break;
case 'u':
t.live -= 1;
t.open = false;
break;
case 'x':
t.live = 0;
t.open = false;
break;
}
const state: S = { ...s0, turn: t, away: s0.away.includes(actor) ? s0.away.filter((p) => p !== actor) : s0.away };
return { state, events: [{ type: 'canvasOp', turn: t.no, n: t0.canvas.length, op }] };
}
// ---------------------------------------------------------------- view helpers
function hintFor(s: S): string[] | null {
const t = s.turn;
if (s.options.hints === 'none' || !t.word) return null;
const full = s.options.hints === 'full';
return [...t.word.word].map((ch, i) => {
if (/\s/u.test(ch)) return ' ';
if (full && t.hintStage >= 2 && i === t.revealIndex) return ch;
if (full && t.hintStage >= 1) {
const ini = initialOf(ch);
return ini === ch ? '○' : ini;
}
return '○';
});
}
function chatFor(s: S, me: PlayerId | null): ChatView[] {
const t = s.turn;
const insider = !!me && (me === t.drawer || isSolver(t, me));
const open = t.phase === 'reveal' || s.finished;
const out: ChatView[] = [];
for (const e of s.chat) {
if (e.audience === 'self' && e.from !== me) continue;
if (e.audience === 'solvers' && e.turn === t.no && !open && !insider) continue;
const v: ChatView = {
id: e.id,
from: e.from,
text: e.text,
at: e.at,
kind: e.kind === 'close' && e.from !== me ? 'chat' : e.kind,
solversOnly: e.audience === 'solvers',
};
if (e.note) v.note = e.note;
out.push(v);
}
return out.slice(-CHAT_VIEW);
}
function buildView(s: S, viewer: PlayerId | null, withCanvas: boolean): CatchmindView {
const t = s.turn;
const o = s.options;
const me = viewer && s.order.includes(viewer) ? viewer : null;
const isDrawer = me === t.drawer;
const solved = !!me && isSolver(t, me);
const revealed = t.phase === 'reveal' || s.finished;
const knows = isDrawer || solved || revealed;
const active = !!me && !s.left.includes(me) && !s.finished;
const drawing = t.phase === 'drawing' && !s.finished;
const { customWords, ...rest } = o;
return {
options: { ...rest, customWordCount: parseCustomWords(customWords).length },
me,
role: !me ? 'spectator' : isDrawer ? 'drawer' : solved ? 'solver' : 'guesser',
order: s.order,
scores: s.scores,
correctCounts: s.correctCounts,
round: s.round,
rounds: o.rounds,
turnNo: t.no,
turnsPlayed: s.turnsPlayed,
totalTurns: s.totalTurns,
phase: s.finished ? 'ended' : t.phase,
drawer: t.drawer,
startedAt: t.startedAt,
endsAt: t.endsAt,
phaseMs: t.endsAt - t.startedAt,
choices: isDrawer && t.phase === 'choosing' && !s.finished ? t.choices.map((w) => ({ word: w.word, category: w.category, difficulty: w.difficulty })) : null,
word: knows && t.word ? t.word.word : null,
category: knows && t.word ? t.word.category : null,
hint: hintFor(s),
hintStage: t.hintStage,
nextHintAt: nextHintAt(s),
solvers: t.solvers.map((x) => ({ id: x.id, points: x.points })),
reports: t.reports.length,
reportsNeeded: reportsNeeded(s),
reportedByMe: !!me && t.reports.includes(me),
canGuess: active && drawing && !isDrawer,
canChat: active && (t.phase === 'reveal' || (t.phase === 'choosing' && !isDrawer)),
canDraw: active && drawing && isDrawer,
canReport: active && drawing && o.letterReport && !isDrawer && !t.reports.includes(me),
canReact: active && drawing && isDrawer,
canvas: withCanvas ? t.canvas : null,
canvasCount: t.canvas.length,
nextStroke: t.lastStroke + 1,
openStroke: t.open,
lastBatch: t.lastBatch,
liveStrokes: t.live,
pointsUsed: t.totalPoints,
pointLimit: MAX_TURN_POINTS,
chat: chatFor(s, me),
away: s.away,
left: s.left,
turnPoints:
t.phase === 'reveal' || s.finished
? Object.fromEntries([...t.solvers.map((x) => [x.id, x.points] as const), [t.drawer, t.drawerPoints] as const])
: null,
drawerPoints: t.drawerPoints,
endReason: t.endReason,
history: s.history,
result: s.final,
};
}
// ---------------------------------------------------------------- definition
export const catchmind: GameDefinition<S, A, CatchmindView, CatchmindOptions> = {
id: 'catchmind',
nameKo: '그림 맞히기',
minPlayers: 2,
maxPlayers: 10,
stateVersion: 1,
defaultOptions: catchmindOptionsSchema.parse({}),
optionsSchema: catchmindOptionsSchema as unknown as GameDefinition<S, A, CatchmindView, CatchmindOptions>['optionsSchema'],
actionSchema: catchmindActionSchema as unknown as GameDefinition<S, A, CatchmindView, CatchmindOptions>['actionSchema'],
playerCountError(options, count) {
const have = buildPool(options).length;
const need = turnsFor(options, count);
if (have >= need) return null;
return options.customOnly
? `추가 단어가 부족해요. ${need}개 이상 넣어 주세요(지금 ${have}개).`
: `단어가 부족해요(${have}개). 분야나 난이도를 넓히거나 라운드를 줄여 주세요.`;
},
setup({ players, options, rng, now }) {
if (players.length < 2 || players.length > 10) throw new Error(`catchmind: ${players.length} players not allowed`);
// §4: drawing order and the word pool come from the seeded RNG.
const order = rng.shuffle(players);
const pool = rng.shuffle(buildPool(options));
if (pool.length < turnsFor(options, order.length)) throw new Error('catchmind: not enough words');
const zero = Object.fromEntries(order.map((p) => [p, 0])) as Record<PlayerId, number>;
const s: S = {
options,
order,
scores: { ...zero },
correctCounts: { ...zero },
wordPool: pool,
spare: [],
round: 1,
turnsPlayed: 0,
totalTurns: turnsFor(options, order.length),
turn: newTurn(0, order[0]!, [], now),
chat: [],
chatSeq: 0,
msgTimes: {},
away: [],
left: [],
history: [],
finished: false,
final: null,
};
startTurn(s, order[0]!, { now, rng, events: [] });
return s;
},
validate(s, actor, a, vctx) {
if (!s.order.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
if (s.finished) return fail('게임이 이미 끝났어요.');
const t = s.turn;
const now = vctx?.now;
if (a.type === 'tick') return actor === t.drawer ? ok : fail('시간 초과 대상이 아니에요.');
if (a.type === 'leave') return s.left.includes(actor) ? fail('이미 게임에서 나갔어요.') : ok;
if (s.left.includes(actor)) return fail('게임에서 나갔어요.');
switch (a.type) {
case 'chooseWord':
if (actor !== t.drawer) return fail('출제자만 단어를 고를 수 있어요.');
if (t.phase !== 'choosing') return fail('지금은 단어를 고를 때가 아니에요.');
if (a.index >= t.choices.length) return fail('없는 단어예요.');
return ok;
case 'draw':
return validateDraw(s, actor, a.op, now);
case 'guess': {
if (t.phase !== 'drawing') return fail('지금은 맞힐 때가 아니에요.');
if (actor === t.drawer) return fail('출제자는 그림만 그릴 수 있어요. 반응 버튼을 써 주세요.');
const len = [...cleanText(a.text)].length;
if (len < 1) return fail('내용을 입력해 주세요.');
if (len > GUESS_MAX) return fail(`${GUESS_MAX}자까지 쓸 수 있어요.`);
if (now !== undefined && now >= t.endsAt) return fail('시간이 다 됐어요.');
if (!rateOk(s.msgTimes[actor], GUESS_MSGS_PER_SEC, now)) return fail('조금 천천히 보내 주세요.');
return ok;
}
case 'chat': {
if (t.phase === 'drawing') return fail('그리는 동안에는 정답 입력으로 보내 주세요.');
if (t.phase === 'choosing' && actor === t.drawer) return fail('단어를 고르는 동안에는 채팅할 수 없어요.');
const len = [...cleanText(a.text)].length;
if (len < 1) return fail('내용을 입력해 주세요.');
if (len > CHAT_MAX) return fail(`${CHAT_MAX}자까지 쓸 수 있어요.`);
if (!rateOk(s.msgTimes[actor], GUESS_MSGS_PER_SEC, now)) return fail('조금 천천히 보내 주세요.');
return ok;
}
case 'reaction':
if (actor !== t.drawer || t.phase !== 'drawing') return fail('그리는 동안 출제자만 쓸 수 있어요.');
if (!rateOk(s.msgTimes[actor], GUESS_MSGS_PER_SEC, now)) return fail('조금 천천히 보내 주세요.');
return ok;
case 'back':
return s.away.includes(actor) ? ok : fail('이미 게임에 참여하고 있어요.');
case 'report':
if (!s.options.letterReport) return fail('이 방은 신고를 쓰지 않아요.');
if (t.phase !== 'drawing') return fail('그리는 동안에만 신고할 수 있어요.');
if (actor === t.drawer) return fail('출제자는 신고할 수 없어요.');
if (t.reports.includes(actor)) return fail('이미 신고했어요.');
return ok;
}
},
apply(prev, actor, a, { now, rng }) {
if (a.type === 'draw') return applyDraw(prev, actor, a.op, now);
const s = clone(prev);
const ctx: Ctx = { now, rng, events: [] };
if (a.type !== 'tick' && a.type !== 'leave' && s.away.includes(actor)) s.away = s.away.filter((p) => p !== actor);
switch (a.type) {
case 'tick':
tick(s, ctx);
break;
case 'leave':
leave(s, actor, ctx);
break;
case 'chooseWord':
beginDrawing(s, a.index, ctx);
break;
case 'guess':
guess(s, actor, a.text, ctx);
break;
case 'chat':
stamp(s, actor, now);
addChat(s, { from: actor, text: maskBanned(cleanText(a.text)), at: now, audience: 'all', kind: 'chat' });
break;
case 'reaction':
stamp(s, actor, now);
addChat(s, { from: actor, text: a.code, at: now, audience: 'all', kind: 'reaction' });
break;
case 'back':
ctx.events.push({ type: 'back', player: actor });
break;
case 'report':
s.turn.reports.push(actor);
ctx.events.push({ type: 'report', count: s.turn.reports.length, need: reportsNeeded(s) });
if (s.turn.reports.length >= reportsNeeded(s)) endDrawing(s, 'report', ctx);
break;
}
return { state: s, events: ctx.events };
},
view(s, viewer) {
return buildView(s, viewer, false);
},
/** (Re)joining or resyncing clients get the whole drawing once (§5.5). */
syncView(s, viewer) {
return buildView(s, viewer, true);
},
isStreamAction(a) {
return a.type === 'draw';
},
activePlayers(s) {
if (s.finished) return [];
const t = s.turn;
if (t.phase !== 'drawing') return [t.drawer];
return [t.drawer, ...s.order.filter((p) => p !== t.drawer && !s.left.includes(p) && !isSolver(t, p))];
},
deadline(s) {
if (s.finished) return null;
const h = nextHintAt(s);
return h === null ? s.turn.endsAt : Math.min(h, s.turn.endsAt);
},
/** The drawer's clock drives every server tick (choosing, hints, time up, reveal). */
timeoutPlayers(s) {
return s.finished ? [] : [s.turn.drawer];
},
/** A drawer who was already away when drawing began still gets DRAWER_AWAY_MS; reveal always lasts its 5 s. */
timeoutNotBefore(s) {
if (s.finished) return null;
const t = s.turn;
if (t.phase === 'drawing') return t.startedAt + DRAWER_AWAY_MS;
if (t.phase === 'reveal') return t.endsAt;
return null;
},
/** §8.5: disconnected drawer → pick at once while choosing, end the turn after 10 s while drawing. */
disconnectGraceMs(s) {
if (s.finished) return null;
if (s.turn.phase === 'choosing') return 0;
if (s.turn.phase === 'drawing') return DRAWER_AWAY_MS;
return null;
},
onTimeout() {
return { type: 'tick' };
},
onLeave(s, player) {
if (s.finished || s.left.includes(player)) return null;
return { type: 'leave' };
},
result(s): GameResult | null {
const f = s.final;
if (!f) return null;
const groups = new Map<number, PlayerId[]>();
for (const r of f.ranking) groups.set(r.rank, [...(groups.get(r.rank) ?? []), r.id]);
return {
ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids),
scores: { ...s.scores },
summary: f.summary,
reason: 'normal',
};
},
legalActions(s, p) {
if (s.finished || !s.order.includes(p)) return [];
const t = s.turn;
const out: A[] = [];
if (p === t.drawer) out.push({ type: 'tick' });
if (s.away.includes(p)) out.push({ type: 'back' });
if (t.phase === 'choosing') {
if (p === t.drawer) t.choices.forEach((_, index) => out.push({ type: 'chooseWord', index }));
else out.push({ type: 'chat', text: '안녕하세요' });
} else if (t.phase === 'drawing') {
if (p === t.drawer) {
if (!t.open) out.push({ type: 'draw', op: { k: 'b', s: t.lastStroke + 1, c: t.lastStroke % COLOR_COUNT, w: t.lastStroke % WIDTHS.length, e: t.lastStroke % 5 === 4 ? 1 : 0 } });
else {
const x = 100 + ((t.lastBatch * 37 + t.lastStroke * 11) % 800);
out.push({ type: 'draw', op: { k: 'p', s: t.lastStroke, q: t.lastBatch + 1, d: [x, 900 - (x % 700), 3, -4, 5, 2, -1, 6] } });
out.push({ type: 'draw', op: { k: 'e', s: t.lastStroke } });
}
if (t.live > 0) out.push({ type: 'draw', op: { k: 'u' } }, { type: 'draw', op: { k: 'x' } });
out.push({ type: 'reaction', code: 'almost' });
} else {
out.push({ type: 'guess', text: t.word!.word }, { type: 'guess', text: '모르겠어요' }, { type: 'guess', text: `${t.word!.word} 맞나` }, { type: 'report' });
}
} else {
out.push({ type: 'chat', text: '잘 그렸어요' });
}
return out.filter((a) => catchmind.validate(s, p, a).ok);
},
/**
* Leak-check secrets (§8.3): upcoming pool words are nobody's to see; the drawer's candidates and — until someone
* else legitimately knows it — the answer belong to the drawer.
*/
secretsOf(s, player) {
const t = s.turn;
const out = s.wordPool.slice(0, 8).map((w) => w.word);
if (player === t.drawer && t.phase === 'choosing') out.push(...t.choices.map((w) => w.word));
if (player === t.drawer && t.phase === 'drawing' && t.solvers.length === 0 && t.word) out.push(...answersOf(t.word));
return out;
},
};