622 lines
32 KiB
JavaScript
622 lines
32 KiB
JavaScript
/**
|
||
* 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)
|
||
* 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
|
||
* 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 { 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).
|
||
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)),
|
||
// 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 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('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);
|
||
}
|