221 lines
9.6 KiB
JavaScript
221 lines
9.6 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,
|
|
* playTimeMs }
|
|
*
|
|
* captureState(scene) — GameScene → record (the Save panel calls it)
|
|
* prepareLoad(registry, r) — record → shared registry: rebuilds the
|
|
* galaxy from the seed, restores discovery,
|
|
* and parks the ship/tether/playtime state
|
|
* under 'orbit.pendingRestore'
|
|
* consumeRestore(registry) — GameScene.create() picks the parked state
|
|
* off the registry (and clears it)
|
|
* resetRunState(registry) — "New Game" from the menu: a fresh run must
|
|
* not inherit the previous one's discovery
|
|
*
|
|
* Pure module (no Phaser) — the registry is just {get,set}, so Node
|
|
* tests can drive it (dev/saves.test.mjs).
|
|
*/
|
|
import { config } from '../config/Config.js';
|
|
import { Galaxy } from '../galaxy/Galaxy.js';
|
|
import { Discovery } from '../galaxy/Discovery.js';
|
|
import { Reputation } from '../reputation/Reputation.js';
|
|
import { SAVE_FORMAT, SaveManager } from './SaveManager.js';
|
|
|
|
export const PENDING_RESTORE_KEY = 'orbit.pendingRestore';
|
|
|
|
/**
|
|
* Snapshot the live game into a save record.
|
|
*
|
|
* @param {object} scene the GameScene (reads: registry, galaxy,
|
|
* systemRecord, ship, discovery, reputation, tetherField, playTimeMs)
|
|
* @param {number} [now] fallback live clock (ms) — see below; each
|
|
* subsystem captures on its own time base.
|
|
* @returns {object} the record (ready for SaveManager.put)
|
|
*/
|
|
export function captureState(scene, now) {
|
|
const galaxy = scene.galaxy;
|
|
if (!galaxy) throw new Error('no galaxy to save');
|
|
// Each subsystem's in-flight timer is captured on ITS OWN time base:
|
|
// - research: the GameScene's clock (beginResearch uses it — the scene
|
|
// is awake while research runs, so it is live);
|
|
// - builds: the game-loop clock (game.loop.now — the build's time base,
|
|
// global + monotonic, and still live while the GameScene SLEEPS
|
|
// during a surface stay; the caller's `now` or Date.now only as a
|
|
// last resort).
|
|
const researchNow = Number.isFinite(scene.time?.now) ? scene.time.now : Date.now();
|
|
const buildNow = Number.isFinite(scene.game?.loop?.now)
|
|
? scene.game.loop.now
|
|
: (Number.isFinite(now) ? now : Date.now());
|
|
const rec = {
|
|
app: 'orbit',
|
|
format: SAVE_FORMAT,
|
|
savedAt: new Date().toISOString(),
|
|
seed: scene.registry.get('seed') ?? galaxy.seed,
|
|
galaxyName: galaxy.name,
|
|
currentSystemId: galaxy.currentSystemId,
|
|
systemName: scene.systemRecord?.name ?? null,
|
|
ship: {
|
|
x: Number(scene.ship.x),
|
|
y: Number(scene.ship.y),
|
|
heading: Number(scene.ship.rotation),
|
|
// The hold (minerals aboard) — saves that predate it just lack the
|
|
// field; the restore treats a missing number as 0.
|
|
minerals: Math.max(0, Math.round(Number(scene.ship.minerals) || 0)),
|
|
},
|
|
discovery: scene.discovery ? scene.discovery.toJSON() : { distance: 540, bySystem: {} },
|
|
reputation: scene.reputation ? scene.reputation.toJSON() : new Reputation().toJSON(),
|
|
tethers: (scene.tetherField?.tethers ?? []).map((t) => ({
|
|
id: t.id,
|
|
x: t.x,
|
|
y: t.y,
|
|
level: t.level,
|
|
label: t.label ?? '',
|
|
})),
|
|
// Research — the unlocked set + the in-flight project (its remaining
|
|
// time is captured NOW on the scene's own clock; a save predating
|
|
// research has no field, and the restore treats the absence as "no
|
|
// research" (old saves load).
|
|
research: scene.researchState
|
|
? scene.researchState.toJSON(researchNow)
|
|
: null,
|
|
// Builds — the installed set + the in-flight build (remaining time on
|
|
// the game-loop clock — the build's time base; see above).
|
|
builds: scene.buildState
|
|
? scene.buildState.toJSON(buildNow)
|
|
: null,
|
|
// 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 ?? []),
|
|
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 : []),
|
|
);
|
|
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(PENDING_RESTORE_KEY, null);
|
|
}
|
|
|
|
/** Distance used when a save predates discovery (defensive fallback). */
|
|
export function defaultDiscoveryDistance() {
|
|
return config.get('game.discovery.distance', 540);
|
|
}
|