/** * SaveData — a save as DATA: what a game IS, and how a record becomes * a game again. * * A record is small because the galaxy regenerates from its seed * (PROJECT_NOTES: "a save = seed + player state"): * * { app, format, savedAt, * seed, galaxyName, currentSystemId, systemName, * ship: { x, y, heading, minerals }, * discovery: Discovery.toJSON(), * reputation: Reputation.toJSON(), * tethers: [{ id, x, y, level, label }], * research: ResearchState.toJSON() | null, * builds: BuildState.toJSON() | null, * playTimeMs } * * captureState(scene) — GameScene → record (the Save panel calls it) * prepareLoad(registry, r) — record → shared registry: rebuilds the * galaxy from the seed, restores discovery, * and parks the ship/tether/playtime state * under 'orbit.pendingRestore' * consumeRestore(registry) — GameScene.create() picks the parked state * off the registry (and clears it) * resetRunState(registry) — "New Game" from the menu: a fresh run must * not inherit the previous one's discovery * * Pure module (no Phaser) — the registry is just {get,set}, so Node * tests can drive it (dev/saves.test.mjs). */ import { config } from '../config/Config.js'; import { Galaxy } from '../galaxy/Galaxy.js'; import { Discovery } from '../galaxy/Discovery.js'; import { Reputation } from '../reputation/Reputation.js'; import { SAVE_FORMAT, SaveManager } from './SaveManager.js'; export const PENDING_RESTORE_KEY = 'orbit.pendingRestore'; /** * Snapshot the live game into a save record. * * @param {object} scene the GameScene (reads: registry, galaxy, * systemRecord, ship, discovery, reputation, tetherField, playTimeMs) * @param {number} [now] fallback live clock (ms) — see below; each * subsystem captures on its own time base. * @returns {object} the record (ready for SaveManager.put) */ export function captureState(scene, now) { const galaxy = scene.galaxy; if (!galaxy) throw new Error('no galaxy to save'); // Each subsystem's in-flight timer is captured on ITS OWN time base: // - research: the GameScene's clock (beginResearch uses it — the scene // is awake while research runs, so it is live); // - builds: the game-loop clock (game.loop.now — the build's time base, // global + monotonic, and still live while the GameScene SLEEPS // during a surface stay; the caller's `now` or Date.now only as a // last resort). const researchNow = Number.isFinite(scene.time?.now) ? scene.time.now : Date.now(); const buildNow = Number.isFinite(scene.game?.loop?.now) ? scene.game.loop.now : (Number.isFinite(now) ? now : Date.now()); const rec = { app: 'orbit', format: SAVE_FORMAT, savedAt: new Date().toISOString(), seed: scene.registry.get('seed') ?? galaxy.seed, galaxyName: galaxy.name, currentSystemId: galaxy.currentSystemId, systemName: scene.systemRecord?.name ?? null, ship: { x: Number(scene.ship.x), y: Number(scene.ship.y), heading: Number(scene.ship.rotation), // The hold (minerals aboard) — saves that predate it just lack the // field; the restore treats a missing number as 0. minerals: Math.max(0, Math.round(Number(scene.ship.minerals) || 0)), }, discovery: scene.discovery ? scene.discovery.toJSON() : { distance: 540, bySystem: {} }, reputation: scene.reputation ? scene.reputation.toJSON() : new Reputation().toJSON(), tethers: (scene.tetherField?.tethers ?? []).map((t) => ({ id: t.id, x: t.x, y: t.y, level: t.level, label: t.label ?? '', })), // Research — the unlocked set + the in-flight project (its remaining // time is captured NOW on the scene's own clock; a save predating // research has no field, and the restore treats the absence as "no // research" (old saves load). research: scene.researchState ? scene.researchState.toJSON(researchNow) : null, // Builds — the installed set + the in-flight build (remaining time on // the game-loop clock — the build's time base; see above). builds: scene.buildState ? scene.buildState.toJSON(buildNow) : null, playTimeMs: Math.round(scene.playTimeMs ?? 0), }; const err = SaveManager.validateRecord(rec); if (err) throw new Error(err); return rec; } /** * Stage a record for play: rebuild the galaxy from its seed, restore the * discovery + reputation state, and park the live-state (ship/tethers/ * playtime) in the registry — the next GameScene.create() consumes it via * consumeRestore(). * * A record from before reputation existed (no `reputation` field) stages * a fresh, all-neutral reputation — old saves keep loading. * * @throws {Error} when the record fails validation — the caller toasts it. */ export function prepareLoad(registry, record) { const err = SaveManager.validateRecord(record); if (err) throw new Error(err); const seed = String(record.seed).trim(); const galaxy = Galaxy.create(seed); // Honour the saved current system when the roster knows it (it should — // same seed ⇒ same roster). if (typeof record.currentSystemId === 'string' && galaxy.byId.has(record.currentSystemId)) { galaxy.currentSystemId = record.currentSystemId; } registry.set('galaxy', galaxy); registry.set('seed', seed); registry.set('discovery', Discovery.fromJSON(record.discovery ?? { distance: 540, bySystem: {} })); registry.set('reputation', Reputation.fromJSON(record.reputation)); registry.set(PENDING_RESTORE_KEY, { ship: record.ship, tethers: Array.isArray(record.tethers) ? record.tethers : [], research: record.research ?? null, builds: record.builds ?? null, playTimeMs: Number(record.playTimeMs) || 0, }); } /** * GameScene.create() seam: take the parked restore state (if any) off the * registry and clear the key. Returns null for a fresh run. */ export function consumeRestore(registry) { const r = registry.get(PENDING_RESTORE_KEY); if (r === undefined || r === null) return null; registry.set(PENDING_RESTORE_KEY, null); return r; } /** * "New Game" from the main menu: a fresh run must not inherit the * previous one's discovery state, its standing with the old galaxy, or a * half-staged restore. */ export function resetRunState(registry) { registry.set('discovery', null); registry.set('reputation', null); registry.set(PENDING_RESTORE_KEY, null); } /** Distance used when a save predates discovery (defensive fallback). */ export function defaultDiscoveryDistance() { return config.get('game.discovery.distance', 540); }