]> git.cworth.org Git - lmno-server/commitdiff
lmno: Implement saving of game state when shutting down the server
authorCarl Worth <cworth@cworth.org>
Sun, 8 Mar 2026 22:57:29 +0000 (18:57 -0400)
committerCarl Worth <cworth@cworth.org>
Sun, 8 Mar 2026 22:57:29 +0000 (18:57 -0400)
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
game.js
lmno.js

index 3684be69c78743c3496c839153609a58695d7f41..fd1b110edc9b4ebe08d385cddb8579c6c5a898c3 100644 (file)
@@ -4,3 +4,4 @@ node_modules
 game.html
 lmno-config.json
 .cookie-*
+data
diff --git a/game.js b/game.js
index f078c354d44164b4f5ad449b372c3f7cb258e056..41fd4e6dc8d10c6ea387069332f9956d3f70dd50 100644 (file)
--- 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 deb81724323f438642aaba8a92db40200fd02ac9..5f3f097c8fdaec12d05c2bf9762ce170ffafb9ac 100644 (file)
--- 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');
 });