import type { Game } from "./types"; import { promises as fsp } from "fs"; import path from "path"; import { transientState } from "./sessionState"; interface GameDB { db: any | null; init(): Promise; getGameById(id: string): Promise; saveGame(game: Game): Promise; deleteGame?(id: string): Promise; } export const gameDB: GameDB = { db: null, init: async () => { let mod: any; try { // Use runtime require to load the DB module. This runs under Node (ts-node) // so a direct require is appropriate and avoids relying on globalThis. // eslint-disable-next-line @typescript-eslint/no-var-requires mod = require("../../db/games"); } catch (e) { // DB-only mode: fail fast so callers know persistence is required. throw new Error("Game DB module could not be loaded: " + String(e)); } // If the module uses default export, prefer it let db = mod.default || mod; // If the required module returned a Promise (the db initializer may), await it. if (db && typeof db.then === "function") { try { db = await db; } catch (e) { throw new Error("Game DB initializer promise rejected: " + String(e)); } } gameDB.db = db; return db; }, getGameById: async (id: string | number): Promise => { if (!gameDB.db) { await gameDB.init(); } const db = gameDB.db; // fallback: try to query by id using raw SQL if sequelize is available if (db && db.sequelize) { try { const rows = await db.sequelize.query("SELECT state FROM games WHERE id=:id", { replacements: { id }, type: db.Sequelize.QueryTypes.SELECT, }); if (rows && rows.length) { const r = rows[0] as any; // state may be stored as text or JSON if (typeof r.state === "string") { try { return JSON.parse(r.state) as Game; } catch (e) { return null; } } return r.state as Game; } } catch (e) { // ignore and fallthrough } } // If DB didn't have a state or query failed, attempt to read from the // filesystem copy at db/games/ or .json so the state remains editable. try { const gamesDir = path.resolve(__dirname, "../../../db/games"); const candidates = [path.join(gamesDir, String(id)), path.join(gamesDir, String(id) + ".json")]; for (const filePath of candidates) { try { const raw = await fsp.readFile(filePath, "utf8"); return JSON.parse(raw) as Game; } catch (e) { // try next candidate } } return null; } catch (err) { return null; } }, saveGame: async (game: Game): Promise => { if (!gameDB.db) { await gameDB.init(); } const db = gameDB.db; /* Shallow copy game, filling its sessions with a shallow copy of sessions so we can then * delete the player field from them */ const reducedGame = Object.assign({}, game, { sessions: {} }), reducedSessions = []; for (let id in game.sessions) { const reduced = Object.assign({}, game.sessions[id]); // Automatically remove all transient fields (uses TRANSIENT_SESSION_SCHEMA as source of truth) transientState.stripSessionTransients(reduced); reducedGame.sessions[id] = reduced; /* Do not send session-id as those are secrets */ reducedSessions.push(reduced); } // Automatically remove all game-level transient fields (uses TRANSIENT_GAME_SCHEMA) transientState.stripGameTransients(reducedGame); /* Save per turn while debugging... */ game.step = game.step ? game.step : 0; // Always persist a JSON file so game state is inspectable/editable. try { const gamesDir = path.resolve(__dirname, "../../../db/games"); await fsp.mkdir(gamesDir, { recursive: true }); // Write extensionless filename to match existing files const filePath = path.join(gamesDir, reducedGame.id); const tmpPath = `${filePath}.tmp`; await fsp.writeFile(tmpPath, JSON.stringify(reducedGame, null, 2), "utf8"); await fsp.rename(tmpPath, filePath); } catch (err) { // Log but continue to attempt DB persistence // eslint-disable-next-line no-console console.error("Failed to write game JSON file for", reducedGame.id, err); } // Now attempt DB persistence if sequelize is present. if (db && db.sequelize) { const payload = JSON.stringify(reducedGame); // Try an UPDATE; if it errors due to missing column, try to add the // column and retry. If update affects no rows, try INSERT. try { try { await db.sequelize.query("UPDATE games SET state=:state WHERE id=:id", { replacements: { id: reducedGame.id, state: payload }, }); // Some dialects don't return affectedRows consistently; we'll // still attempt insert if no row exists by checking select. const check = await db.sequelize.query("SELECT id FROM games WHERE id=:id", { replacements: { id: reducedGame.id }, type: db.Sequelize.QueryTypes.SELECT, }); if (!check || check.length === 0) { await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", { replacements: { id: reducedGame.id, state: payload }, }); } } catch (e: any) { const msg = String(e && e.message ? e.message : e); // If the column doesn't exist (SQLite: no such column: state), add it. if ( /no such column: state/i.test(msg) || /has no column named state/i.test(msg) || /unknown column/i.test(msg) ) { try { await db.sequelize.query("ALTER TABLE games ADD COLUMN state TEXT"); // retry insert/update after adding column await db.sequelize.query("UPDATE games SET state=:state WHERE id=:id", { replacements: { id: reducedGame.id, state: payload }, }); const check = await db.sequelize.query("SELECT id FROM games WHERE id=:id", { replacements: { id: reducedGame.id }, type: db.Sequelize.QueryTypes.SELECT, }); if (!check || check.length === 0) { await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", { replacements: { id: reducedGame.id, state: payload }, }); } } catch (inner) { // swallow; callers should handle missing persistence } } else { // For other errors, attempt insert as a fallback try { await db.sequelize.query("INSERT INTO games (id, state) VALUES(:id, :state)", { replacements: { id: reducedGame.id, state: payload }, }); } catch (err) { // swallow; callers should handle missing persistence } } } } catch (finalErr) { // swallow; we don't want persistence errors to crash the server } } }, deleteGame: async (id: string | number): Promise => { if (!gameDB.db) { await gameDB.init(); } const db = gameDB.db; if (db && db.sequelize) { try { await db.sequelize.query("DELETE FROM games WHERE id=:id", { replacements: { id }, }); } catch (e) { // swallow; callers should handle missing persistence } } }, }; export const games: Record = {};