- 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>
141 lines
5.8 KiB
TypeScript
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);
|
|
}
|
|
}
|