/** 그림 맞히기 (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): 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 = { id: 'catchmind', nameKo: '그림 맞히기', minPlayers: 2, maxPlayers: 10, stateVersion: 1, defaultOptions: catchmindOptionsSchema.parse({}), optionsSchema: catchmindOptionsSchema as unknown as GameDefinition['optionsSchema'], actionSchema: catchmindActionSchema as unknown as GameDefinition['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; 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(); 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; }, };