/** 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( "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); } }