212 lines
7.6 KiB
TypeScript
212 lines
7.6 KiB
TypeScript
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<void>;
|
|
getGameById(id: string): Promise<Game | null>;
|
|
saveGame(game: Game): Promise<void>;
|
|
deleteGame?(id: string): Promise<void>;
|
|
}
|
|
|
|
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<Game | null> => {
|
|
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];
|
|
// 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/<id> or <id>.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<void> => {
|
|
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<void> => {
|
|
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<string, Game> = {};
|