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
This commit is contained in:
Brian Fertig 2026-09-26 09:50:50 -06:00
parent 64da303c23
commit d3ebb8e2a0
4 changed files with 173 additions and 2 deletions

View File

@ -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 }
}
}

View File

@ -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);

View File

@ -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: ["<sysId>:<clusterId>"], // 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

View File

@ -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<ordinal>` or a station's