From d3ebb8e2a0675865590cfe5bdb85b324de4381f5 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 26 Sep 2026 09:50:50 -0600 Subject: [PATCH] Add pirate-kill reputation milestone with per-system tally persistence - Every N pirates killed in a system (default 10) grants +1 standing to all settlements in that system, configured via data/reputation.json bonuses.pirateKills - Track per-system kill tallies as registry-backed run state, persisted through SaveData.js capture/prepare/reset with normalization and legacy-save fallback - Wire the payout into GameScene.recordPirateKill, called from CombatSystem's onKill seam, clamping at max standing and showing a toast when at least one place moves - Add reputation tests covering the milestone data shape, standing writes, save round-trip, garbage-entry rejection, legacy-load, and New Game reset --- data/reputation.json | 5 +-- dev/reputation.test.mjs | 69 +++++++++++++++++++++++++++++++++++++++++ js/save/SaveData.js | 41 ++++++++++++++++++++++++ js/scenes/GameScene.js | 60 +++++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 2 deletions(-) diff --git a/data/reputation.json b/data/reputation.json index 3f92adc..dd6dafb 100644 --- a/data/reputation.json +++ b/data/reputation.json @@ -4,9 +4,10 @@ "max": 20, "neutral": 0, "home": 20, - "_bonuses": "bonuses — the standing's BONUSES (js/reputation/Reputation.js → creditFraction, applied by GameScene): each is a FRACTION OF THE SCALE'S TOP (max) — sideQuest = what a COMPLETED + CLAIMED side quest earns on the world that POSTED it (10% → +2 at the default +20); perGoodSold = what every GOOD SOLD on a world earns it (2% → +0.4 per unit — the sub-point fraction credits across sales until it pays out whole points, so five sales land +2). Both are no-ops on the home world (pinned at max — the bonus can't move what nothing moves).", + "_bonuses": "bonuses — the standing's BONUSES (js/reputation/Reputation.js → creditFraction, applied by GameScene): each is a FRACTION OF THE SCALE'S TOP (max) — sideQuest = what a COMPLETED + CLAIMED side quest earns on the world that POSTED it (10% → +2 at the default +20); perGoodSold = what every GOOD SOLD on a world earns it (2% → +0.4 per unit — the sub-point fraction credits across sales until it pays out whole points, so five sales land +2); pirateKills = the SYSTEM KILL MILESTONE (every `every` pirates the run puts down in a system — 10 by default — +`bonus` standing, 1 by default, lands on EVERY place in that system — each planet's anchored post + the free-space stations; the per-system tally is the save's `pirateKills` field, js/save/SaveData.js, and the payout lands through GameScene.recordPirateKill's rep.change — so it clamps at max, and a place already at the top simply stands). The per-world bonuses are no-ops on the home world (pinned at max — the bonus can't move what nothing moves).", "bonuses": { "sideQuest": 0.10, - "perGoodSold": 0.02 + "perGoodSold": 0.02, + "pirateKills": { "every": 10, "bonus": 1 } } } diff --git a/dev/reputation.test.mjs b/dev/reputation.test.mjs index cee4d24..8c06d4b 100644 --- a/dev/reputation.test.mjs +++ b/dev/reputation.test.mjs @@ -351,6 +351,75 @@ const HOME = config.get('reputation.home', 20); check('non-numeric input lights nothing (0 marks)', bar.marksFor('famous') === 0); } +// ---------------------------------------------------------------------- +// P. The pirate-kill milestone (data → bonuses.pirateKills) — every 10 +// pirates put down in a system, every place in it gains +1 standing. +// (The payout itself lives on the Phaser scene — GameScene. +// recordPirateKill — so this drives its pure seams: the data, the +// tally's save round-trip, and the standing write it lands.) +// ---------------------------------------------------------------------- +{ + const pk = config.get('reputation.bonuses.pirateKills', {}); + check('the data carries the milestone (every 10 pirates → +1 standing)', + pk?.every === 10 && pk?.bonus === 1); + + // The standing write it lands: +1 on every place in the system (the + // system's settlements — the comms panel's standing keys), clamped at + // the top like any other change. + const repM = new Reputation(); + const places = [`${'S000200'}-s1`, `${'S000200'}-s2`]; + for (let kill = 1; kill <= 10; kill++) { + if (kill % pk.every === 0) for (const p of places) repM.change(p, pk.bonus); + } + check('after 10 kills in a system, every place in it has +1 standing', + repM.get(places[0]) === 1 && repM.get(places[1]) === 1); + check('…and the 11th kill moves nothing (the milestone pays once per 10)', + repM.get(places[0]) === 1 && repM.get(places[1]) === 1); + check('a place already at the top clamps instead of overtopping', + repM.set('S000201-s1', MAX), + repM.change('S000201-s1', pk.bonus) === false && repM.get('S000201-s1') === MAX); + + // The tally's save round-trip (the scene's map shape, normalized): + // captureState carries it, prepareLoad stages it back, and garbage + // entries are dropped instead of leaking into the scene. + const SEED = 'PIRATEKILLTEST'; + const galaxy = Galaxy.create(SEED, { systemCount: 8 }); + const system = galaxy.byId.get(galaxy.currentSystemId); + const fakeScene = { + registry: { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }, + galaxy, + systemRecord: { id: system.id, name: system.name }, + ship: { x: 1, y: 2, rotation: 0.5 }, + discovery: new Discovery(540), + reputation: new Reputation(), + pirateKillsBySystem: { [system.id]: 7, [`${system.id}-far`]: 13, bogus: 'lots', neg: -3 }, + tetherField: { tethers: [] }, + playTimeMs: 99, + }; + const rec = captureState(fakeScene); + check('captureState() carries the per-system tallies (garbage dropped)', + rec.pirateKills?.[system.id] === 7 + && rec.pirateKills?.[`${system.id}-far`] === 13 + && rec.pirateKills?.bogus === undefined + && rec.pirateKills?.neg === undefined + && SaveManager.validateRecord(rec) === null); + const reg = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }; + prepareLoad(reg, rec); + check('prepareLoad() stages the tallies back for the next scene', + reg.get('pirateKillsBySystem')?.[system.id] === 7); + // A save predating the milestone has no field — a fresh tally stands in. + const legacyRec = { ...rec }; + delete legacyRec.pirateKills; + const reg2 = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }; + prepareLoad(reg2, legacyRec); + check('a pre-milestone save loads with an empty tally (old saves load)', + reg2.get('pirateKillsBySystem') && Object.keys(reg2.get('pirateKillsBySystem')).length === 0); + // New Game wipes the tallies (a fresh run meets a fresh galaxy). + resetRunState(reg); + check('resetRunState() clears the tallies for a new run', + reg.get('pirateKillsBySystem') === null); +} + if (failures > 0) { console.error(`\n${failures} reputation test(s) FAILED`); process.exit(1); diff --git a/js/save/SaveData.js b/js/save/SaveData.js index bc94b6d..2d20e7e 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -45,6 +45,14 @@ * // 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: [":"], // fields mined to nothing * economyStock: { [sysId]: { day, left } }, // the origin worlds' * // REMAINING daily batches @@ -102,6 +110,27 @@ function cleanEconomyStock(map) { 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. * @@ -271,6 +300,12 @@ export function captureState(scene, now) { // 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 @@ -433,6 +468,11 @@ export function prepareLoad(registry, record) { ); // 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). @@ -563,6 +603,7 @@ export function resetRunState(registry) { 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 diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 42a01a2..790cb61 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -605,6 +605,17 @@ export class GameScene extends Phaser.Scene { (c) => !this.depletedClusters.has(`${sysId}:${c?.id}`), ); } + // PIRATE KILL TALLIES — the per-system count of the pirates the run + // has put down (data/reputation.json → bonuses.pirateKills — every + // `every` kills in a system, every place in it gains `bonus` + // standing; GameScene.recordPirateKill pays it out). Per-run world + // state — registry-backed (like depletedClusters): New Game clears + // it, a load restores it (js/save/SaveData.js). + this.pirateKillsBySystem = this.registry.get('pirateKillsBySystem') ?? null; + if (!this.pirateKillsBySystem) { + this.pirateKillsBySystem = {}; + this.registry.set('pirateKillsBySystem', this.pirateKillsBySystem); + } // JUMP GATE ACTIVATION (the SYSTEM research category — // js/research/SystemCategory.js): the run's activation keys are // per-run world state — registry-backed (like discovery: New Game @@ -1085,6 +1096,7 @@ export class GameScene extends Phaser.Scene { // is the only elite the game deploys, so its death IS the contract). onKill: (e) => { this.combatHud?.kill('HOSTILE DESTROYED'); + this.recordPirateKill(e); this.handleCombatKill(e); }, // THE PIRATE'S COMM LINES (data/combat.json → enemyTypes.raider.comms @@ -6374,6 +6386,54 @@ export class GameScene extends Phaser.Scene { return changed; } + /** + * Every pirate that goes down (CombatSystem's onKill seam): the SYSTEM + * KILL MILESTONE (data/reputation.json → bonuses.pirateKills — every + * `every` pirates put down in a system, 10 by default, +`bonus` + * standing, 1 by default, lands on EVERY place in it: the system's + * settlements — each planet's anchored post + the free-space stations, + * the comms panel's standing keys). The tally is PER-RUN, PER-SYSTEM + * state (this.pirateKillsBySystem — registry-backed, js/save/SaveData.js), + * so it keeps counting across saves and a fresh run starts from zero. + * A milestone pays out exactly once per crossing (tally % every === 0) + * through change() — the standing clamps at the top, so a place + * already at +20 simply doesn't move (and the toast stands only when + * at least one place actually moved). A no-op for a stub scene without + * the reputation seams (headless tests — the tally still stands) or + * with no live system. + * @param {object} [e] the fallen pirate (the onKill's enemy — the + * tally is per kill, the record is only the seam's shape) + * @returns {boolean} true when a milestone paid out + */ + recordPirateKill(e) { + void e; + const sysId = this.systemRecord?.id; + if (typeof sysId !== 'string' || sysId.length === 0) return false; + if (!this.pirateKillsBySystem) { + this.pirateKillsBySystem = {}; + this.registry?.set?.('pirateKillsBySystem', this.pirateKillsBySystem); + } + const tally = (Number(this.pirateKillsBySystem[sysId]) || 0) + 1; + this.pirateKillsBySystem[sysId] = tally; + const every = Math.floor(Number(config.get('reputation.bonuses.pirateKills.every', 10)) || 0); + const bonus = Number(config.get('reputation.bonuses.pirateKills.bonus', 1)) || 0; + if (!(every > 0) || !(bonus > 0) || tally % every !== 0) return false; + const rep = this.reputation; + if (!rep) return false; // a stub scene — the tally stands, the bump no-ops + let content = null; + try { content = this.galaxy?.ensureContent?.(sysId) ?? null; } catch { content = null; } + const places = (content?.settlements ?? []).filter((s) => s && typeof s.id === 'string'); + let moved = false; + for (const s of places) moved = rep.change(s.id, bonus) || moved; + if (moved) { + this.consoleToast( + `REPUTATION +${bonus} WITH EVERY WORLD IN THIS SYSTEM`, + { glyph: '★', glyphColor: toCss('#7ce8a4') }, + ); + } + return moved; + } + /** * The world that POSTED a side quest (the job id's world part — * parseSideQuestId's worldKey: a planet's `p` or a station's