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>
This commit is contained in:
140
apps/server/src/rooms/store.ts
Normal file
140
apps/server/src/rooms/store.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user