orbit/js/save/SaveData.js

710 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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: ["<sysId>><destId>"], // the SYSTEM research
* // category's activation
* // keys (registry-backed
* // Set, replayed on load —
* // gates + tethers re-form
* // in GameScene.create)
* visitedSystems: ["<sysId>", …], // the systems the run
* // ENTERED (the GALAXY
* // tab's charted region)
* usedGates: ["<sysId><destId>", …], // 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)
* playerClass: { id, perks: [id, …], // THE CLASS LAYER — the class
* skillPoints, granted }, // on file + its granted perks
* // + the banked skill points
* // (js/player/ClassState.js,
* // data/classes.json)
* playerSkills: { ranks: {id: n}, // THE SKILL LAYER — points
* seats: [id|null, …8], // spent per skill + the
* defaultsApplied: bool } // combat bar's bindings
* // (js/skills/SkillState.js,
* // data/skills.json)
* combat: { done: [scriptName, …], // the CLEARED wave scripts
* level, xp, // + the run's COMBAT XP
* hull, shields, energy }, // ledger (js/combat/
* // XpManager.js — the
* // level + the xp banked
* // toward the next; the
* // curve is data/
* // progression.json)
* // + the ship's VITALS
* // (CombatSystem's
* // hull/shields/energy —
* // the LOAD restores the
* // damage; the full
* // repair is the
* // landing's beat)
* combatRepair: true, // set by a JUMP's record
* // (jumpThroughGate):
* // system ARRIVAL is a
* // landing — the ship
* // heals full. A panel
* // load never carries it
* // (damage survives).
* totalMined: number, // lifetime asteroid mining
* pirateKills: { [sysId]: int }, // per-system PIRATE KILL
* // tallies (the reputation
* // milestone's input —
* // data/reputation.json →
* // bonuses.pirateKills:
* // every 10 kills, every
* // place in the system
* // gains +1 standing)
* depletedClusters: ["<sysId>:<clusterId>"], // 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 { ClassState } from '../player/ClassState.js';
import { SkillState } from '../skills/SkillState.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;
}
/**
* Normalize the per-system pirate-kill tallies to their save-safe shape
* — { [systemId]: int ≥ 0 } (plain, integers only; a corrupted entry is
* dropped, not propagated). The reputation milestone's input (the scene
* keeps the map — data/reputation.json → bonuses.pirateKills: every
* `every` kills in a system, every place in it gains `bonus` standing,
* GameScene.recordPirateKill).
*/
function cleanPirateKills(map) {
const out = {};
if (map && typeof map === 'object') {
for (const [k, v] of Object.entries(map)) {
if (typeof k !== 'string' || !k) continue;
const n = Math.floor(Number(v));
if (!Number.isFinite(n) || n < 0) continue;
out[k] = n;
}
}
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 per-bay purchase-cost ledger (PARALLEL to cargo — the credits/unit
// the player actually paid for each bay, null where a bay has no cost
// on file: the quest package's handoff). The ship console's bay
// manifest compares its exit sell prices against it. A save predating
// the field lacks it; the restore treats the absence as "no costs on
// file" (old saves load).
cargoCosts: scene.ship.cargo ? [...scene.ship.cargo.costs] : null,
// 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).
// THE CLASS LAYER (js/player/ClassState.js, data/classes.json) — the class
// the run locked in at combat level 5, the perks its initial bonus paid
// (the combat code's gates: dualCannon / targetingComputer) and the skill
// points banked with them. `granted` is the bonus's idempotency witness.
// A save predating the class layer has no field; the restore stages an
// empty ledger (old saves load — the run simply has no class yet).
playerClass: (scene.classState ?? scene.registry.get('playerClass') ?? null)?.toJSON?.() ?? null,
// THE SKILL LAYER (js/skills/SkillState.js, data/skills.json) — the points
// spent in the class's tree and which of them sit on the combat bar's eight
// seats. The point BANK is not restated here (it rides playerClass above).
// A save predating the skill layer has no field; the restore stages an
// empty ledger (old saves load — the run simply has no ranks yet).
playerSkills: (scene.skillState ?? scene.registry.get('playerSkills') ?? null)?.toJSON?.() ?? null,
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)),
// THE SHIP'S VITALS (hull / shields / energy — the damage the run
// is flying with). A live combat system carries them (null when
// combat is off / a save predating the fields → the load falls back
// to the fresh ship's full repair — old saves load).
hull: scene.combat ? Math.max(0, Math.round(Number(scene.combat.hull) || 0)) : null,
shields: scene.combat ? Math.max(0, Math.round(Number(scene.combat.shields) || 0)) : null,
energy: scene.combat ? Math.max(0, Math.round(Number(scene.combat.energy) || 0)) : 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)),
// PER-SYSTEM PIRATE KILL TALLIES (the reputation milestone's input —
// data/reputation.json → bonuses.pirateKills: every 10 pirates put
// down in a system, every place in it gains +1 standing; the tally
// keeps counting across saves). A save predating it has no field;
// the restore stages {} (a fresh tally — old saves load).
pirateKills: cleanPirateKills(scene.pirateKillsBySystem),
// 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 "<systemId>:<clusterId>"), 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, kind } | 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). The
// KIND is the mark's owner ('manual' the map's pick, 'cargo' the
// player's cargo delivery, 'quest' a story beat — GameScene
// ._routeMarkColor: cargo reads brown, the rest the route's orange);
// a save predating it has no kind → 'manual' (the old orange look).
destination:
scene.destination && typeof scene.destination.systemId === 'string'
? {
systemId: scene.destination.systemId,
objectId: scene.destination.objectId ?? null,
kind: scene.destination.kind ?? 'manual',
}
: 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. The kind
// (mark's owner — cargo deliveries read brown) rides along; a save
// predating it has no kind → 'manual' (the route's orange).
registry.set(
'destination',
record.destination && typeof record.destination.systemId === 'string'
? {
systemId: record.destination.systemId,
objectId: record.destination.objectId ?? null,
kind: record.destination.kind ?? 'manual',
}
: 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 — each system's SURFACE WORLDS × the slot range ×
// the four types) so a foreign or hand-edited id degrades instead of
// sticking; the board is PER-WORLD now, so OLD per-system ids
// (sq-\u003csys\u003e-\u003ccat\u003e-\u003cslot\u003e — the pre-per-world shape) no longer stand in
// the space and drop (Path A's documented trade: an in-flight contract
// in an old save is lost, the rest of the run survives). The
// completed ⊆ accepted invariant is re-established.
registry.set(
'sidequests',
record.sidequests
? SideQuestState.fromJSON(record.sidequests, allSideQuestJobIds(galaxy))
: 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 PIRATE KILL TALLIES (the reputation milestone's input — a
// save predating it has no field → {} — old saves load; a fresh run's
// GameScene starts an empty tally). Normalized so a hand-edited
// record can't leak a bad shape into the scene.
registry.set('pirateKillsBySystem', cleanPirateKills(record.pirateKills));
// 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 SHIP'S VITALS (record.combat hull/shields/energy) — handed to the
// fresh CombatSystem (o.vitals): the LOAD restores the damage the run
// was flying with (clamped to its max), NOT a full ship — the full
// repair is the landing's beat (a world landing heals; a jump's arrival
// heals via the flag below). A save predating the fields (or with
// combat off) stages null → the fresh ship's full armor stands.
registry.set(
'combatVitals',
record.combat && typeof record.combat === 'object'
? {
hull: record.combat.hull ?? null,
shields: record.combat.shields ?? null,
energy: record.combat.energy ?? null,
}
: null,
);
// A jump's ARRIVAL is a LANDING — jumpThroughGate sets the flag on its
// record, and the fresh scene heals the ship full instead of restoring
// the saved damage (GameScene.create consumes it). A panel load never
// carries the key → the damage survives the load.
registry.set('combatRepair', record.combatRepair === true);
// THE CLASS LEDGER (js/player/ClassState.js) — the class on file, its
// granted perks and the banked skill points. A save predating the class
// layer has no field → null (a fresh run picks its class at level 5; old
// saves load with no class on file). Validated against the CURRENT roster,
// so a hand-edited or unknown class id degrades to "no class" instead of
// handing out perks that do not exist.
registry.set('playerClass', record.playerClass ? ClassState.fromJSON(record.playerClass) : null);
// THE SKILL LEDGER (js/skills/SkillState.js) — ranks + bar bindings, built on
// top of the class ledger just staged (the bank it spends from is the class's).
// Validated against the CURRENT roster: an unknown skill id drops its rank and
// a seat wearing a passive or an unknown skill clears, so a hand-edited record
// can never put something on the bar that does not exist. A save predating the
// skill layer has no field → null (a fresh run ranks nothing yet).
registry.set(
'playerSkills',
record.playerSkills ? SkillState.fromJSON(record.playerSkills, registry.get('playerClass') ?? null) : 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('pirateKillsBySystem', null); // the new run's tallies stand fresh
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('combatVitals', null); // the new run's ship stands full
registry.set('combatRepair', false); // no arrival repair is staged
registry.set('playerClass', null); // the new run picks its class again
registry.set('playerSkills', null); // …and ranks its tree from scratch
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);
}