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,
|
||||
"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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue