orbit/js/save/SaveData.js

136 lines
5.0 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 },
* discovery: Discovery.toJSON(),
* reputation: Reputation.toJSON(),
* tethers: [{ id, x, y, level, label }],
* 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)
* @returns {object} the record (ready for SaveManager.put)
*/
export function captureState(scene) {
const galaxy = scene.galaxy;
if (!galaxy) throw new Error('no galaxy to save');
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),
},
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 ?? '',
})),
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));
registry.set(PENDING_RESTORE_KEY, {
ship: record.ship,
tethers: Array.isArray(record.tethers) ? record.tethers : [],
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, or a
* half-staged restore.
*/
export function resetRunState(registry) {
registry.set('discovery', null);
registry.set('reputation', 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);
}