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:
parent
b66b3b73e3
commit
21e9e68033
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue