From 044a9b78e9372000308d2a889ad23a577ea6885b Mon Sep 17 00:00:00 2001 From: Carl Worth Date: Sun, 8 Mar 2026 18:57:29 -0400 Subject: [PATCH] lmno: Implement saving of game state when shutting down the server State is written to data/lmno-state.json at shutdown and then re-read at server start. This should let us stop/start the server without disrupting game play for in-progress games. --- .gitignore | 1 + game.js | 59 ++++++++++++++++++++++++++ lmno.js | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 179 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 3684be6..fd1b110 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules game.html lmno-config.json .cookie-* +data diff --git a/game.js b/game.js index f078c35..41fd4e6 100644 --- a/game.js +++ b/game.js @@ -67,6 +67,9 @@ class Game { team_to_play: no_team }; this.first_move = true; + this.created_at = Date.now(); + this.last_action = Date.now(); + this.game_over = false; /* Send a comment to every connected client every 15 seconds. */ setInterval(() => {this.broadcast_string(":");}, 15000); @@ -350,6 +353,62 @@ class Game { this.broadcast_event("move", JSON.stringify(move)); } + touch() { + this.last_action = Date.now(); + } + + /* Serialize game state for persistence to disk. + * + * Subclasses can override this to include additional data beyond + * this.state (call super.serialize() and merge the result). + */ + serialize() { + return { + id: this.id, + engine: this.meta.identifier, + created_at: this.created_at, + last_action: this.last_action, + game_over: this.game_over, + state: JSON.parse(JSON.stringify(this.state, stringify_replacer)), + first_move: this.first_move, + next_player_id: this.next_player_id, + players: this.players.map(p => ({ + id: p.id, + session_id: p.session_id, + name: p.name, + team: p.team.name, + score: p.score, + })), + }; + } + + /* Restore game state from a previously serialized object. + * + * Subclasses can override this to restore additional data + * (call super.restore(data) first). + */ + restore(data) { + this.state = data.state; + this.first_move = data.first_move; + this.next_player_id = data.next_player_id; + this.created_at = data.created_at; + this.last_action = data.last_action; + this.game_over = data.game_over || false; + + for (const pd of data.players) { + const player = new Player(pd.id, pd.session_id, pd.name, null); + player.connections = []; + player.active = false; + player.score = pd.score; + if (pd.team) { + const team = this.teams.find(t => t.name === pd.team); + if (team) player.team = team; + } + this.players.push(player); + this.players_by_session[pd.session_id] = player; + } + } + } module.exports = Game; diff --git a/lmno.js b/lmno.js index deb8172..5f3f097 100644 --- a/lmno.js +++ b/lmno.js @@ -3,9 +3,15 @@ const cors = require("cors"); const body_parser = require("body-parser"); const session = require("express-session"); const bcrypt = require("bcrypt"); +const fs = require("fs"); const path = require("path"); const nunjucks = require("nunjucks"); +const DATA_DIR = path.join(__dirname, "data"); +const STATE_FILE = path.join(DATA_DIR, "lmno-state.json"); +const STATS_FILE = path.join(DATA_DIR, "lmno-stats.jsonl"); +const RETIREMENT_AGE_MS = 24 * 60 * 60 * 1000; + try { var lmno_config = require("./lmno-config.json"); } catch (err) { @@ -154,6 +160,93 @@ class LMNO { return this.create_game_with_id(engine_name, id); } + + ensure_data_dir() { + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR); + } + } + + save_state() { + const data = {}; + for (const id in this.games) { + data[id] = this.games[id].serialize(); + } + this.ensure_data_dir(); + const tmp = STATE_FILE + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(data, null, 2)); + fs.renameSync(tmp, STATE_FILE); + console.log(`Saved ${Object.keys(data).length} game(s) to ${STATE_FILE}`); + } + + load_state() { + if (!fs.existsSync(STATE_FILE)) + return; + + const raw = fs.readFileSync(STATE_FILE, 'utf8'); + const data = JSON.parse(raw); + + let count = 0; + for (const id in data) { + const game_data = data[id]; + const engine_name = game_data.engine; + if (!engines[engine_name]) { + console.log(`Skipping game ${id}: unknown engine "${engine_name}"`); + continue; + } + const game = this.create_game_with_id(engine_name, id); + if (!game) { + console.log(`Skipping game ${id}: ID already in use`); + continue; + } + game.restore(game_data); + count++; + } + console.log(`Restored ${count} game(s) from ${STATE_FILE}`); + } + + retire_old_games() { + const now = Date.now(); + const to_retire = []; + + for (const id in this.games) { + const game = this.games[id]; + if (now - game.last_action > RETIREMENT_AGE_MS) { + to_retire.push(id); + } + } + + if (to_retire.length === 0) + return; + + this.ensure_data_dir(); + + /* Build new stats lines, then atomically append to the stats file + * by reading existing content, concatenating, and renaming. */ + let new_lines = ""; + for (const id of to_retire) { + const game = this.games[id]; + const stats = { + engine: game.meta.identifier, + id: game.id, + players: game.players.length, + completed: game.game_over, + started: new Date(game.created_at).toISOString(), + ended: new Date(game.last_action).toISOString(), + retired: new Date(now).toISOString(), + }; + new_lines += JSON.stringify(stats) + "\n"; + + delete this.games[id]; + console.log(`Retired game ${id} (${game.meta.identifier}, ${game.players.length} players)`); + } + + const existing = fs.existsSync(STATS_FILE) + ? fs.readFileSync(STATS_FILE, 'utf8') : ""; + const tmp = STATS_FILE + ".tmp"; + fs.writeFileSync(tmp, existing + new_lines); + fs.renameSync(tmp, STATS_FILE); + } } /* Some letters we don't use in our IDs: @@ -168,12 +261,20 @@ LMNO.letters = "CCDDDGGGHHJKLLLLMMMMPPPPQRRRSSSTTTVVWWYYZ"; const lmno = new LMNO(); -/* Pre-allocate an empires game with ID QRST. +/* Restore any games persisted from a previous server run. */ +lmno.load_state(); + +/* Pre-allocate an empires game with ID QRST (if not already restored). * This is for convenience in the development of the flempires * client which would like to have stable API endpoints across * server restarts. */ -lmno.create_game_with_id("empires", "QRST"); +if (!lmno.games["QRST"]) { + lmno.create_game_with_id("empires", "QRST"); +} + +/* Periodically retire games with no client activity for 24 hours. */ +setInterval(() => lmno.retire_old_games(), 60 * 60 * 1000); /* Force a game ID into a canonical form as described above. */ function lmno_canonize(id) { @@ -252,6 +353,7 @@ app.use('/:engine([^/]+)/:game_id([a-zA-Z0-9]{4})', (request, response, next) => /* Stash the game onto the request to be used by the game-specific code. */ request.game = game; + game.touch(); next(); }); @@ -402,6 +504,21 @@ for (let key in engines) { app.use(`/${engine.meta.identifier}/[a-zA-Z0-9]{4}/`, router); } +/* Save state and exit on shutdown signals or uncaught exceptions. */ +function shutdown(reason) { + console.log(`\nShutting down: ${reason}`); + lmno.retire_old_games(); + lmno.save_state(); + process.exit(reason === 'uncaughtException' ? 1 : 0); +} + +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('uncaughtException', (err) => { + console.error('Uncaught exception:', err); + shutdown('uncaughtException'); +}); + app.listen(4000, '0.0.0.0', function () { console.log('LMNO server listening on 0.0.0.0:4000'); }); -- 2.45.2