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:
parent
64da303c23
commit
d3ebb8e2a0
|
|
@ -4,9 +4,10 @@
|
||||||
"max": 20,
|
"max": 20,
|
||||||
"neutral": 0,
|
"neutral": 0,
|
||||||
"home": 20,
|
"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": {
|
"bonuses": {
|
||||||
"sideQuest": 0.10,
|
"sideQuest": 0.10,
|
||||||
"perGoodSold": 0.02
|
"perGoodSold": 0.02,
|
||||||
|
"pirateKills": { "every": 10, "bonus": 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,75 @@ const HOME = config.get('reputation.home', 20);
|
||||||
check('non-numeric input lights nothing (0 marks)', bar.marksFor('famous') === 0);
|
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) {
|
if (failures > 0) {
|
||||||
console.error(`\n${failures} reputation test(s) FAILED`);
|
console.error(`\n${failures} reputation test(s) FAILED`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,14 @@
|
||||||
* // curve is data/
|
* // curve is data/
|
||||||
* // progression.json)
|
* // progression.json)
|
||||||
* totalMined: number, // lifetime asteroid mining
|
* 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
|
* depletedClusters: ["<sysId>:<clusterId>"], // fields mined to nothing
|
||||||
* economyStock: { [sysId]: { day, left } }, // the origin worlds'
|
* economyStock: { [sysId]: { day, left } }, // the origin worlds'
|
||||||
* // REMAINING daily batches
|
* // REMAINING daily batches
|
||||||
|
|
@ -102,6 +110,27 @@ function cleanEconomyStock(map) {
|
||||||
return out;
|
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.
|
* 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).
|
// requirement's input — the hold is cargo, this is the record).
|
||||||
// A save predating it has no field; the restore stages 0.
|
// A save predating it has no field; the restore stages 0.
|
||||||
totalMined: Math.max(0, Math.round(Number(scene.totalMined) || 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
|
// THE RUN'S STORY LEDGER (data/quests.json → 'Special Delivery' — the
|
||||||
// neutral capital's bar, the package handoff, the delivery leg — and
|
// neutral capital's bar, the package handoff, the delivery leg — and
|
||||||
// the surface TRADE gate: GameScene.isTradeUnlocked — the shop's
|
// 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).
|
// Lifetime asteroid mining (a save predating it has no field → 0).
|
||||||
registry.set('totalMined', Math.max(0, Math.round(Number(record.totalMined) || 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
|
// 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
|
// 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).
|
// 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('quests', null);
|
||||||
registry.set('sidequests', null); // the new run's contracts stand fresh
|
registry.set('sidequests', null); // the new run's contracts stand fresh
|
||||||
registry.set('totalMined', null);
|
registry.set('totalMined', null);
|
||||||
|
registry.set('pirateKillsBySystem', null); // the new run's tallies stand fresh
|
||||||
registry.set('depletedClusters', null);
|
registry.set('depletedClusters', null);
|
||||||
registry.set('economyStock', null); // the new run's batches are full
|
registry.set('economyStock', null); // the new run's batches are full
|
||||||
registry.set('story', null); // the bar, the handoff, the leg — all fresh
|
registry.set('story', null); // the bar, the handoff, the leg — all fresh
|
||||||
|
|
|
||||||
|
|
@ -605,6 +605,17 @@ export class GameScene extends Phaser.Scene {
|
||||||
(c) => !this.depletedClusters.has(`${sysId}:${c?.id}`),
|
(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 —
|
// JUMP GATE ACTIVATION (the SYSTEM research category —
|
||||||
// js/research/SystemCategory.js): the run's activation keys are
|
// js/research/SystemCategory.js): the run's activation keys are
|
||||||
// per-run world state — registry-backed (like discovery: New Game
|
// 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).
|
// is the only elite the game deploys, so its death IS the contract).
|
||||||
onKill: (e) => {
|
onKill: (e) => {
|
||||||
this.combatHud?.kill('HOSTILE DESTROYED');
|
this.combatHud?.kill('HOSTILE DESTROYED');
|
||||||
|
this.recordPirateKill(e);
|
||||||
this.handleCombatKill(e);
|
this.handleCombatKill(e);
|
||||||
},
|
},
|
||||||
// THE PIRATE'S COMM LINES (data/combat.json → enemyTypes.raider.comms
|
// THE PIRATE'S COMM LINES (data/combat.json → enemyTypes.raider.comms
|
||||||
|
|
@ -6374,6 +6386,54 @@ export class GameScene extends Phaser.Scene {
|
||||||
return changed;
|
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 —
|
* 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
|
* parseSideQuestId's worldKey: a planet's `p<ordinal>` or a station's
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue