Add reputation bonuses for side-quest claims and goods sold

- Add `bonuses` config (sideQuest 10%, perGoodSold 2%) to reputation.json
- Implement `Reputation.creditFraction()` with sub-point pending credit that pays out whole points across multiple sales
- Wire GameScene to award the issuer's standing on side-quest claim and on goods sold in the shop
- Serialize/deserialize pending credits through save/load round-trip
- Add tests covering crediting, clamping, home-world pinning, and save round-trip
This commit is contained in:
Brian Fertig 2026-09-23 22:39:27 -06:00
parent b66b3b73e3
commit 21e9e68033
5 changed files with 263 additions and 2 deletions

View File

@ -3,5 +3,10 @@
"min": -20,
"max": 20,
"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": {
"sideQuest": 0.10,
"perGoodSold": 0.02
}
}

View File

@ -282,6 +282,58 @@ const HOME = config.get('reputation.home', 20);
// ----------------------------------------------------------------------
// ----------------------------------------------------------------------
// M. The standing bonuses (data → bonuses) — creditFraction()
// ----------------------------------------------------------------------
{
const pctQuest = config.get('reputation.bonuses.sideQuest', 0.10);
const pctSale = config.get('reputation.bonuses.perGoodSold', 0.02);
check('the data carries both bonuses (10% quest / 2% per good sold)',
pctQuest === 0.10 && pctSale === 0.02);
const rep = new Reputation();
const k = 'S000100-s1';
check('a side-quest claim (10% of the top) lands +2 on the issuer',
rep.creditFraction(k, pctQuest) === true && rep.get(k) === 2);
check('a second claim lands another +2 (4 total)',
rep.creditFraction(k, pctQuest) === true && rep.get(k) === 4);
// The shop's 2% per good sold — +0.4 per unit, CREDITING across sales
// (no rounding-away): the standing moves only when a whole point lands.
const k2 = 'S000101-p2';
check('one good sold (+0.4) credits — the standing stays neutral',
rep.creditFraction(k2, pctSale) === false && rep.get(k2) === NEUTRAL);
check('five goods sold (5 × 0.4 = +2) pay out whole',
(rep.creditFraction(k2, pctSale * 5) === true && rep.get(k2) === 2) || rep.get(k2) === 2);
check('…and the payout lands on a batched sale of five singles too',
(rep.creditFraction('S000102-p1', pctSale),
rep.creditFraction('S000102-p1', pctSale),
rep.creditFraction('S000102-p1', pctSale),
rep.creditFraction('S000102-p1', pctSale),
rep.creditFraction('S000102-p1', pctSale)) && rep.get('S000102-p1') === 2);
check('bad input no-ops (standing + credit untouched)',
rep.creditFraction(k, 0) === false && rep.creditFraction(k, -1) === false
&& rep.creditFraction(k, 'a lot') === false && rep.get(k) === 4);
check('the home world is PINNED through the bonus too (no-op, no credit)',
rep.creditFraction(HOME_KEY, pctQuest) === false && rep.get(HOME_KEY) === HOME);
check('the bonus clamps at the top (a near-max place can\u2019t overtop)',
rep.set('S000103-p1', MAX - 1),
rep.creditFraction('S000103-p1', pctQuest) && rep.get('S000103-p1') === MAX);
// The CREDIT rides the save — toJSON/fromJSON round-trip it.
const rep2 = new Reputation();
rep2.creditFraction('S000104-p1', pctSale); // +0.4 crediting, nothing paid
const back = Reputation.fromJSON(JSON.parse(JSON.stringify(rep2.toJSON())));
check('a sub-point credit round-trips through the save',
Math.abs((back.pending.get('S000104-p1') ?? 0) - 0.4) < 1e-9
&& back.get('S000104-p1') === NEUTRAL
&& (back.creditFraction('S000104-p1', pctSale * 5) === true || back.get('S000104-p1') === 2)
&& back.get('S000104-p1') === 2);
check('garbage credits in a save are dropped',
Reputation.fromJSON({ pending: { a: 1.5, b: -2, c: 'nope' } }).pending.size === 0);
}
// ----------------------------------------------------------------------
// N. The reputation bar (the comms panel's gauge, js/ui/CommsPanel.js)
// ----------------------------------------------------------------------

View File

@ -74,6 +74,10 @@ export class Reputation {
this.home = clamp(this.home, this.min, this.max);
/** @type {Map<string, number>} place key → standing (the home world never appears here) */
this.places = new Map();
/** @type {Map<string, number>} place key → sub-point CREDIT (0 ≤ v < 1) —
* the fraction of a point creditFraction() hasn't paid out yet (the
* per-sale 2% is +0.4 — five sales' worth credits to a +2) */
this.pending = new Map();
}
/** Is this the player's home world (whose standing is always `home`)? */
@ -167,6 +171,44 @@ export class Reputation {
return true;
}
/**
* Award a FRACTION OF THE SCALE'S TOP as a standing bonus (data →
* `bonuses`): the delta is `max * fraction` points (the side-quest
* claim's 10% → +2 at the default +20; the shop's 2% per good sold →
* +0.4), applied through change() — so the home world stays PINNED
* (a no-op for it) and the scale clamps the overtop.
*
* SUB-POINT deltas CREDIT instead of rounding away: the fraction of
* a point that hasn't reached a whole one stays on the place
* (this.pending) and pays out as whole points when it reaches them
* (five +0.4 sales land +2), across saves (pending serializes with
* the rest of the state — toJSON/fromJSON).
*
* @param {string} key the place's reputation key (see the file header)
* @param {number} fraction of the scale's top (0.1 = 10%; the caller
* multiplies it out — 5 goods × 0.02 = 0.1 → +2 at +20)
* @returns {boolean} true when the standing changed (false on the home
* world, on bad input, or while the fraction is still crediting)
*/
creditFraction(key, fraction) {
if (this.isHome(key)) return false;
const f = Number(fraction);
if (!Number.isFinite(f) || f <= 0) return false;
const delta = Math.max(0, this.max) * f;
if (delta <= 0) return false;
const carry = (this.pending.get(key) ?? 0) + delta;
const whole = Math.floor(carry + 1e-9);
if (whole < 1) {
this.pending.set(key, carry);
return false; // still crediting — the next sale(s) pay it out
}
const changed = this.change(key, whole);
const rest = carry - whole;
if (rest > 1e-9) this.pending.set(key, rest);
else this.pending.delete(key);
return changed;
}
/**
* THE FACTION CHECK (placeholder — factions are a later layer).
*
@ -228,7 +270,12 @@ export class Reputation {
if (k === HOME_KEY) continue; // defensive — it can't be stored anyway
places[k] = v;
}
return { min: this.min, max: this.max, neutral: this.neutral, home: this.home, places };
const pending = {};
for (const [k, v] of this.pending) {
if (k === HOME_KEY) continue; // defensive — it never credits
pending[k] = v;
}
return { min: this.min, max: this.max, neutral: this.neutral, home: this.home, places, pending };
}
/**
@ -249,6 +296,18 @@ export class Reputation {
rep.places.set(k, clamp(n, rep.min, rep.max));
}
}
const pend = d.pending;
if (pend && typeof pend === 'object') {
for (const [k, v] of Object.entries(pend)) {
if (typeof k !== 'string' || k.length === 0 || k === HOME_KEY) continue;
const n = Number(v);
// A credit is a fraction of a point (0 ≤ v < 1) — anything else
// (garbage, a whole or negative point) is dropped rather than
// replayed as a standing write.
if (!Number.isFinite(n) || n < 0 || n >= 1) continue;
rep.pending.set(k, n);
}
}
return rep;
}
}

View File

@ -6187,10 +6187,147 @@ export class GameScene extends Phaser.Scene {
// under its own 'sidequest' source (the ledger's trail). No bank
// (the stubs' shape, a combat-less boot) — the credits stand alone.
if (xp > 0 && this.combat?.xp?.grant) this.combat.xp.grant(Math.floor(xp), 'sidequest');
// The REPUTATION reward (the claim's third payout) — the issuer's
// standing bump, landed exactly once with the claim (idempotent). The
// optional call: headless test stubs bind claimSideQuest onto a bare
// `this` without the scene's reputation seams — there it simply no-ops.
this.rewardSideQuestReputation?.(jobId);
this.playSfx('construct'); // the payout tick — the reward lands
return { ok: true, credits, xp };
}
/**
* The claim's REPUTATION payout (data/reputation.json →
* bonuses.sideQuest — 10% of the scale's top, +2 at the default +20):
* the world that POSTED the contract (the job id's world part — its
* planet or station) earns the bump on a completed + claimed job.
* Resolved through the issuer's SETTLEMENT id (the comms panel's
* standing key — commsTargetFor's rule: a station is its own
* settlement, a planet is the settlement anchored to it), so the
* standing reads back in the panel and the landing gate. A no-op for
* the home world (pinned at the top — the bonus can't move it), a
* hand-edited id, or a world without a settlement.
* @param {string} jobId the claimed contract's id
* @returns {boolean} true when the standing moved
*/
rewardSideQuestReputation(jobId) {
const rep = this.reputation;
if (!rep) return false;
const key = this.sideQuestIssuerKey(jobId);
if (!key) return false;
const pct = Number(config.get('reputation.bonuses.sideQuest', 0.10));
if (!(pct > 0)) return false;
const changed = rep.creditFraction(key, pct);
if (changed) {
const name = key === HOME_KEY ? this.homeWorldName : this.worldNameForReputationKey(key);
const pts = Math.round(Math.max(0, rep.max) * pct);
this.consoleToast(
`REPUTATION +${pts} ${name ? `WITH ${String(name).toUpperCase()}` : 'ON THIS WORLD'}`,
{ glyph: '★', glyphColor: toCss('#7ce8a4') },
);
}
return changed;
}
/**
* 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
* `s<n>`) resolved to its reputation key (the settlement's id — the
* key the comms panel reads): a station IS its settlement (the id is
* `<systemId>-s<n>` — the world part as-is); a planet is the
* settlement ANCHORED to it (commsTargetFor's rule — the first in
* generation order, the panel's standing key). null when the world is
* unresolvable (a hand-edited id, a world without settlements — the
* bonus simply doesn't land).
* @param {string} jobId the contract's id (sq-<system>-<world>-<cat>-<slot>)
* @returns {?string} the place's reputation key
*/
sideQuestIssuerKey(jobId) {
const m = parseSideQuestId(String(jobId ?? ''));
if (!m || !this.galaxy) return null;
let content = null;
try { content = this.galaxy.ensureContent(m.systemId); } catch { content = null; }
const settlements = (content?.settlements ?? [])
.filter((s) => s && typeof s.id === 'string' && s.id.length > 0);
if (m.worldKey[0] === 's') {
return settlements.find((s) => s.id === `${m.systemId}-${m.worldKey}`)?.id ?? null;
}
const ordinal = Math.floor(Number(m.worldKey.slice(1)));
if (!Number.isFinite(ordinal)) return null;
return settlements.find((s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === ordinal)?.id ?? null;
}
/**
* The world NAME behind a reputation key (the toast's "WITH KESTREL").
* The key is a settlement id — the settlement's own name; null when the
* key is unresolvable (the toast drops the name, not the bump).
* @param {string} key the place's reputation key (a settlement id)
* @returns {?string}
*/
worldNameForReputationKey(key) {
if (typeof key !== 'string' || key === '' || !this.galaxy) return null;
const sysId = key.slice(0, key.lastIndexOf('-s'));
let content = null;
try { content = this.galaxy.ensureContent(sysId); } catch { content = null; }
const s = (content?.settlements ?? []).find((x) => x?.id === key);
return typeof s?.name === 'string' && s.name !== '' ? s.name : null;
}
/**
* The shop's SALE hook (ShopView.confirm → here): every GOOD SOLD on
* the world the player stands on adds the world's standing bonus
* (data/reputation.json → bonuses.perGoodSold — 2% of the scale's top
* per unit, +0.4 at the default +20). The sub-point fraction CREDITS
* across sales (Reputation.creditFraction — five units land +2), so
* the standing still rises on the 2% step. A no-op for the home
* world (pinned) and for a world without a settlement.
* @param {string} worldName the world the sale landed on (the shop's, canonical)
* @param {number} units the goods' total units sold (the trade's sell side)
*/
onGoodsSold(worldName, units) {
const rep = this.reputation;
if (!rep) return;
const n = Math.max(0, Math.floor(Number(units) || 0));
if (n === 0) return;
const pct = Number(config.get('reputation.bonuses.perGoodSold', 0.02));
if (!(pct > 0)) return;
const key = this.reputationKeyForWorld(worldName);
if (!key) return;
const changed = rep.creditFraction(key, n * pct);
if (changed) {
this.consoleToast(
`REPUTATION RISING WITH ${String(worldName ?? 'THIS WORLD').toUpperCase()}`,
{ glyph: '★', glyphColor: toCss('#7ce8a4') },
);
}
}
/**
* A WORLD NAME → its reputation key (the settlement's id — the key
* commsTargetFor reads, so a bump lands where the panel shows it):
* the home world → HOME_KEY (creditFraction is a no-op there — it is
* PINNED at the scale's top); a free-space station → its settlement's
* id; a planet → the FIRST settlement anchored to it (the panel's
* rule). null when the name is unresolvable (the sale still pays in
* credits — reputation is the bonus, not the trade).
* @param {string} name the world's name (any casing — matched canonically)
* @returns {?string} the place's reputation key
*/
reputationKeyForWorld(name) {
if (typeof name !== 'string' || name.trim() === '') return null;
const lower = name.trim().toLowerCase();
if (this.isHomeSystem && this.homeWorldName && this.homeWorldName.toLowerCase() === lower) return HOME_KEY;
const st = this.systemStations.find((s) => s.discoveryName?.toLowerCase() === lower);
if (st?.settlement?.id) return st.settlement.id;
const rec = (this.systemContent?.planets ?? []).find((p) => p.name?.toLowerCase() === lower);
if (rec) {
const anchored = (this.systemContent?.settlements ?? [])
.find((s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === rec.ordinal);
if (anchored?.id) return anchored.id;
}
return null;
}
/**
* The window's ROUTE readout seam (the detail's "ROUTE — 2/4 WAYPOINTS
* LOGGED" line + the contacts' line — "CONTACTS — 1/2 CLEARED" + the

View File

@ -738,6 +738,14 @@ export class ShopView extends Phaser.GameObjects.Container {
if (pv.sell.some((s) => E.isQuestCargo(s.id))) {
this.gameScene?.onQuestPackageSold?.(this.worldName);
}
// The REPUTATION bump (data/reputation.json → bonuses.perGoodSold —
// 2% of the scale's top per good sold, +0.4 at the default +20): the
// world the shop stands on earns it, the sub-point fraction crediting
// across sales until it pays out whole points (five units → +2). The
// home world is pinned (creditFraction no-ops), so a sale there earns
// nothing — the delivery the package sale already stood.
const soldUnits = pv.sell.reduce((a, s) => a + Math.max(0, Math.floor(Number(s.qty) || 0)), 0);
if (soldUnits > 0) this.gameScene?.onGoodsSold?.(this.worldName, soldUnits);
this._afterTrade(pv);
return true;
}