1
0

185 lines
7.2 KiB
TypeScript

import type { GameState } from './state.js';
import { promises as fsp } from 'fs';
import path from 'path';
export interface GameDB {
sequelize?: any;
Sequelize?: any;
getGameById(id: string | number): Promise<GameState | null>;
saveGameState(id: string | number, state: GameState): Promise<void>;
deleteGame?(id: string | number): Promise<void>;
[k: string]: any;
}
/**
* Thin game DB initializer / accessor.
* This currently returns the underlying db module (for runtime compatibility)
* and is the single place to add typed helper methods for game persistence.
*/
export async function initGameDB(): Promise<GameDB> {
// dynamic import to preserve original runtime ordering
// path is relative to this file (routes/games)
// Prefer synchronous require at runtime when available to avoid TS module resolution
// issues during type-checking. Declare require to keep TypeScript happy.
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: any = (mod && (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));
}
}
// attach typed helper placeholders (will be implemented incrementally)
if (!db.getGameById) {
db.getGameById = async (id: string | number): Promise<GameState | null> => {
// 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 GameState;
} catch (e) {
return null;
}
}
return r.state as GameState;
}
} 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 GameState;
} catch (e) {
// try next candidate
}
}
return null;
} catch (err) {
return null;
}
};
}
if (!db.saveGameState) {
db.saveGameState = async (id: string | number, state: GameState): Promise<void> => {
// 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, String(id));
const tmpPath = `${filePath}.tmp`;
await fsp.writeFile(tmpPath, JSON.stringify(state, 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', id, err);
}
// Now attempt DB persistence if sequelize is present.
if (db && db.sequelize) {
const payload = JSON.stringify(state);
// 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, 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 },
type: db.Sequelize.QueryTypes.SELECT
});
if (!check || check.length === 0) {
await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', {
replacements: { 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, state: payload }
});
const check = await db.sequelize.query('SELECT id FROM games WHERE id=:id', {
replacements: { 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, 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, state: payload }
});
} catch (err) {
// swallow; callers should handle missing persistence
}
}
}
} catch (finalErr) {
// swallow; we don't want persistence errors to crash the server
}
}
};
}
if (!db.deleteGame) {
db.deleteGame = async (id: string | number): Promise<void> => {
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
}
}
};
}
return db as GameDB;
}