Files
joke-app/apps/server/src/rooms/store.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

141 lines
5.8 KiB
TypeScript

/** Room persistence (docs/04 §2, docs/08 §3). All writes are synchronous SQLite transactions. */
import type { Database } from 'bun:sqlite';
export interface RoomRow {
id: string;
code: string;
host_id: string;
game_id: string;
status: string;
visibility: string;
config_json: string;
created_at: number;
updated_at: number;
}
export interface LogEntry {
seq: number;
gameNo: number;
actorId: string | null;
kind: string;
data: unknown;
at: number;
}
export class RoomStore {
constructor(private db: Database) {}
codeInUse(code: string, now = Date.now()): boolean {
// Open rooms, plus rooms closed within the last 24h (docs/06 §3).
const row = this.db
.query<{ n: number }, [string, number]>(
"SELECT COUNT(*) AS n FROM rooms WHERE code = ? AND (status <> 'closed' OR closed_at > ?)",
)
.get(code, now - 24 * 3600_000);
return (row?.n ?? 0) > 0;
}
insertRoom(r: { id: string; code: string; hostId: string; gameId: string; status: string; visibility: string; config: unknown; stateJson: string; now: number }): void {
this.db.transaction(() => {
this.db
.query('INSERT INTO rooms (id, code, host_id, game_id, status, visibility, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
.run(r.id, r.code, r.hostId, r.gameId, r.status, r.visibility, JSON.stringify(r.config), r.now, r.now);
this.db.query('INSERT INTO room_state (room_id, seq, state_json, updated_at) VALUES (?, 0, ?, ?)').run(r.id, r.stateJson, r.now);
})();
}
/** 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 | LogEntry[] | null,
now = Date.now(),
): void {
this.db.transaction(() => {
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, 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
.query('UPDATE rooms SET host_id = ?, game_id = ?, status = ?, visibility = ?, config_json = ?, updated_at = ? WHERE id = ?')
.run(meta.hostId, meta.gameId, meta.status, meta.visibility, JSON.stringify(meta.config), now, roomId);
})();
}
close(roomId: string, now = Date.now()): void {
this.db.transaction(() => {
this.db.query("UPDATE rooms SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?").run(now, now, roomId);
this.db.query('DELETE FROM room_state WHERE room_id = ?').run(roomId);
})();
}
markBroken(roomId: string, now = Date.now()): void {
this.db.query("UPDATE rooms SET status = 'broken', updated_at = ? WHERE id = ?").run(now, roomId);
}
openRooms(): { row: RoomRow; seq: number; stateJson: string }[] {
return this.db
.query<RoomRow & { seq: number; state_json: string }, []>(
"SELECT r.*, s.seq, s.state_json FROM rooms r JOIN room_state s ON s.room_id = r.id WHERE r.status IN ('lobby','playing','finished','paused')",
)
.all()
.map((r) => ({ row: r, seq: r.seq, stateJson: r.state_json }));
}
insertGame(g: { id: string; roomId: string; gameNo: number; gameId: string; options: unknown; seedHash: string; startedAt: number }): void {
this.db
.query('INSERT INTO games (id, room_id, game_no, game_id, options_json, seed_hash, started_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(g.id, g.roomId, g.gameNo, g.gameId, JSON.stringify(g.options), g.seedHash, g.startedAt);
}
finishGame(g: {
id: string;
gameId: string;
seed: string;
endedAt: number;
endReason: string;
result: unknown;
players: { userId: string; seat: number; rank: number | null; score: number | null }[];
}): void {
this.db.transaction(() => {
this.db
.query('UPDATE games SET seed = ?, ended_at = ?, end_reason = ?, result_json = ? WHERE id = ?')
.run(g.seed, g.endedAt, g.endReason, JSON.stringify(g.result), g.id);
const counted = g.endReason !== 'void';
const ranks = g.players.map((p) => p.rank).filter((r): r is number => r !== null);
const firstCount = ranks.filter((r) => r === 1).length;
for (const p of g.players) {
this.db
.query('INSERT OR REPLACE INTO game_players (game_pk, user_id, seat, rank, score) VALUES (?, ?, ?, ?, ?)')
.run(g.id, p.userId, p.seat, p.rank, p.score);
if (!counted) continue;
const draw = p.rank === 1 && firstCount === g.players.length && g.players.length > 1;
const win = p.rank === 1 && !draw;
this.db
.query(
`INSERT INTO user_stats (user_id, game_id, played, wins, draws) VALUES (?, ?, 1, ?, ?)
ON CONFLICT(user_id, game_id) DO UPDATE SET played = played + 1, wins = wins + excluded.wins, draws = draws + excluded.draws`,
)
.run(p.userId, g.gameId, win ? 1 : 0, draw ? 1 : 0);
}
})();
}
log(roomId: string): LogEntry[] {
return this.db
.query<{ seq: number; game_no: number; actor_id: string | null; kind: string; data_json: string; at: number }, [string]>(
'SELECT seq, game_no, actor_id, kind, data_json, at FROM room_log WHERE room_id = ? ORDER BY seq',
)
.all(roomId)
.map((r) => ({ seq: r.seq, gameNo: r.game_no, actorId: r.actor_id, kind: r.kind, data: JSON.parse(r.data_json), at: r.at }));
}
purgeOldLogs(now = Date.now()): void {
this.db.query('DELETE FROM room_log WHERE at < ?').run(now - 90 * 24 * 3600_000);
}
}