- 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>
790 lines
30 KiB
TypeScript
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;
|
|
},
|
|
};
|