diff --git a/dev/saves.test.mjs b/dev/saves.test.mjs index 44d3f3e..c20b68d 100644 --- a/dev/saves.test.mjs +++ b/dev/saves.test.mjs @@ -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); diff --git a/js/combat/CombatSystem.js b/js/combat/CombatSystem.js index 3df3109..74588d3 100644 --- a/js/combat/CombatSystem.js +++ b/js/combat/CombatSystem.js @@ -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; diff --git a/js/save/SaveData.js b/js/save/SaveData.js index 0d1fcb0..71fdb30 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -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 diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 3da13f8..6be4c98 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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,