From 3721d2de5bd2dfb8de02116a4d302bcccc41a1ea Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Mar 2026 19:37:17 +0000 Subject: [PATCH] fix: persist admin runtime constants to DB across restarts Constants were purely in-memory; any server restart reverted them to code defaults. Now saved to metadata table as JSON on every autosave and restored on startup. Co-Authored-By: Claude Opus 4.6 --- server/src/config/runtimeConstants.ts | 9 +++++++++ server/src/game/GameLoop.ts | 8 ++++---- server/src/persistence/saveManager.ts | 20 +++++++++++++++----- server/src/persistence/worldSerializer.ts | 19 +++++++++++++++++++ 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/server/src/config/runtimeConstants.ts b/server/src/config/runtimeConstants.ts index 6d627ef..6a972bd 100644 --- a/server/src/config/runtimeConstants.ts +++ b/server/src/config/runtimeConstants.ts @@ -46,6 +46,15 @@ export function createRuntimeConstants() { getDefaults(): TunableConstants { return { ...DEFAULTS }; }, + + /** Load saved overrides, ignoring any unknown keys */ + loadOverrides(overrides: Partial): void { + for (const [key, value] of Object.entries(overrides)) { + if (key in DEFAULTS && typeof value === 'number') { + current[key as TunableKey] = value; + } + } + }, }; } diff --git a/server/src/game/GameLoop.ts b/server/src/game/GameLoop.ts index 95a322a..28bc8c4 100644 --- a/server/src/game/GameLoop.ts +++ b/server/src/game/GameLoop.ts @@ -110,7 +110,7 @@ export class GameLoop { obstacles: new Set(this.map.getObstacles().map(p => `${p.x},${p.y}`)), foodPositions: this.map.getPointsOfInterest('food').map(p => p.position), }); - this.saveManager.saveEntityState(this.world, 0); + this.saveManager.saveEntityState(this.world, 0, this.runtimeConstants); this.wirePersistenceCallbacks(); } console.log('Created new world'); @@ -127,7 +127,7 @@ export class GameLoop { this.world.setSingleton('recipeRegistry', RecipeRegistry.createDefault()); // Load entities (also restores bondRegistry singleton) - this.tick = this.saveManager!.loadEntityState(this.world); + this.tick = this.saveManager!.loadEntityState(this.world, this.runtimeConstants); // Create and load inventionTimeline const inventionTimeline = createInventionTimeline(); @@ -273,7 +273,7 @@ export class GameLoop { this.saveInterval = setInterval(() => { if (this.saveManager) { try { - this.saveManager.saveEntityState(this.world, this.tick); + this.saveManager.saveEntityState(this.world, this.tick, this.runtimeConstants); this.logService.log('info', 'Save', `Autosaved at tick ${this.tick}`); } catch (err) { this.logService.log('error', 'Save', `Autosave failed: ${(err as Error).message}`); @@ -294,7 +294,7 @@ export class GameLoop { } // Final save on shutdown if (this.saveManager) { - this.saveManager.saveEntityState(this.world, this.tick); + this.saveManager.saveEntityState(this.world, this.tick, this.runtimeConstants); this.saveManager.close(); console.log(`Final save at tick ${this.tick}`); } diff --git a/server/src/persistence/saveManager.ts b/server/src/persistence/saveManager.ts index fe82ac2..c4ea229 100644 --- a/server/src/persistence/saveManager.ts +++ b/server/src/persistence/saveManager.ts @@ -1,9 +1,10 @@ import fs from 'fs'; import { openDatabase, closeDatabase, getDatabase, getSchemaVersion, CURRENT_SCHEMA_VERSION } from './database.js'; -import { saveTiles, loadTiles, saveMetadata, loadMetadata, type TileData } from './worldSerializer.js'; +import { saveTiles, loadTiles, saveMetadata, loadMetadata, saveRuntimeConstants, loadRuntimeConstants, type TileData } from './worldSerializer.js'; import { saveEntities, loadEntities } from './entitySerializer.js'; import type { World } from '../ecs/World.js'; import type { GameMap } from '../map/GameMap.js'; +import type { RuntimeConstants } from '../config/runtimeConstants.js'; export class SaveManager { private filepath: string; @@ -92,16 +93,25 @@ export class SaveManager { return true; } - /** Save all entity state (entities, components, relationships, bonds) + metadata */ - saveEntityState(world: World, tick: number): void { + /** Save all entity state (entities, components, relationships, bonds) + metadata + runtime constants */ + saveEntityState(world: World, tick: number, runtimeConstants?: RuntimeConstants): void { saveEntities(world); saveMetadata({ tick, lastSavedAt: new Date().toISOString() }); + if (runtimeConstants) { + saveRuntimeConstants(runtimeConstants.getAll()); + } } - /** Load entity state into world. Returns saved tick number. */ - loadEntityState(world: World): number { + /** Load entity state into world. Restores runtime constants if saved. Returns saved tick number. */ + loadEntityState(world: World, runtimeConstants?: RuntimeConstants): number { loadEntities(world); const meta = loadMetadata(); + if (runtimeConstants) { + const saved = loadRuntimeConstants(); + if (saved) { + runtimeConstants.loadOverrides(saved); + } + } return meta.tick; } diff --git a/server/src/persistence/worldSerializer.ts b/server/src/persistence/worldSerializer.ts index 22ec22b..772b4f8 100644 --- a/server/src/persistence/worldSerializer.ts +++ b/server/src/persistence/worldSerializer.ts @@ -114,3 +114,22 @@ export function loadMetadata(): { tick: number; lastSavedAt: string } { return { tick, lastSavedAt }; } + +export function saveRuntimeConstants(constants: Record): void { + const db = getDatabase(); + const upsert = db.prepare( + "INSERT INTO metadata (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value" + ); + upsert.run('runtime_constants', JSON.stringify(constants)); +} + +export function loadRuntimeConstants(): Record | null { + const db = getDatabase(); + const row = db.prepare("SELECT value FROM metadata WHERE key = 'runtime_constants'").get() as { value: string } | undefined; + if (!row) return null; + try { + return JSON.parse(row.value); + } catch { + return null; + } +}