/** * Reputation — the standing the player holds on each planet and space * station. * * The rules (data/reputation.json → min/max/neutral/home): * - the scale runs min…max (−20…+20 by default), integer steps, max is * the BEST reputation; every write is clamped to the scale; * - a place the player has no standing with is NEUTRAL (0) — unless it * is controlled by a faction the player has standing with (the * FACTION CHECK below — standing is stored PER FACTION in this * instance; a player starts NEUTRAL with every faction); * - the ONE exception: the player's home world is ALWAYS +20 (the * `home` value) — pinned. Nothing sets, changes, or clears it. * * The FACTION CHECK: every settlement carries an `owner` (a faction id, * or null = neutral/wild space — js/galaxy/SystemGenerator.js populates * it from the system's territory, the roster's faction pass in * js/galaxy/Galaxy.js, data/factions.json). standingFor() resolves it: * a place owned by a faction falls back to the player's standing WITH * THAT FACTION (factionStanding()) before settling on neutral. The * per-faction standing lives in this instance (this.factions — * setFaction()/changeFaction() are the seams the later influence * mechanics will drive; nothing sets it yet, so every faction-controlled * place reads neutral today — home world: +20). The resolution order * below is the final one. * * PLACE IDENTITY (the reputation keys — stable per seed, from the * generator): planets `-p`, settlements * `-s` (n = generation order: planet-bound in orbital * order, then free-space), and the home world the fixed key * HOME_KEY ('home' — same id the discovery system uses for it). Same * seed ⇒ same ids ⇒ reputation in a save lines up with the regenerated * galaxy. * * Pure — no Phaser — so it's testable in Node (dev/reputation.test.mjs) * and trivially serializable for saves (toJSON / fromJSON). The scene * keeps one instance in the shared registry (like Discovery), and the * save record carries its state (js/save/SaveData.js). * * const rep = new Reputation(); // scale from data/reputation.json * rep.get('home'); // → +20 (pinned) * rep.get('S000012-p3'); // → 0 (neutral — no standing yet) * rep.change('S000012-p3', 5); // the seam the influence * rep.get('S000012-p3'); // → +5 (later mechanics use) * rep.standingFor(settlement); // {id, owner} — the * // faction-check-aware read * rep.setFaction('apex', 7); // the FACTION standing seam * rep.factionStanding('apex'); // → 7 (a faction-owned world * // reads this standing) * rep.factionStanding('zenith'); // → null (no standing yet — * // standingFor() falls * // through to neutral) */ import { config } from '../config/Config.js'; /** The home world's reputation key (the discovery id for the home world). */ export const HOME_KEY = 'home'; const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); export class Reputation { /** * @param {number} [min] worst reputation (default: reputation.min, −20) * @param {number} [max] best reputation (default: reputation.max, +20) * @param {number} [neutral] standing with no contact (default: 0) * @param {number} [home] the home world's pinned standing (default: +20) * (the constructor arguments exist so tests/dev tools can pin a * different scale without touching the config) */ constructor(min = null, max = null, neutral = null, home = null) { this.min = min ?? config.get('reputation.min', -20); this.max = max ?? config.get('reputation.max', 20); this.neutral = neutral ?? config.get('reputation.neutral', 0); this.home = home ?? config.get('reputation.home', 20); if (!(Number.isFinite(this.min) && Number.isFinite(this.max) && this.max >= this.min)) { throw new Error( `reputation scale must be finite with max >= min (got ${this.min}…${this.max})`, ); } this.neutral = clamp(this.neutral, this.min, this.max); 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} faction id → standing WITH that faction * (the FACTION CHECK — standingFor() hands a faction-owned place this * standing; absent = no standing yet, so a new player starts NEUTRAL * (0) with every faction; setFaction()/changeFaction() are the seams * the later influence mechanics will drive) */ this.factions = 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`)? */ isHome(key) { return key === HOME_KEY; } /** * The raw standing at a place, on the scale — home world → the pinned * +20, an explicitly stored standing → that value, anything else → * neutral. (No faction check: use standingFor() for the full * resolution.) * * @param {string} key the place's reputation key (see the file header) * @returns {number} the standing, clamped to the scale */ get(key) { if (this.isHome(key)) return this.home; const v = this.places.get(key); return v === undefined ? this.neutral : v; } /** * The player's standing AT A PLACE — what every future call site * (trading, landing, diplomacy) should read. Resolution order: * * 1. the home world → always the pinned +20 (nothing changes it); * 2. an explicitly stored standing (set()/change() — the seam the * influence mechanics land on); * 3. the FACTION CHECK — the place's `owner` (a faction id, from the * settlement record) → the player's standing WITH THAT FACTION * (factionStanding() — stored per faction in this instance; * neutral (0) when nothing has set it — a new player's case); * 4. neutral. * * @param {object|string} place a place record ({ id|key, owner }) — a * settlement or planet from the generated content — or a raw key * @returns {number} the standing, clamped to the scale */ standingFor(place) { const key = typeof place === 'string' ? place : place?.key ?? place?.id; if (typeof key !== 'string' || key.length === 0) return this.neutral; if (this.isHome(key)) return this.home; const stored = this.places.get(key); if (stored !== undefined) return stored; const owner = place && typeof place === 'object' ? place.owner ?? null : null; if (owner) { const f = this.factionStanding(owner); if (f !== null) return f; } return this.neutral; } /** * Explicitly set the standing at a place (the seam the later influence * mechanics can use directly). Clamps to the scale, rounds to the * integer step, and ignores non-numeric input. * * The home world is PINNED: this is always a no-op for it. * * @param {string} key the place's reputation key * @param {number} value the new standing (clamped to min…max) * @returns {boolean} true when the standing changed */ set(key, value) { if (this.isHome(key)) return false; const next = this._toStanding(value); if (next === null) return false; const prev = this.places.get(key); if (prev === next) return false; this.places.set(key, next); return true; } /** * Shift the standing at a place by `delta` (positive or negative), * clamped to the scale. Same contract as set(): the home world is * PINNED — always a no-op for it. * * @param {string} key the place's reputation key * @param {number} delta the change (e.g. +5 for a trade, −5 for a theft) * @returns {boolean} true when the standing changed */ change(key, delta) { if (this.isHome(key)) return false; const d = Number(delta); if (!Number.isFinite(d)) return false; const base = this.places.get(key) ?? this.neutral; const next = Math.round(clamp(base + d, this.min, this.max)); if (next === base) return false; this.places.set(key, next); 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 — the player's standing WITH A FACTION. * * Standing is stored PER FACTION (this.factions — setFaction() / * changeFaction() are the seams the later influence mechanics use; the * "ways to influence reputation" will mostly move these). A faction the * player has no standing with answers null and standingFor() settles on * neutral (0) — so a NEW PLAYER starts NEUTRAL with every faction * (nothing has been set yet). Stored values are clamped to this scale * (the same one the place standings use). * * @param {string} factionId the controlling faction (settlement.owner) * @returns {number|null} the standing with the faction, or null (no * standing yet — standingFor() falls through to neutral) */ factionStanding(factionId) { if (typeof factionId !== 'string' || factionId.length === 0) return null; const v = this.factions.get(factionId); return v === undefined ? null : v; } /** * Explicitly set the standing WITH A FACTION (the seam the later * influence mechanics can use directly). Same contract as set() — * clamped to the scale, rounded to the integer step, non-numeric input * ignored — but keyed by faction id (not a place key); the home world * is unrelated to faction standings. * * @param {string} factionId the faction (apex / mindweave / zenith) * @param {number} value the new standing (clamped to min…max) * @returns {boolean} true when the standing changed */ setFaction(factionId, value) { if (typeof factionId !== 'string' || factionId.length === 0) return false; const next = this._toStanding(value); if (next === null) return false; const prev = this.factions.get(factionId); if (prev === next) return false; this.factions.set(factionId, next); return true; } /** * Shift the standing WITH A FACTION by `delta` (positive or negative), * from the faction's current standing (neutral (0) when unset), * clamped to the scale. Same contract as change(), keyed by faction id. * * @param {string} factionId the faction (apex / mindweave / zenith) * @param {number} delta the change (e.g. +5 for a good deed, −5 for a war) * @returns {boolean} true when the standing changed */ changeFaction(factionId, delta) { if (typeof factionId !== 'string' || factionId.length === 0) return false; const d = Number(delta); if (!Number.isFinite(d)) return false; const base = this.factions.get(factionId) ?? this.neutral; const next = Math.round(clamp(base + d, this.min, this.max)); if (next === base) return false; this.factions.set(factionId, next); return true; } /** * How many of the scale's marks are LIT at standing `value` — the * reputation gauge (the comms panel's bar, js/ui/CommsPanel.js) draws * one mark per integer on the scale (41 for −20…+20), and the mark * for standing `v` is lit iff `v <= standing`. So the worst standing * lights just the leftmost (red) mark, the best lights the whole bar * (through green), and 0 lights the left half plus the centre. * * @param {number} value a standing on the scale (clamped; rounded) * @returns {number} 1…(max−min+1), or 0 when the input is not numeric */ marksFor(value) { const v = this._toStanding(value); if (v === null) return 0; return clamp(v - this.min + 1, 1, this.max - this.min + 1); } /** Round + clamp to the scale (integer steps); null when not a number. */ _toStanding(value) { const n = Math.round(Number(value)); if (!Number.isFinite(n)) return null; return clamp(n, this.min, this.max); } // ------------------------------------------------------------------ // Serialization (the save record carries this — js/save/SaveData.js) // ------------------------------------------------------------------ /** * The full state, as data: the scale (snapshot, like Discovery keeps * its distance) + every explicitly stored standing. The home world is * never stored — it is pinned by the scale itself. */ toJSON() { const places = {}; for (const [k, v] of this.places) { if (k === HOME_KEY) continue; // defensive — it can't be stored anyway places[k] = v; } const factions = {}; for (const [k, v] of this.factions) factions[k] = v; 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, factions, pending }; } /** * Restore state saved with toJSON(). Missing/corrupt pieces fall back * to the scale defaults; out-of-range values are clamped; non-numeric * or home-world entries are dropped. A record from before reputation * existed (no `reputation` field) loads as a fresh, all-neutral state; * a record from before the FACTION CHECK existed (no `factions` field) * loads with every faction standing unset (neutral — a new player's * case). */ static fromJSON(data) { const d = data && typeof data === 'object' ? data : {}; const rep = new Reputation(d.min, d.max, d.neutral, d.home); const fac = d.factions; if (fac && typeof fac === 'object') { for (const [k, v] of Object.entries(fac)) { if (typeof k !== 'string' || k.length === 0) continue; const n = Math.round(Number(v)); if (!Number.isFinite(n)) continue; rep.factions.set(k, clamp(n, rep.min, rep.max)); } } const places = d.places; if (places && typeof places === 'object') { for (const [k, v] of Object.entries(places)) { if (typeof k !== 'string' || k.length === 0 || k === HOME_KEY) continue; const n = Math.round(Number(v)); if (!Number.isFinite(n)) continue; 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; } }