From 21e9e68033cc8f108e8bcf4cd21c9811fb423b0a Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Wed, 23 Sep 2026 22:39:27 -0600 Subject: [PATCH] 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 --- data/reputation.json | 7 +- dev/reputation.test.mjs | 52 ++++++++++++++ js/reputation/Reputation.js | 61 +++++++++++++++- js/scenes/GameScene.js | 137 ++++++++++++++++++++++++++++++++++++ js/ui/shop/ShopView.js | 8 +++ 5 files changed, 263 insertions(+), 2 deletions(-) diff --git a/data/reputation.json b/data/reputation.json index 3ab74a2..3f92adc 100644 --- a/data/reputation.json +++ b/data/reputation.json @@ -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 + } } diff --git a/dev/reputation.test.mjs b/dev/reputation.test.mjs index d8261a5..cee4d24 100644 --- a/dev/reputation.test.mjs +++ b/dev/reputation.test.mjs @@ -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) // ---------------------------------------------------------------------- diff --git a/js/reputation/Reputation.js b/js/reputation/Reputation.js index 05d78cc..22974aa 100644 --- a/js/reputation/Reputation.js +++ b/js/reputation/Reputation.js @@ -74,6 +74,10 @@ export class Reputation { this.home = clamp(this.home, this.min, this.max); /** @type {Map} place key → standing (the home world never appears here) */ this.places = new Map(); + /** @type {Map} 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; } } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 516762a..bcbc14f 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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` or a station's + * `s`) resolved to its reputation key (the settlement's id — the + * key the comms panel reads): a station IS its settlement (the id is + * `-s` — 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----) + * @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 diff --git a/js/ui/shop/ShopView.js b/js/ui/shop/ShopView.js index a95ee11..71908af 100644 --- a/js/ui/shop/ShopView.js +++ b/js/ui/shop/ShopView.js @@ -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; }