Adding DB persistence
This commit is contained in:
parent
69ccaa7560
commit
1469282199
@ -7,6 +7,8 @@
|
|||||||
"start:legacy": "export $(cat ../.env | xargs) && node app.js",
|
"start:legacy": "export $(cat ../.env | xargs) && node app.js",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start:dev": "ts-node-dev --respawn --transpile-only src/app.ts",
|
"start:dev": "ts-node-dev --respawn --transpile-only src/app.ts",
|
||||||
|
"list-games": "ts-node-dev --transpile-only tools/list-games.ts",
|
||||||
|
"import-games": "ts-node-dev --transpile-only tools/import-games-to-db.ts",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"type-check": "tsc -p tsconfig.json --noEmit"
|
"type-check": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
|
@ -32,8 +32,9 @@ const debug = {
|
|||||||
// normalizeIncoming imported from './games/utils.ts'
|
// normalizeIncoming imported from './games/utils.ts'
|
||||||
|
|
||||||
import { initGameDB } from './games/store';
|
import { initGameDB } from './games/store';
|
||||||
|
import type { GameDB } from './games/store';
|
||||||
|
|
||||||
let gameDB: any;
|
let gameDB: GameDB | undefined;
|
||||||
initGameDB().then((db) => {
|
initGameDB().then((db) => {
|
||||||
gameDB = db;
|
gameDB = db;
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
@ -620,47 +621,39 @@ const loadGame = async (id) => {
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to load from the configured game DB first (if available). Fall
|
// Load game from the configured game DB. In DB-only mode a missing DB or
|
||||||
// back to the original file-based storage for compatibility.
|
// missing game is considered an error; we still allow creating a new game
|
||||||
let game: any = null;
|
// when one doesn't exist.
|
||||||
if (gameDB && gameDB.getGameById) {
|
// Ensure the gameDB is initialized (handle startup race where init may
|
||||||
|
// still be in progress). If initialization fails, surface a clear error.
|
||||||
|
if (!gameDB) {
|
||||||
try {
|
try {
|
||||||
game = await gameDB.getGameById(id);
|
gameDB = await initGameDB();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`${info}: gameDB.getGameById error`, e);
|
throw new Error('Game DB is not available; persistence is required in DB-only mode');
|
||||||
game = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If DB didn't return a game, try the original filesystem-backed storage
|
if (!gameDB.getGameById) {
|
||||||
// including the existing backup/restore behavior.
|
throw new Error('Game DB does not expose getGameById; persistence is required');
|
||||||
if (!game) {
|
|
||||||
let raw = await readFile(`/db/games/${id}`).catch(() => { return; });
|
|
||||||
if (raw) {
|
|
||||||
try {
|
|
||||||
game = JSON.parse(raw as any);
|
|
||||||
console.log(`${info}: Creating backup of /db/games/${id}`);
|
|
||||||
await writeFile(`/db/games/${id}.bk`, JSON.stringify(game));
|
|
||||||
} catch (error) {
|
|
||||||
console.log(`Load or parse error from /db/games/${id}:`, error);
|
|
||||||
console.log(`Attempting to load backup from /db/games/${id}.bk`);
|
|
||||||
raw = await readFile(`/db/games/${id}.bk`).catch(() => { return; });
|
|
||||||
if (raw) {
|
|
||||||
try {
|
|
||||||
game = JSON.parse(raw as any);
|
|
||||||
console.log(`Saving backup to /db/games/${id}`);
|
|
||||||
await writeFile(`/db/games/${id}`, JSON.stringify(game, null, 2));
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
game = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let game: any = null;
|
||||||
|
try {
|
||||||
|
game = await gameDB.getGameById(id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${info}: gameDB.getGameById error`, e);
|
||||||
|
game = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!game) {
|
if (!game) {
|
||||||
game = await createGame(id);
|
game = await createGame(id);
|
||||||
|
// Persist the newly-created game immediately
|
||||||
|
try {
|
||||||
|
await gameDB.saveGameState(game.id, game);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${info}: Failed to persist newly created game ${game.id}`, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Clear out cached names from player colors and rebuild them
|
/* Clear out cached names from player colors and rebuild them
|
||||||
@ -3490,29 +3483,15 @@ const saveGame = async (game) => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
*/
|
*/
|
||||||
// Prefer DB persistence when available, but gracefully fall back to the
|
if (!gameDB || !gameDB.saveGameState) {
|
||||||
// original file-based storage on error or when DB is not configured.
|
console.error(`${info}: gameDB.saveGameState is not available; cannot persist game ${game.id}`);
|
||||||
if (gameDB && gameDB.saveGameState) {
|
return;
|
||||||
try {
|
}
|
||||||
await gameDB.saveGameState(game.id, reducedGame);
|
|
||||||
} catch (e) {
|
try {
|
||||||
console.error(`${info}: gameDB.saveGameState failed for ${game.id}`, e);
|
await gameDB.saveGameState(game.id, reducedGame);
|
||||||
try {
|
} catch (e) {
|
||||||
await mkdir('/db/games', { recursive: true });
|
console.error(`${info}: gameDB.saveGameState failed for ${game.id}`, e);
|
||||||
await writeFile(`/db/games/${game.id}`, JSON.stringify(reducedGame, null, 2));
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Unable to write to /db/games/${game.id}`);
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await mkdir('/db/games', { recursive: true });
|
|
||||||
await writeFile(`/db/games/${game.id}`, JSON.stringify(reducedGame, null, 2));
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Unable to write to /db/games/${game.id}`);
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4108,13 +4087,13 @@ router.ws("/ws/:id", async (ws, req) => {
|
|||||||
delete audio[id];
|
delete audio[id];
|
||||||
delete games[id];
|
delete games[id];
|
||||||
try {
|
try {
|
||||||
if (gameDB && gameDB.deleteGame) {
|
if (!gameDB || !gameDB.deleteGame) {
|
||||||
await gameDB.deleteGame(id);
|
console.error(`${session.id}: gameDB.deleteGame is not available; cannot remove ${id}`);
|
||||||
} else {
|
} else {
|
||||||
fs.unlinkSync(`/db/games/${id}`);
|
await gameDB.deleteGame(id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`${session.id}: Unable to remove /db/games/${id}`);
|
console.error(`${session.id}: Unable to remove game ${id} via gameDB.deleteGame`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -5019,23 +4998,16 @@ const createGame = async (id) => {
|
|||||||
while (!id) {
|
while (!id) {
|
||||||
id = randomWords(4).join('-');
|
id = randomWords(4).join('-');
|
||||||
try {
|
try {
|
||||||
/* If a game with this id exists in the DB or filesystem, look for a new name */
|
/* If a game with this id exists in the DB, look for a new name */
|
||||||
let exists = false;
|
if (!gameDB || !gameDB.getGameById) {
|
||||||
if (gameDB && gameDB.getGameById) {
|
throw new Error('Game DB not available for uniqueness check');
|
||||||
try {
|
|
||||||
const g = await gameDB.getGameById(id);
|
|
||||||
if (g) exists = true;
|
|
||||||
} catch (e) {
|
|
||||||
// ignore DB errors and fall back to filesystem check
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!exists) {
|
let exists = false;
|
||||||
try {
|
try {
|
||||||
accessSync(`/db/games/${id}`, fs.F_OK);
|
const g = await gameDB.getGameById(id);
|
||||||
exists = true;
|
if (g) exists = true;
|
||||||
} catch (err) {
|
} catch (e) {
|
||||||
// file does not exist
|
// if DB check fails treat as non-existent and continue searching
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (exists) {
|
if (exists) {
|
||||||
id = '';
|
id = '';
|
||||||
|
@ -1,33 +1,46 @@
|
|||||||
import type { GameState } from './state.js';
|
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.
|
* Thin game DB initializer / accessor.
|
||||||
* This currently returns the underlying db module (for runtime compatibility)
|
* This currently returns the underlying db module (for runtime compatibility)
|
||||||
* and is the single place to add typed helper methods for game persistence.
|
* and is the single place to add typed helper methods for game persistence.
|
||||||
*/
|
*/
|
||||||
export async function initGameDB(): Promise<any> {
|
export async function initGameDB(): Promise<GameDB> {
|
||||||
// dynamic import to preserve original runtime ordering
|
// dynamic import to preserve original runtime ordering
|
||||||
// path is relative to this file (routes/games)
|
// path is relative to this file (routes/games)
|
||||||
// Prefer synchronous require at runtime when available to avoid TS module resolution
|
// Prefer synchronous require at runtime when available to avoid TS module resolution
|
||||||
// issues during type-checking. Declare require to keep TypeScript happy.
|
// issues during type-checking. Declare require to keep TypeScript happy.
|
||||||
let mod: any;
|
let mod: any;
|
||||||
try {
|
try {
|
||||||
mod = (globalThis as any).require('../db/games');
|
// 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) {
|
} catch (e) {
|
||||||
// If require isn't available (very unusual in our Node container),
|
// DB-only mode: fail fast so callers know persistence is required.
|
||||||
// return a safe no-op DB object rather than attempting a dynamic import.
|
throw new Error('Game DB module could not be loaded: ' + String(e));
|
||||||
// This keeps runtime behavior unchanged in the normal case and avoids
|
|
||||||
// needing // @ts-ignore for module resolution during type-checking.
|
|
||||||
return {
|
|
||||||
sequelize: undefined,
|
|
||||||
Sequelize: undefined,
|
|
||||||
getGameById: async (_id: string | number) => null,
|
|
||||||
saveGameState: async (_id: string | number, _state: GameState) => { /* no-op */ },
|
|
||||||
deleteGame: async (_id: string | number) => { /* no-op */ }
|
|
||||||
} as any;
|
|
||||||
}
|
}
|
||||||
// If the module uses default export, prefer it
|
// If the module uses default export, prefer it
|
||||||
const db = (mod && (mod.default || mod));
|
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)
|
// attach typed helper placeholders (will be implemented incrementally)
|
||||||
if (!db.getGameById) {
|
if (!db.getGameById) {
|
||||||
@ -55,27 +68,99 @@ export async function initGameDB(): Promise<any> {
|
|||||||
// ignore and fallthrough
|
// ignore and fallthrough
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
// 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) {
|
if (!db.saveGameState) {
|
||||||
db.saveGameState = async (id: string | number, state: GameState): Promise<void> => {
|
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) {
|
if (db && db.sequelize) {
|
||||||
const payload = JSON.stringify(state);
|
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 }
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// if update failed, attempt insert
|
|
||||||
try {
|
try {
|
||||||
await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', {
|
await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', {
|
||||||
replacements: { id, state: payload }
|
replacements: { id, state: payload }
|
||||||
});
|
});
|
||||||
} catch (err) {
|
// Some dialects don't return affectedRows consistently; we'll
|
||||||
// swallow; callers should handle missing persistence
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -89,11 +174,11 @@ export async function initGameDB(): Promise<any> {
|
|||||||
replacements: { id }
|
replacements: { id }
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// swallow errors; callers will rely on fallback behavior
|
// swallow; callers should handle missing persistence
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return db;
|
return db as GameDB;
|
||||||
}
|
}
|
||||||
|
90
server/tools/import-games-to-db.ts
Normal file
90
server/tools/import-games-to-db.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env ts-node
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import { initGameDB } from '../routes/games/store';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const gamesDir = path.resolve(__dirname, '../../db/games');
|
||||||
|
let files: string[] = [];
|
||||||
|
try {
|
||||||
|
files = await fs.readdir(gamesDir);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to read games dir', gamesDir, e);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let db: any;
|
||||||
|
try {
|
||||||
|
db = await initGameDB();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to initialize DB', e);
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db || !db.sequelize) {
|
||||||
|
console.error('DB did not expose sequelize; cannot proceed.');
|
||||||
|
process.exit(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
// ignore dotfiles and keep only .json or numeric filenames
|
||||||
|
if (f.startsWith('.')) continue;
|
||||||
|
const full = path.join(gamesDir, f);
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(full);
|
||||||
|
if (!stat.isFile()) continue;
|
||||||
|
const raw = await fs.readFile(full, 'utf8');
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
// Derive id from filename (strip .json if present)
|
||||||
|
const idStr = f.endsWith('.json') ? f.slice(0, -5) : f;
|
||||||
|
const id = isNaN(Number(idStr)) ? idStr : Number(idStr);
|
||||||
|
const payload = JSON.stringify(state);
|
||||||
|
|
||||||
|
// Ensure state column exists; attempt a safe ALTER if needed.
|
||||||
|
try {
|
||||||
|
await db.sequelize.query('ALTER TABLE games ADD COLUMN state TEXT');
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore: may already exist */
|
||||||
|
}
|
||||||
|
|
||||||
|
// If filename is numeric use id column; otherwise use path column so we don't write strings into an INTEGER id.
|
||||||
|
try {
|
||||||
|
if (typeof id === 'number') {
|
||||||
|
const rows: any[] = await db.sequelize.query('SELECT id FROM games WHERE id=:id', {
|
||||||
|
replacements: { id },
|
||||||
|
type: db.Sequelize.QueryTypes.SELECT
|
||||||
|
});
|
||||||
|
if (rows && rows.length) {
|
||||||
|
await db.sequelize.query('UPDATE games SET state=:state WHERE id=:id', { replacements: { id, state: payload } });
|
||||||
|
console.log(`Updated game id=${id}`);
|
||||||
|
} else {
|
||||||
|
await db.sequelize.query('INSERT INTO games (id, state) VALUES(:id, :state)', { replacements: { id, state: payload } });
|
||||||
|
console.log(`Inserted game id=${id}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// use path column to record the filename identifier
|
||||||
|
const rows: any[] = await db.sequelize.query('SELECT id FROM games WHERE path=:path', {
|
||||||
|
replacements: { path: idStr },
|
||||||
|
type: db.Sequelize.QueryTypes.SELECT
|
||||||
|
});
|
||||||
|
if (rows && rows.length) {
|
||||||
|
await db.sequelize.query('UPDATE games SET state=:state WHERE path=:path', { replacements: { path: idStr, state: payload } });
|
||||||
|
console.log(`Updated game path=${idStr}`);
|
||||||
|
} else {
|
||||||
|
await db.sequelize.query('INSERT INTO games (path, state) VALUES(:path, :state)', { replacements: { path: idStr, state: payload } });
|
||||||
|
console.log(`Inserted game path=${idStr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to insert/update game', idStr, e);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to read/parse', full, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Import complete');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
102
server/tools/list-games.ts
Normal file
102
server/tools/list-games.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env ts-node
|
||||||
|
import { initGameDB } from '../routes/games/store';
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
gameId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArgs(): Args {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const res: Args = {};
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const a = args[i];
|
||||||
|
if ((a === '-g' || a === '--game') && args[i+1]) {
|
||||||
|
res.gameId = String(args[i+1]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { gameId } = parseArgs();
|
||||||
|
|
||||||
|
let db: any;
|
||||||
|
try {
|
||||||
|
db = await initGameDB();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to initialize game DB:', e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db || !db.sequelize) {
|
||||||
|
console.error('DB does not expose sequelize; cannot run queries.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gameId) {
|
||||||
|
// List all game ids
|
||||||
|
try {
|
||||||
|
const rows: any[] = await db.sequelize.query('SELECT id FROM games', { type: db.Sequelize.QueryTypes.SELECT });
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
console.log('No games found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('Games:');
|
||||||
|
rows.forEach(r => console.log(` - ${r.id}`));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to list games:', e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For a given game ID, try to print the turns history from the state
|
||||||
|
try {
|
||||||
|
const rows: any[] = await db.sequelize.query('SELECT state FROM games WHERE id=:id', {
|
||||||
|
replacements: { id: gameId },
|
||||||
|
type: db.Sequelize.QueryTypes.SELECT
|
||||||
|
});
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
console.error('Game not found:', gameId);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const r = rows[0] as any;
|
||||||
|
let state = r.state;
|
||||||
|
if (typeof state === 'string') {
|
||||||
|
try {
|
||||||
|
state = JSON.parse(state);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse stored state JSON:', e);
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
console.error('Empty state for game', gameId);
|
||||||
|
process.exit(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Game ${gameId} summary:`);
|
||||||
|
console.log(` - turns: ${state.turns || 0}`);
|
||||||
|
if (state.turnHistory || state.turnsData || state.turns_list) {
|
||||||
|
const turns = state.turnHistory || state.turnsData || state.turns_list;
|
||||||
|
console.log('Turns:');
|
||||||
|
turns.forEach((t: any, idx: number) => {
|
||||||
|
console.log(`${idx}: ${JSON.stringify(t)}`);
|
||||||
|
});
|
||||||
|
} else if (state.turns && state.turns > 0) {
|
||||||
|
console.log('No explicit turn history found inside state; showing snapshot metadata.');
|
||||||
|
// Print limited snapshot details per turn if available
|
||||||
|
if (state.turnsData) {
|
||||||
|
state.turnsData.forEach((t: any, idx: number) => console.log(`${idx}: ${JSON.stringify(t)}`));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No turn history recorded in state.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load game state for', gameId, e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
Loading…
x
Reference in New Issue
Block a user