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:
@@ -6,7 +6,7 @@ import { ulid } from 'ulid';
|
||||
import { SeededRng, createSeed, seedToHex, type AnyGameDefinition, type GameResult, type RngState } from '@bg/engine';
|
||||
import { maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared';
|
||||
import { sha256Hex } from '../auth/sessions';
|
||||
import type { RoomStore } from './store';
|
||||
import type { LogEntry, RoomStore } from './store';
|
||||
import { applyLocks, type EffectiveGame, type SiteSettings } from '../admin/settings';
|
||||
|
||||
export interface Conn {
|
||||
@@ -83,6 +83,8 @@ export interface RoomDeps {
|
||||
|
||||
const CHAT_HISTORY = 30;
|
||||
const ERROR_LIMIT = 3;
|
||||
/** Streaming actions (drawing) are logged in batches: at most this long between snapshot writes. */
|
||||
const STREAM_FLUSH_MS = 1000;
|
||||
|
||||
export class Room {
|
||||
status: 'lobby' | 'playing' | 'finished' | 'paused' | 'broken' = 'lobby';
|
||||
@@ -104,6 +106,9 @@ export class Room {
|
||||
chatLog: Extract<ServerMessage, { t: 'chat' }>[] = [];
|
||||
lastActiveAt: number;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Log entries of streaming actions not yet written (flushed with the next snapshot). */
|
||||
private pendingLogs: LogEntry[] = [];
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private recentCs = new Map<string, number[]>();
|
||||
private lastChatAt = new Map<string, number>();
|
||||
private consecutiveErrors = 0;
|
||||
@@ -217,17 +222,39 @@ export class Room {
|
||||
|
||||
private persist(log: { actorId: string | null; kind: string; data: unknown } | null): void {
|
||||
const nextSeq = this.seq + 1;
|
||||
const entry = log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null;
|
||||
const logs = this.pendingLogs.length ? [...this.pendingLogs, ...(entry ? [entry] : [])] : entry;
|
||||
this.deps.store.save(
|
||||
this.id,
|
||||
nextSeq,
|
||||
JSON.stringify(this.snapshot()),
|
||||
{ hostId: this.hostId, gameId: this.config.gameId, status: this.status, visibility: this.config.visibility, config: this.config },
|
||||
log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null,
|
||||
logs,
|
||||
);
|
||||
this.pendingLogs = [];
|
||||
if (this.flushTimer) clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
this.seq = nextSeq;
|
||||
this.lastActiveAt = this.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming actions (e.g. 그림 맞히기 strokes, ~20/s) get a seq and a log entry right away, but the snapshot
|
||||
* is written at most every STREAM_FLUSH_MS (or with the next regular persist) instead of once per message.
|
||||
*/
|
||||
private persistStream(log: { actorId: string | null; kind: string; data: unknown }): void {
|
||||
this.seq += 1;
|
||||
this.pendingLogs.push({ seq: this.seq, gameNo: this.gameNo, at: this.now(), ...log });
|
||||
this.lastActiveAt = this.now();
|
||||
this.flushTimer ??= setTimeout(() => this.flushStream(), STREAM_FLUSH_MS);
|
||||
}
|
||||
|
||||
/** Writes pending streamed log entries and the snapshot. Public for tests and shutdown. */
|
||||
flushStream(): void {
|
||||
this.flushTimer = null;
|
||||
if (this.pendingLogs.length) this.persist(null);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- views
|
||||
|
||||
roomView(): RoomView {
|
||||
@@ -264,13 +291,14 @@ export class Room {
|
||||
};
|
||||
}
|
||||
|
||||
private stateMessage(viewer: string, cs?: number): ServerMessage | null {
|
||||
/** `sync`: full view for (re)join / resync (`syncView`, e.g. the whole drawing). */
|
||||
private stateMessage(viewer: string, cs?: number, sync = false): ServerMessage | null {
|
||||
if (!this.game) return null;
|
||||
const def = this.def;
|
||||
const asPlayer = this.game.players.includes(viewer) ? viewer : null;
|
||||
let view: unknown;
|
||||
try {
|
||||
view = def.view(this.game.state, asPlayer);
|
||||
view = sync && def.syncView ? def.syncView(this.game.state, asPlayer) : def.view(this.game.state, asPlayer);
|
||||
} catch (err) {
|
||||
this.deps.log?.('error', 'view failed', { room: this.code, viewer, err: String(err) });
|
||||
return { t: 'error', code: 'view-failed', message: '화면을 그리는 중 오류가 발생했어요.' };
|
||||
@@ -345,7 +373,7 @@ export class Room {
|
||||
}
|
||||
this.persist(wasSeated ? null : { actorId: u, kind: 'join', data: { as } });
|
||||
this.broadcastRoom();
|
||||
const st = this.stateMessage(u);
|
||||
const st = this.stateMessage(u, undefined, true);
|
||||
if (st) conn.send(st);
|
||||
if (this.status === 'finished' && this.lastResult) conn.send({ t: 'result', seq: this.seq, result: this.lastResult });
|
||||
for (const m of this.chatLog) conn.send(m);
|
||||
@@ -670,10 +698,11 @@ export class Room {
|
||||
const def = this.def;
|
||||
let next: { state: unknown; events: unknown[] };
|
||||
const rng = new SeededRng(g.rng);
|
||||
const now = this.now();
|
||||
try {
|
||||
const v = def.validate(g.state, userId, action);
|
||||
const v = def.validate(g.state, userId, action, { now });
|
||||
if (!v.ok) return v.reason;
|
||||
next = def.apply(g.state, userId, action, { rng, now: this.now() });
|
||||
next = def.apply(g.state, userId, action, { rng, now });
|
||||
} catch (err) {
|
||||
this.consecutiveErrors++;
|
||||
this.deps.log?.('error', 'game apply failed', { room: this.code, game: def.id, seq: this.seq, action, err: String(err), stack: (err as Error)?.stack });
|
||||
@@ -690,8 +719,15 @@ export class Room {
|
||||
g.state = next.state;
|
||||
g.rng = rng.state();
|
||||
g.deadlineFloor = null;
|
||||
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action });
|
||||
this.broadcastState(next.events, userId, cs);
|
||||
if (!system && def.isStreamAction?.(action)) {
|
||||
// Relay the (public) events only; views are not recomputed per stroke batch (docs/games/catchmind.md §5.5).
|
||||
this.persistStream({ actorId: userId, kind: 'act', data: action });
|
||||
const msg: ServerMessage = { t: 'stream', seq: this.seq, events: next.events };
|
||||
for (const c of this.conns.values()) c.send(c.userId === userId && cs !== undefined ? { ...msg, cs } : msg);
|
||||
} else {
|
||||
this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action });
|
||||
this.broadcastState(next.events, userId, cs);
|
||||
}
|
||||
const result = def.result(g.state);
|
||||
if (result) this.finishGame(result);
|
||||
else this.scheduleTimer();
|
||||
@@ -768,12 +804,13 @@ export class Room {
|
||||
const base = def.deadline?.(g.state) ?? null;
|
||||
const notBefore = def.timeoutNotBefore?.(g.state) ?? null;
|
||||
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
|
||||
const graceMs = def.disconnectGraceMs?.(g.state) ?? this.config.graceSec * 1000;
|
||||
let best: number | null = null;
|
||||
for (const u of timed) {
|
||||
let d = base;
|
||||
const p = this.presence.get(u);
|
||||
if (p && !p.connected) {
|
||||
const grace = p.since + this.config.graceSec * 1000;
|
||||
const grace = p.since + graceMs;
|
||||
d = d === null ? grace : Math.min(d, grace);
|
||||
}
|
||||
if (d === null) continue;
|
||||
@@ -807,11 +844,12 @@ export class Room {
|
||||
const base = def.deadline?.(g.state) ?? null;
|
||||
const notBefore = def.timeoutNotBefore?.(g.state) ?? null;
|
||||
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
|
||||
const graceMs = def.disconnectGraceMs?.(g.state) ?? this.config.graceSec * 1000;
|
||||
for (const u of timed) {
|
||||
let d = base;
|
||||
const p = this.presence.get(u);
|
||||
if (p && !p.connected) {
|
||||
const grace = p.since + this.config.graceSec * 1000;
|
||||
const grace = p.since + graceMs;
|
||||
d = d === null ? grace : Math.min(d, grace);
|
||||
}
|
||||
if (g.deadlineFloor !== null && d !== null) d = Math.max(d, g.deadlineFloor);
|
||||
@@ -833,7 +871,7 @@ export class Room {
|
||||
|
||||
sendSync(conn: Conn): void {
|
||||
conn.send({ t: 'room', seq: this.seq, room: this.roomView() });
|
||||
const st = this.stateMessage(conn.userId);
|
||||
const st = this.stateMessage(conn.userId, undefined, true);
|
||||
if (st) conn.send(st);
|
||||
}
|
||||
|
||||
@@ -852,6 +890,7 @@ export class Room {
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
this.flushStream();
|
||||
this.clearTimer();
|
||||
for (const c of this.conns.values()) {
|
||||
c.send({ t: 'bye', reason: 'restart' });
|
||||
|
||||
Reference in New Issue
Block a user