/** * 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 * // + the completed checklist * // steps (monotonic witness) * sidequests: SideQuestState.toJSON() | null, // the JOB BOARD's ledger — * // the accepted + completed * // contract ids (the postings * // themselves regenerate from * // the seed — js/quests/ * // SideQuests.js) * combat: { done: [scriptName, …], // the CLEARED wave scripts * level, xp }, // + the run's COMBAT XP * // ledger (js/combat/ * // XpManager.js — the * // level + the xp banked * // toward the next; the * // curve is data/ * // progression.json) * totalMined: number, // lifetime asteroid mining * depletedClusters: [":"], // fields mined to nothing * economyStock: { [sysId]: { day, left } }, // the origin worlds' * // REMAINING daily batches * // (model v2's one * // stateful economy piece * // — js/economy/Stock.js) * story: { capitalLanded, launchedFromCapital, lastLandedWorld, delivered }, * // the 'Special Delivery' witnesses + the surface TRADE gate * // (GameScene._recordLanding / onSurfaceDeparture / * // onQuestPackageSold — delivered = the home-world SALE of * // the package, the delivery's proof) * 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 { SideQuestState, allSideQuestJobIds } from '../quests/SideQuests.js'; import { SAVE_FORMAT, SaveManager } from './SaveManager.js'; export const PENDING_RESTORE_KEY = 'orbit.pendingRestore'; /** * Normalize the economy stock map to its save-safe shape — * { [systemId]: { day: int, left: int } } (plain, integers only; a * corrupted entry is dropped, not propagated). The economy's one * stateful piece: the origin worlds' REMAINING daily batches * (js/economy/Stock.js — the time-gated buy side; the sell side is * unlimited and the prices/floors are pure seed derivations). */ function cleanEconomyStock(map) { const out = {}; if (map && typeof map === 'object') { for (const [k, v] of Object.entries(map)) { if (typeof k !== 'string' || !k) continue; if (!v || typeof v !== 'object') continue; if (!Number.isFinite(v.day) || !Number.isFinite(v.left)) continue; out[k] = { day: Math.max(0, Math.floor(Number(v.day))), left: Math.max(0, Math.floor(Number(v.left))) }; } } return out; } /** * 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, // The last JUMP's source system (GameScene.lastJumpFromSystem — // jumpThroughGate stages it into the restore record; the scene // re-reads it from its own restore). The 'Pirate Problems' // ambush's midpoint is measured from the ENTRY GATE // (returnGateFor(content, lastJumpFrom) — // GameScene._pirateAmbushEntryGate), so the field rides saves and a // reload keeps the same midpoint. A save predating it has no field; // the restore treats the absence as a fresh run (the ambush's // nearest-gate fallback). lastJumpFrom: typeof scene.lastJumpFromSystem === 'string' ? scene.lastJumpFromSystem : 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)), // The credit ledger (data/economy.json → credits) — the trade // currency. Saves predating it lack the field; the restore treats // the absence as 0 (old saves load). credits: Math.max(0, Math.round(Number(scene.ship.credits) || 0)), // The trade-goods hold (data/economy.json → cargo) — the good ids in // the filled bays, in fill order (one good per bay). cargo: scene.ship.cargo ? [...scene.ship.cargo.items] : [], // The UNLOCKED cargo bays (ship.stats.cargoHold) — the run's ship // starts with 0 (data/ship.json); the bar scene's DONE press builds // it out (GameScene.onVignetteComplete). It is SHIP state (like the // hold above), so the save carries it — a load restores it before // the goods go back in (a save predating the field has none — the // fresh ship's stat stands, old saves load). cargoHold: Math.max(0, Math.floor(Number(scene.ship.stats?.cargoHold) || 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), // which rewards have been CLAIMED, and which checklist STEPS have read // DONE (the completed set — the monotonic witness: a step that stands // on the home world keeps standing after the ship leaves the home // system; the rest of the progress is computed live from the run's // state, GameScene.questSnapshot). A save predating quests has no // field (and a save predating the completed set has no done field — // the live evaluation stands alone, exactly as before); the restore // stages null and the starter quest is granted onto the fresh ledger // (old saves load). quests: scene.questState ? scene.questState.toJSON() : null, // SIDE QUESTS — the run's JOB BOARD ledger (data/sidequests.json, // js/quests/SideQuests.js): which posted contracts are ACCEPTED and // which of those have COMPLETED (the postings themselves are seed // derivations — a save = seed + player state). A save predating the // feature has no field (and the restore stages null — the fresh run's // GameScene starts an empty ledger; old saves load). sidequests: scene.sideQuestState ? scene.sideQuestState.toJSON() : null, // Comms — the run's ALREADY-PLAYED ids (data/comms.json: the `once` // comms the run has heard, so a load doesn't re-fire them and a new // game re-plays the welcome — INCLUDING Trestle's 'Leaving the Nest' // beats: trestle-brief-1/2/3 + trestle-arrive-1/2, the witnesses of the // quest's two social checks, GameScene._trestleCheck). The ledger // lives in the registry // ('commsPlayed' — js/comms/CommsHub.js owns it). A save predating // comms has no field; the restore stages an empty set (old saves // load). comms: { // The ALREADY-PLAYED ids (marked on the comm's START) — the 'once' // comms' one-shot ledger (above). played: Array.from(scene.registry.get('commsPlayed') ?? []), // The ALREADY-ACKED ids (data/comms.json → type 'ack' comms; marked // on the ACKNOWLEDGE press — CommsHub's onAck hook, the OPPOSITE // mark of the played-ledger's: a chain's final beat can have // started and still be un-ACKed). 'Pirate Problems' (data/quests.json // → pirate_problems.unlocks.acked) stands on this: its grant waits // for the 'Special Delivery' payoff chain to be HEARD ALL THE WAY // THROUGH. A save predating it has no field; the restore stages an // empty set (old saves load; the quest simply hasn't armed yet). acked: Array.from(scene.registry.get('commsAcked') ?? []), }, // Vignettes — the run's ALREADY-COMPLETED scene ids (data/vignettes.json: // the `once` scenes the run has finished, so a load doesn't re-play them // and a new game re-plays them). Marked on COMPLETION (the opposite of // comms' mark-on-start — a conversation is a place: leave mid-scene and // it re-arms on the next visit), which is also the step's WITNESS — the // 'vignettePlayed' quest check (GameScene._questCheckEval) reads this // same ledger. The ledger lives in the registry // ('vignettesPlayed' — js/vignettes/VignetteHub.js owns it). A save // predating vignettes has no field; the restore stages an empty set // (old saves load). vignettes: { played: Array.from(scene.registry.get('vignettesPlayed') ?? []) }, // COMBAT — the run's CLEARED WAVE SCRIPTS (data/combat.json → // waves.scripts names; the 'Pirate Problems' step — check type // 'combatCleared' — is the first to stand on one). Marked on the // run's COMPLETION (CombatSystem's onComplete → GameScene. // _recordCombatDone — the OPPOSITE mark of a comm's: a wave's IN- // FLIGHT state is scene-local and dies with the scene, so the // ledger — not the run — is the step's witness: a mid-fight quit // re-arms the ambush on load, but a cleared one never re-fires). // The ledger lives in the registry ('combatDone'). A save predating // it has no field; the restore stages an empty set (old saves // load — the step simply hasn't earned its witness yet). PLUS the // run's COMBAT XP ledger (js/combat/XpManager.js — the curve is // data/progression.json): level + the xp banked toward the next // (at the cap, the banked overflow — harmless). A save predating // it has no fields; the restore stages a fresh ledger (old saves // load — the run starts at level 0). combat: { done: Array.from(scene.registry.get('combatDone') ?? []), level: Math.max(0, Math.floor(Number(scene.combat?.xp?.level) || 0)), xp: Math.max(0, Math.round(Number(scene.combat?.xp?.xp) || 0)), }, // 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 RUN'S STORY LEDGER (data/quests.json → 'Special Delivery' — the // neutral capital's bar, the package handoff, the delivery leg — and // the surface TRADE gate: GameScene.isTradeUnlocked — the shop's // Buy/Sell tab). The witnesses live // on the scene (this.story, the registry's 'story'); a save predating // it has no field, and the restore stages the fresh (all-false) ledger // (old saves load — the trade gate re-arms on the next capital landing; // the handoff rides the bar scene's DONE press, onVignetteComplete). story: { capitalLanded: scene.story?.capitalLanded === true, launchedFromCapital: scene.story?.launchedFromCapital === true, lastLandedWorld: typeof scene.story?.lastLandedWorld === 'string' && scene.story.lastLandedWorld ? scene.story.lastLandedWorld : null, delivered: scene.story?.delivered === true, // step 3's proof — the home-world sale (monotonic) // 'Pirate Problems' step 3 — the RETURN arrival at the capital // world (GameScene._maybeWitnessCapitalReturn set it: the ship // within the world's keep-out rim while the quest is held + // unclaimed — monotonic; the 'capitalReturned' check reads it). // A save predating it has no field; the restore stages false // (old saves load). capitalReturned: scene.story?.capitalReturned === true, }, // DEPLETED asteroid fields — the clusters the run has mined to // NOTHING (ids keyed ":"), so a load keeps them // gone: the content regenerates from the seed and GameScene splices // these out (world + map). A save predating it has no field; the // restore stages an empty set (old saves load). depletedClusters: Array.from(scene.depletedClusters ?? []), // THE ORIGIN WORLDS' REMAINING DAILY BATCHES (model v2 — the economy's // one stateful piece, the time-gated BUY side: the world's signature // good sells out for the day and regenerates on the day clock; // js/economy/Stock.js). { [systemId]: { day, left } }. A save // predating it has no field; the restore stages {} (a full batch for // today — old saves load). economyStock: cleanEconomyStock(scene.economyStock), // 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); // The run's SIDE QUEST ledger (the job board's accepted/completed // contract ids — a save predating the feature has no field → null; // the fresh run's GameScene starts an empty ledger; old saves load). // Validated against the save's OWN job space (allSideQuestJobIds of the // rebuilt galaxy) so a foreign or hand-edited id degrades instead of // sticking; the completed ⊆ accepted invariant is re-established. registry.set( 'sidequests', record.sidequests ? SideQuestState.fromJSON(record.sidequests, allSideQuestJobIds(galaxy.records)) : null, ); // Lifetime asteroid mining (a save predating it has no field → 0). registry.set('totalMined', Math.max(0, Math.round(Number(record.totalMined) || 0))); // THE RUN'S STORY LEDGER (a save predating it has no field → the fresh // all-false ledger — old saves load; the trade gate re-arms on the next // capital landing; the handoff rides the bar scene's DONE press). // Normalized here so a hand-edited record can't leak a bad shape into // the scene. registry.set('story', { capitalLanded: record.story?.capitalLanded === true, launchedFromCapital: record.story?.launchedFromCapital === true, lastLandedWorld: typeof record.story?.lastLandedWorld === 'string' && record.story.lastLandedWorld ? record.story.lastLandedWorld : null, delivered: record.story?.delivered === true, // 'Pirate Problems' step 3 — the return ARRIVAL at the capital world // (GameScene._maybeWitnessCapitalReturn set it; monotonic, the // 'capitalReturned' check reads it). A save predating it has no // field → false (old saves load; the step simply stands open). capitalReturned: record.story?.capitalReturned === true, }); // DEPLETED asteroid fields (a save predating them has no field → empty // set — old saves load; GameScene.create() splices them out of the // regenerated content of the system the run is in). registry.set( 'depletedClusters', new Set(Array.isArray(record.depletedClusters) ? record.depletedClusters : []), ); // THE ORIGIN WORLDS' REMAINING DAILY BATCHES (model v2 — a save // predating it has no field → {} — a full batch for today; old saves // load). Normalized so a hand-edited record can't leak a bad shape into // the scene (Stock.batchRemaining re-checks the day + range anyway). registry.set('economyStock', cleanEconomyStock(record.economyStock)); // The run's COMMS played-ledger (a save predating comms has no field → // an empty set — old saves load; a fresh run's welcome arms on its own // 'newGame' trigger in GameScene.create()). registry.set( 'commsPlayed', new Set( Array.isArray(record.comms?.played) ? record.comms.played.filter((id) => typeof id === 'string') : [], ), ); // The run's COMMS ACKED-ledger (a save predating it has no field → an // empty set — old saves load; 'Pirate Problems' simply hasn't armed). // Marked on the ACKNOWLEDGE press (the opposite of the played-ledger's // mark-on-start) by CommsHub's onAck hook (GameScene._commAcked). registry.set( 'commsAcked', new Set( Array.isArray(record.comms?.acked) ? record.comms.acked.filter((id) => typeof id === 'string') : [], ), ); // The run's COMBAT DONE-ledger — the WAVE RUNS cleared (data/combat.json // → waves.scripts names; CombatSystem's onComplete marks it via // GameScene._recordCombatDone). 'Pirate Problems'' 'Defend your ship' // step (check type 'combatCleared') reads it — the combat's monotonic // witness (a wave re-fired on a load stands on the script name, so a // cleared ambush never re-fights once its run finished). A save // predating it has no field → an empty set (old saves load). registry.set( 'combatDone', new Set( Array.isArray(record.combat?.done) ? record.combat.done.filter((id) => typeof id === 'string') : [], ), ); // The run's COMBAT XP ledger (js/combat/XpManager.js — level + the xp // banked toward the next). A save predating it has no fields → null // (a fresh run starts at level 0; old saves load). Normalized so a // hand-edited record can't leak a bad shape into the scene (the // manager's restore re-clamps anyway). registry.set( 'combatXp', Number(record.combat?.level) > 0 || Number(record.combat?.xp) > 0 ? { level: Math.max(0, Math.floor(Number(record.combat?.level) || 0)), xp: Math.max(0, Math.floor(Number(record.combat?.xp) || 0)), } : null, ); // The run's VIGNETTES played-ledger (a save predating vignettes has no // field → an empty set — old saves load; a fresh run's scenes arm on // their quest gates in data/vignettes.json). registry.set( 'vignettesPlayed', new Set( Array.isArray(record.vignettes?.played) ? record.vignettes.played.filter((id) => typeof id === 'string') : [], ), ); 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('sidequests', null); // the new run's contracts stand fresh registry.set('totalMined', null); registry.set('depletedClusters', null); registry.set('economyStock', null); // the new run's batches are full registry.set('story', null); // the bar, the handoff, the leg — all fresh registry.set('commsPlayed', null); // the welcome arms again on the new run registry.set('commsAcked', null); // the ack gates re-arm on the new run registry.set('combatDone', null); // the cleared waves re-arm on the new run registry.set('combatXp', null); // the new run's level + xp stand fresh registry.set('vignettesPlayed', null); // the scenes re-arm on the new run 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); }