Merge branch 'm8/catchmind' into codex/owner/grp_discord_joke-app
# Conflicts: # apps/web/src/games/registry.ts # apps/web/src/lib/catalog-ui.ts # e2e/games.e2e.ts # packages/games/src/index.ts # packages/shared/src/catalog.ts
This commit is contained in:
191
apps/server/src/rooms/catchmind.room.test.ts
Normal file
191
apps/server/src/rooms/catchmind.room.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/** 그림 맞히기 through a real Room: stroke streaming, resync on reconnect, drawer disconnect, rate limit (docs/games/catchmind.md §5.5, §8.5, §10). */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { ServerMessage } from '@bg/shared';
|
||||
import { GAMES } from '@bg/games';
|
||||
import type { CanvasOp, CatchmindState, CatchmindView } from '@bg/games/catchmind';
|
||||
import { openDb } from '../db';
|
||||
import { Room, type Conn, type RoomConfig } from './room';
|
||||
import { RoomStore } from './store';
|
||||
|
||||
type Msgs = ServerMessage[];
|
||||
type StateMsg = Extract<ServerMessage, { t: 'state' }>;
|
||||
|
||||
function harness(players: string[]) {
|
||||
const db = openDb(':memory:');
|
||||
const store = new RoomStore(db);
|
||||
let now = 1_000_000;
|
||||
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
|
||||
const games = { catchmind: GAMES.catchmind! };
|
||||
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
|
||||
const config: RoomConfig = {
|
||||
gameId: 'catchmind',
|
||||
options: games.catchmind.defaultOptions,
|
||||
maxPlayers: players.length,
|
||||
visibility: 'private',
|
||||
allowSpectators: true,
|
||||
chatEnabled: true,
|
||||
chatFilter: true,
|
||||
graceSec: 60,
|
||||
};
|
||||
const room = new Room('room1', '482913', config, players[0]!, deps);
|
||||
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'catchmind', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
|
||||
const conn = (p: string) => {
|
||||
const msgs: Msgs = [];
|
||||
const c: Conn & { msgs: Msgs } = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
|
||||
return c;
|
||||
};
|
||||
const conns: Record<string, Conn & { msgs: Msgs }> = {};
|
||||
for (const p of players) {
|
||||
conns[p] = conn(p);
|
||||
room.join(conns[p], 'player');
|
||||
}
|
||||
expect(room.start(players[0]!)).toBeNull();
|
||||
room.clearTimer();
|
||||
const state = () => room.game!.state as CatchmindState;
|
||||
let cs = 100;
|
||||
const act = (p: string, a: unknown) => {
|
||||
room.act(p, ++cs, a);
|
||||
room.clearTimer();
|
||||
return cs;
|
||||
};
|
||||
return { room, db, clock, conns, conn, state, act };
|
||||
}
|
||||
|
||||
const drawOps = (stroke: number): CanvasOp[] => [
|
||||
{ k: 'b', s: stroke, c: 3, w: 1, e: 0 },
|
||||
{ k: 'p', s: stroke, q: 0, d: [100, 100, 10, 0, 10, 5] },
|
||||
{ k: 'p', s: stroke, q: 1, d: [130, 120, 4, 4] },
|
||||
{ k: 'e', s: stroke },
|
||||
];
|
||||
const lastState = (m: Msgs) => m.filter((x): x is StateMsg => x.t === 'state').at(-1)!;
|
||||
const pointCount = (ops: CanvasOp[]) => ops.reduce((n, o) => n + (o.k === 'p' ? o.d.length / 2 : 0), 0);
|
||||
|
||||
describe('그림 맞히기 room flow', () => {
|
||||
test('그림은 stream 메시지로만 중계(전체 view 재전송 없음), 재접속한 사람은 그림 기록 전체를 받음', () => {
|
||||
const { room, db, clock, conns, conn, state, act } = harness(['A', 'B', 'C']);
|
||||
const d = state().turn.drawer;
|
||||
const [g1, g2] = state().order.filter((p) => p !== d) as [string, string];
|
||||
act(d, { type: 'chooseWord', index: 0 });
|
||||
expect(state().turn.phase).toBe('drawing');
|
||||
const before = conns[g1]!.msgs.length;
|
||||
const ops = [...drawOps(1), ...drawOps(2)];
|
||||
for (const op of ops) {
|
||||
clock.advance(50);
|
||||
act(d, { type: 'draw', op });
|
||||
}
|
||||
const got = conns[g1]!.msgs.slice(before);
|
||||
expect(got.every((m) => m.t === 'stream')).toBe(true);
|
||||
expect(got).toHaveLength(ops.length);
|
||||
const relayed = got.flatMap((m) => (m.t === 'stream' ? m.events : [])) as { type: string; n: number; op: CanvasOp }[];
|
||||
expect(relayed.map((e) => e.n)).toEqual(ops.map((_, i) => i));
|
||||
expect(relayed.map((e) => e.op)).toEqual(ops);
|
||||
// The drawer's echo carries its cs (ack).
|
||||
expect(conns[d]!.msgs.at(-1)).toMatchObject({ t: 'stream', cs: expect.any(Number) });
|
||||
// Regular views carry only the count.
|
||||
act(g1, { type: 'guess', text: '모르겠어요' });
|
||||
const v = lastState(conns[g2]!.msgs).view as CatchmindView;
|
||||
expect(v.canvas).toBeNull();
|
||||
expect(v.canvasCount).toBe(ops.length);
|
||||
|
||||
// g2 drops, more strokes are drawn, g2 comes back → the join view has the whole drawing.
|
||||
room.disconnect(conns[g2]!);
|
||||
room.clearTimer();
|
||||
for (const op of drawOps(3)) {
|
||||
clock.advance(50);
|
||||
act(d, { type: 'draw', op });
|
||||
}
|
||||
const back = conn(g2);
|
||||
room.join(back, 'player');
|
||||
room.clearTimer();
|
||||
const sv = lastState(back.msgs).view as CatchmindView;
|
||||
expect(sv.canvas).toEqual(state().turn.canvas);
|
||||
expect(sv.canvas!.filter((o) => o.k === 'b')).toHaveLength(3);
|
||||
expect(pointCount(sv.canvas!)).toBe(pointCount(state().turn.canvas));
|
||||
expect(sv.canvasCount).toBe(12);
|
||||
// Explicit resync too (client gap detection).
|
||||
room.sendSync(back);
|
||||
expect((lastState(back.msgs).view as CatchmindView).canvas).toHaveLength(12);
|
||||
|
||||
// Streamed actions are logged in batches with the next snapshot.
|
||||
const drawLogs = () => db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM room_log WHERE data_json LIKE '%\"draw\"%'").get()!.n;
|
||||
expect(drawLogs()).toBe(12); // the join wrote the pending ones
|
||||
clock.advance(50);
|
||||
act(d, { type: 'draw', op: { k: 'b', s: 4, c: 0, w: 0, e: 0 } });
|
||||
expect(drawLogs()).toBe(12); // not yet written
|
||||
room.flushStream();
|
||||
expect(drawLogs()).toBe(13);
|
||||
const seqs = db.query<{ seq: number }, []>('SELECT seq FROM room_log ORDER BY seq').all().map((r) => r.seq);
|
||||
expect(new Set(seqs).size).toBe(seqs.length);
|
||||
const snap = db.query<{ state_json: string }, []>('SELECT state_json FROM room_state').get()!;
|
||||
expect(JSON.parse(snap.state_json).game.state.turn.canvas).toHaveLength(13);
|
||||
});
|
||||
|
||||
test('초당 31번째 그림 메시지는 거절, 출제자가 아니면 거절', () => {
|
||||
const { clock, conns, state, act } = harness(['A', 'B']);
|
||||
const d = state().turn.drawer;
|
||||
const g = state().order.find((p) => p !== d)!;
|
||||
act(d, { type: 'chooseWord', index: 1 });
|
||||
const cs = act(g, { type: 'draw', op: { k: 'b', s: 1, c: 0, w: 0, e: 0 } });
|
||||
expect(conns[g]!.msgs.at(-1)).toEqual({ t: 'reject', cs, reason: '그림은 출제자만 그릴 수 있어요.' });
|
||||
for (let s = 1; s <= 30; s++) {
|
||||
clock.advance(10);
|
||||
act(d, { type: 'draw', op: { k: 'b', s, c: 0, w: 0, e: 0 } });
|
||||
}
|
||||
expect(state().turn.canvas).toHaveLength(30);
|
||||
const c31 = act(d, { type: 'draw', op: { k: 'b', s: 31, c: 0, w: 0, e: 0 } });
|
||||
expect(conns[d]!.msgs.at(-1)).toMatchObject({ t: 'reject', cs: c31, reason: expect.stringContaining('너무 빨리') });
|
||||
clock.advance(1000);
|
||||
act(d, { type: 'draw', op: { k: 'b', s: 31, c: 0, w: 0, e: 0 } });
|
||||
expect(state().turn.canvas).toHaveLength(31);
|
||||
});
|
||||
|
||||
test('출제자 연결 끊김: 고르기 중이면 바로 자동 선택, 그리기 10초 뒤 턴 종료·정답 공개·다음 출제자', () => {
|
||||
const { room, clock, conns, state } = harness(['A', 'B', 'C']);
|
||||
const d = state().turn.drawer;
|
||||
const g = state().order.find((p) => p !== d)!;
|
||||
room.disconnect(conns[d]!);
|
||||
room.clearTimer();
|
||||
expect(room.effectiveDeadline()).toBe(clock.now());
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().turn.phase).toBe('drawing');
|
||||
const word = state().turn.word!.word;
|
||||
expect(room.effectiveDeadline()).toBe(clock.now() + 10_000);
|
||||
clock.advance(9_999);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().turn.phase).toBe('drawing');
|
||||
clock.advance(1);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().turn.phase).toBe('reveal');
|
||||
expect(state().turn.endReason).toBe('drawerAway');
|
||||
expect(state().scores[d]).toBe(0);
|
||||
expect((lastState(conns[g]!.msgs).view as CatchmindView).word).toBe(word);
|
||||
clock.advance(5_000);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().turn.phase).toBe('choosing');
|
||||
expect(state().turn.drawer).not.toBe(d);
|
||||
expect(state().away).toEqual([d]);
|
||||
});
|
||||
|
||||
test('연결된 출제자는 힌트·시간 만료 때만 tick(10초 지나도 끝나지 않음)', () => {
|
||||
const { room, clock, state, act } = harness(['A', 'B']);
|
||||
const d = state().turn.drawer;
|
||||
act(d, { type: 'chooseWord', index: 0 });
|
||||
const t0 = state().turn.startedAt;
|
||||
const ends = state().turn.endsAt;
|
||||
expect(room.effectiveDeadline()).toBe(t0 + (ends - t0) / 2);
|
||||
clock.advance(20_000);
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
expect(state().turn.phase).toBe('drawing');
|
||||
clock.advance(ends - t0);
|
||||
for (let i = 0; i < 3 && state().turn.phase === 'drawing'; i++) {
|
||||
room.onTimer();
|
||||
room.clearTimer();
|
||||
}
|
||||
expect(state().turn.endReason).toBe('time');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -44,20 +44,20 @@ export class RoomStore {
|
||||
})();
|
||||
}
|
||||
|
||||
/** Writes the log entry (optional), latest room snapshot and room metadata in one transaction. */
|
||||
/** Writes the log entries (optional), latest room snapshot and room metadata in one transaction. */
|
||||
save(
|
||||
roomId: string,
|
||||
seq: number,
|
||||
stateJson: string,
|
||||
meta: { hostId: string; gameId: string; status: string; visibility: string; config: unknown },
|
||||
log: LogEntry | null,
|
||||
log: LogEntry | LogEntry[] | null,
|
||||
now = Date.now(),
|
||||
): void {
|
||||
this.db.transaction(() => {
|
||||
if (log) {
|
||||
for (const e of log === null ? [] : Array.isArray(log) ? log : [log]) {
|
||||
this.db
|
||||
.query('INSERT INTO room_log (room_id, seq, game_no, actor_id, kind, data_json, at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(roomId, log.seq, log.gameNo, log.actorId, log.kind, JSON.stringify(log.data ?? null), log.at);
|
||||
.run(roomId, e.seq, e.gameNo, e.actorId, e.kind, JSON.stringify(e.data ?? null), e.at);
|
||||
}
|
||||
this.db.query('UPDATE room_state SET seq = ?, state_json = ?, updated_at = ? WHERE room_id = ?').run(seq, stateJson, now, roomId);
|
||||
this.db
|
||||
|
||||
Reference in New Issue
Block a user