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);
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;
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) {
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:
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) {
/* Stash the game onto the request to be used by the game-specific code. */
request.game = game;
+ game.touch();
next();
});
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');
});