/** * 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 }], // incl. the activated * // gates' anchor tethers * activatedGates: [">"], // the SYSTEM research * // category's activation * // keys (registry-backed * // Set, replayed on load — * // gates + tethers re-form * // in GameScene.create) * visitedSystems: ["", …], // the systems the run * // ENTERED (the GALAXY * // tab's charted region) * usedGates: ["", …], // the jump lanes the run * // TRAVELED (GalaxyChart. * // edgeKey — undirected; * // the bright lanes) * research: ResearchState.toJSON() | null, * builds: BuildState.toJSON() | null, * quests: QuestState.toJSON() | null, // held + claimed quest ids * totalMined: number, // lifetime asteroid mining * 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 { QuestState } from '../quests/QuestState.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, // Quests — the run's mission ledger: which quests are HELD (granted) // and which rewards have been CLAIMED. Progress itself is NOT saved — // it is computed live from the run's state (GameScene.questSnapshot). // A save predating quests has no field; the restore stages null and // the starter quest is granted onto the fresh ledger (old saves load). quests: scene.questState ? scene.questState.toJSON() : null, // Lifetime minerals mined from asteroids (the 'Mine 200 Minerals' // requirement's input — the hold is cargo, this is the record). // A save predating it has no field; the restore stages 0. totalMined: Math.max(0, Math.round(Number(scene.totalMined) || 0)), // The SYSTEM research category's activation keys (which jump gates — // incl. other systems' return gates — the run has activated; the // per-run world state GameScene keeps in its registry-backed set). // Pre-system-category saves lack the field; the restore treats the // absence as an empty set (old saves load). activatedGates: Array.from(scene.activatedGates ?? []), // THE RUN'S GALAXY FOOTPRINT (the MAP console's GALAXY tab — // js/galaxy/GalaxyChart.js): the systems entered + the lanes traveled. // A save predating the galaxy tab lacks both fields; the restore // treats the absence as "just the current system, no lanes yet" // (old saves load, the region starts small). visitedSystems: Array.from(scene.visitedSystems ?? []), usedGates: Array.from(scene.usedGates ?? []), // THE PLOTTED DESTINATION (the MAP console's SYSTEM tab — js/galaxy/ // Route.js): { systemId, objectId } | null. Only the destination is // stored — the route itself is derived from the current system at load // time (a save predating it has no field → no destination). destination: scene.destination && typeof scene.destination.systemId === 'string' ? { systemId: scene.destination.systemId, objectId: scene.destination.objectId ?? null } : 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)); // The SYSTEM category's activation keys (a save predating the category // has no field → an empty set). GameScene.create() reads the set, flips // the current system's gate content before its entities build, and // anchors the gate tethers (data/gates.json → ACTIVITY). registry.set( 'activatedGates', new Set(Array.isArray(record.activatedGates) ? record.activatedGates : []), ); // The run's galaxy footprint (the GALAXY tab's charted region + bright // lanes). Legacy saves (no fields) default to "the current system is // charted, nothing traveled" — the region starts at one star. registry.set( 'visitedSystems', new Set( Array.isArray(record.visitedSystems) && record.visitedSystems.length ? record.visitedSystems : [record.currentSystemId].filter((id) => typeof id === 'string'), ), ); registry.set( 'usedGates', new Set(Array.isArray(record.usedGates) ? record.usedGates : []), ); // The plotted destination (a save predating it has no field → null). // The route is derived from the current system at play time. registry.set( 'destination', record.destination && typeof record.destination.systemId === 'string' ? { systemId: record.destination.systemId, objectId: record.destination.objectId ?? null } : null, ); // The quest ledger (a save predating quests has no field → null — the // fresh run's GameScene starts an empty ledger and grants the starter // quest; old saves load). The validation inside fromJSON keeps a // corrupted ledger from breaking the load (claimed ⊆ granted, ids must // exist in data/quests.json). registry.set('quests', record.quests ? QuestState.fromJSON(record.quests) : null); // Lifetime asteroid mining (a save predating it has no field → 0). registry.set('totalMined', Math.max(0, Math.round(Number(record.totalMined) || 0))); 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, its * activated jump gates, or a half-staged restore. */ export function resetRunState(registry) { registry.set('discovery', null); registry.set('reputation', null); registry.set('activatedGates', null); registry.set('visitedSystems', null); registry.set('usedGates', null); registry.set('destination', null); registry.set('routeNotice', null); registry.set('quests', null); registry.set('totalMined', 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); }