Persist combat vitals in save data and restore damage on load

- Capture hull/shields/energy from the live CombatSystem into record.combat on save
- Stage `combatVitals` in prepareLoad so a panel load restores the damage the run was flying with (clamped, hull floored at 1) instead of always starting full
- Add `repairFull()` to CombatSystem and call it on world landing (startLanding) so the dock heals the ship
- Set `combatRepair` flag in jumpThroughGate's record so a system arrival also triggers a full repair, while panel loads never carry it
- Clear both staged values in resetRunState for fresh runs
- Carry `cargoCosts` through jump records to preserve per-bay pricing across systems
This commit is contained in:
Brian Fertig 2026-09-28 19:06:11 -06:00
parent ab7cd1857e
commit aca351b035
4 changed files with 126 additions and 7 deletions

View File

@ -73,6 +73,10 @@ const fakeScene = () => {
],
},
playTimeMs: 123456,
// THE SHIP'S VITALS (hull/shields/energy — combat's live state): the
// damage the run is flying with rides the save (a load restores it,
// the landing is the repair).
combat: { hull: 42, shields: 7, energy: 33, xp: { level: 3, xp: 90 } },
// THE ORIGIN WORLDS' REMAINING DAILY BATCHES (model v2 — the economy's
// only state): a partial batch for the current day rides the save.
economyStock: { [system.id]: { day: 1234, left: 2 } },
@ -283,6 +287,8 @@ const makeStorage = (fail = false) => {
Array.isArray(rec.comms?.acked) && rec.comms.acked.includes('carol-delivered-1') && rec.comms.acked.includes('oac-delivered-1'));
check('capture: the combat done-ledger is carried (record.combat.done)',
Array.isArray(rec.combat?.done) && rec.combat.done.includes('pirate_ambush'));
check('capture: the ship\u2019s vitals are carried (record.combat hull/shields/energy — the damage the run flies with)',
rec.combat?.hull === 42 && rec.combat?.shields === 7 && rec.combat?.energy === 33);
check('capture: the story ledger rides (record.story.delivered + capitalReturned)',
rec.story?.delivered === true && rec.story?.capitalReturned === true);
@ -320,6 +326,10 @@ const makeStorage = (fail = false) => {
&& reg.get('commsAcked').size === 2);
check('prepare: the combat done-ledger is restored (a Set, same ids)',
reg.get('combatDone') instanceof Set && reg.get('combatDone').has('pirate_ambush') && reg.get('combatDone').size === 1);
check('prepare: the ship\u2019s vitals are staged (combatVitals — the load restores the damage, not a full ship)',
JSON.stringify(reg.get('combatVitals')) === JSON.stringify({ hull: 42, shields: 7, energy: 33 }));
check('prepare: a panel load stages NO arrival repair (the damage survives the load)',
reg.get('combatRepair') === false);
check('prepare: the story ledger is restored (delivered + capitalReturned)',
reg.get('story')?.delivered === true && reg.get('story')?.capitalReturned === true);
check('prepare: the batch remainders are restored (registry.economyStock)',
@ -360,6 +370,15 @@ const makeStorage = (fail = false) => {
regNoBuilds.get('commsAcked') instanceof Set && regNoBuilds.get('commsAcked').size === 0);
check('prepare: a legacy record (no combat field) stages an empty done-ledger',
regNoBuilds.get('combatDone') instanceof Set && regNoBuilds.get('combatDone').size === 0);
check('prepare: a legacy record (no combat vitals) stages null vitals (the fresh ship stands full)',
regNoBuilds.get('combatVitals') === null);
check('prepare: a legacy record (no combatRepair) stages no arrival repair',
regNoBuilds.get('combatRepair') === false);
// A JUMP\u2019s record (jumpThroughGate sets the flag) heals on arrival.
const regJump = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } };
prepareLoad(regJump, makeRec({ combatRepair: true, combat: { hull: 12, shields: 3, energy: 8 } }));
check('prepare: a jump record stages the arrival repair (system landing = full repair)',
regJump.get('combatRepair') === true && regJump.get('combatVitals')?.hull === 12);
check('prepare: a legacy record (no story field) stages the fresh story ledger',
regNoBuilds.get('story')?.capitalLanded === false && regNoBuilds.get('story')?.delivered === false && regNoBuilds.get('story')?.capitalReturned === false);
check('prepare: a legacy record (no economyStock) restores an empty batch state',
@ -381,8 +400,12 @@ const makeStorage = (fail = false) => {
reg.set('commsPlayed', new Set(['oac-intro']));
reg.set('commsAcked', new Set(['oac-intro']));
reg.set('combatDone', new Set(['raid']));
reg.set('combatVitals', { hull: 1, shields: 1, energy: 1 });
reg.set('combatRepair', true);
reg.set(PENDING_RESTORE_KEY, { seed: SEED, ship: { x: 0, y: 0, heading: 0 } });
resetRunState(reg);
check('reset: the combat vitals are cleared (the new run\u2019s ship stands full)', reg.get('combatVitals') === null);
check('reset: the arrival repair flag is cleared', reg.get('combatRepair') === false);
check('reset: discovery cleared (fresh run)', reg.get('discovery') === null);
check('reset: depleted fields cleared (fresh run)', reg.get('depletedClusters') === null);
check('reset: staged restore cleared', reg.get(PENDING_RESTORE_KEY) === null);

View File

@ -147,11 +147,27 @@ export class CombatSystem {
// the system's share of it.
this._levelUnsub = this.xp.on('levelup', (ev) => this._onLevelUp(ev));
// Apply the CURRENT level's armor (a restored ledger's level — a load
// fires no events, so the bump lands here, not on the event), then a
// fresh scene is a fresh ship (full armor, whatever the level).
// fires no events, so the bump lands here, not on the event), then
// settle the ship's VITALS: o.vitals (SaveData's record.combat
// hull/shields/energy, staged by prepareLoad as 'combatVitals')
// restores the DAMAGE the run was flying with, clamped to this
// level's max — a load is not a repair. Missing or invalid numbers
// (a save predating the fields, combat off, a fresh run) fall back
// to the fresh ship (full armor). The full repair instead happens on
// a WORLD LANDING (GameScene.startLanding → repairFull) and a
// SYSTEM ARRIVAL (a jump's 'combatRepair' flag — the destination's
// dock stands in for the cut).
this._applyArmorToLevel();
this.hull = this.maxHull;
this.shields = this.maxShields;
const vit = o.vitals && typeof o.vitals === 'object' ? o.vitals : null;
const settle = (n, max) => {
const x = Number(n);
return Number.isFinite(x) ? Math.max(0, Math.min(max, x)) : max;
};
// A restored run is ALIVE — the hull floors at 1 (a hull of 0 is a
// death, and death ends the run; a degenerate save captured on the
// dying frame must still load as a flying ship).
this.hull = Math.max(1, settle(vit?.hull, this.maxHull));
this.shields = settle(vit?.shields, this.maxShields);
this.playerRespawn = p.respawn !== false;
this.playerRespawnDelay = (Number(p.respawnDelay) || 2) * 1000;
this.playerDown = false;
@ -163,7 +179,7 @@ export class CombatSystem {
// shows it.
const en = config.section('combat.energy', {});
this.maxEnergy = Math.max(0, Number(en.max) || 100);
this.energy = this.maxEnergy;
this.energy = settle(vit?.energy, this.maxEnergy); // the drain rides the load too
this.energyRegen = Math.max(0, Number(en.regen) || 0);
// Main-game combat (→ game).
@ -1462,6 +1478,20 @@ export class CombatSystem {
}
}
/**
* The dock's REPAIR — a WORLD LANDING (planet/station) puts the ship in
* the bay: hull + shields + energy back to max (GameScene.startLanding
* calls it; the system-arrival repair is the same settle via the
* constructor's o.vitals). The respawn's full-heal beat stands on its
* own.
*/
repairFull() {
this.hull = this.maxHull;
this.shields = this.maxShields;
this.energy = this.maxEnergy;
this.lastHitAt = -1e9; // the shield-regen grace clock stands fresh
}
/** The clock ran out — the player is back, full armor, in the same seat. */
revivePlayer(time) {
this.playerDown = false;

View File

@ -47,13 +47,27 @@
* // (js/skills/SkillState.js,
* // data/skills.json)
* combat: { done: [scriptName, …], // the CLEARED wave scripts
* level, xp }, // + the run's COMBAT XP
* // ledger (js/combat/
* 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
@ -320,6 +334,13 @@ export function captureState(scene, now) {
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).
@ -581,6 +602,27 @@ export function prepareLoad(registry, record) {
}
: 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
@ -653,6 +695,8 @@ export function resetRunState(registry) {
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

View File

@ -1120,9 +1120,18 @@ export class GameScene extends Phaser.Scene {
// data/combat.json → enemy.xp / enemyTypes). A save predating the
// ledger has no field → the run starts at level 0 (old saves load).
const savedXp = this.registry.get('combatXp');
// THE SHIP'S VITALS (hull/shields/energy): a LOAD restores the
// damage the run was flying with (record.combat → prepareLoad's
// 'combatVitals'); a JUMP's arrival is a SYSTEM LANDING — the
// 'combatRepair' flag (staged in jumpThroughGate's record) heals
// the ship full instead; a fresh run (neither staged) is a fresh
// ship (full armor).
const arrivalRepair = this.registry.get('combatRepair') === true;
this.registry.set('combatRepair', false);
this.combat = new CombatSystem(this, {
ship: this.ship,
xp: savedXp ? XpManager.fromJSON(savedXp) : new XpManager(),
vitals: arrivalRepair ? null : (this.registry.get('combatVitals') ?? null),
onActiveChange: (active, neutral) => this._setCombatBar(active, neutral),
// The readability layer's bookkeeping (js/combat/CombatHud.js —
// the same shared layer the range runs): a kill lands in the feed,
@ -3684,6 +3693,10 @@ export class GameScene extends Phaser.Scene {
// the field (the nearest-gate fallback).
rec.lastJumpFrom = from.id;
rec.systemName = dest.name;
// SYSTEM ARRIVAL = LANDING — the destination's dock repairs the ship
// (hull/shields/energy full) the moment the cut lands. A panel LOAD
// never carries the key, so a save's damage survives its load.
rec.combatRepair = true;
rec.ship = {
x: arrival.x,
y: arrival.y,
@ -3699,6 +3712,10 @@ export class GameScene extends Phaser.Scene {
// restore would re-seed the fresh ship's 0 and shed the goods
// aboard (the 4-bay hold would die on the first jump).
cargoHold: rec.ship?.cargoHold ?? 0,
// The per-bay cost ledger rides the jump too — the manifest prices
// exits against what was ACTUALLY paid per bay; dropping it would
// re-price every bay at the catalog's own value on the way down.
cargoCosts: rec.ship?.cargoCosts ?? null,
};
rec.tethers = []; // the destination's native set re-forms in create()
prepareLoad(this.registry, rec);
@ -4229,6 +4246,11 @@ export class GameScene extends Phaser.Scene {
// where they are). A no-op unless the escort is the active contract in
// its 'failed' phase (a normal landing — the convoy intact — never resets).
this._escortResetForLanding();
// THE DOCK'S REPAIR — the world is a landing, not a jump: the bay
// puts the hull, shields and energy back to full (a combat-damaged
// ship or a drained-energy one alike — the surface is where ships
// get fixed; the run's other full-heal beat is the respawn's).
this.combat?.repairFull();
this.scene.launch('SurfaceScene', {
frame: Number(target.frame ?? target.sheetFrame ?? 0),
name,