Files
joke-app/apps/server/src/rooms/store.ts
EJClaw 6ee8da18f1 M1 기반: 모노레포, 공통 엔진, 공용 프로토콜, 오목 엔진, 서버 골격
- packages/engine: 시드 RNG(sfc32), GameDefinition 타입, 무작위 대국·정보 유출 테스트 도구
- packages/shared: WS 프로토콜 스키마, 닉네임·방 코드 규칙, 게임 카탈로그
- packages/games/omok: 한국식·렌주·자유룰, 재귀 금수 판정, 무르기·무승부·시계 (테스트 27개)
- apps/server: SQLite 스키마, 게스트·디스코드 로그인, 세션, 방 관리(저장·복구·타이머·재접속), WS 게이트웨이, HTTP API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:18:29 +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 entry (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,
now = Date.now(),
): void {
this.db.transaction(() => {
if (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);
}
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);
}
}