/** * 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 — a placeholder until factions land); * - the ONE exception: the player's home world is ALWAYS +20 (the * `home` value) — pinned. Nothing sets, changes, or clears it. * * The FACTION CHECK (placeholder — factions come later): every settlement * carries the reserved `owner` seam (js/galaxy/SystemGenerator.js — * `owner: null` for now). standingFor() resolves it: a place owned by a * faction falls back to the player's standing WITH THAT FACTION * (factionStanding()) before settling on neutral. Until factions exist * every owner is null and factionStanding() is a stub — so the whole * galaxy reads neutral (home world: +20). When factions land, only * (a) populate `owner` in generation and (b) fill factionStanding() * remain; the resolution order below is already 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 */ 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(); } /** 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` (the settlement's * reserved seam, null until factions exist) → the player's * standing with that faction (factionStanding()); * 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; } /** * THE FACTION CHECK (placeholder — factions are a later layer). * * Returns the player's standing WITH A FACTION, so standingFor() can * hand a faction-controlled place that standing instead of neutral. * Until factions exist no settlement is owned (`owner: null`) and * nothing consults this — it is deliberately a stub, marked here so * the factions work knows exactly what to fill in: * * TODO(factions): standing per faction (the player's real * relationships — the "ways to influence reputation" will mostly * move THESE), clamped to this scale; null when the player has no * standing with `factionId`. * * @param {string} factionId the controlling faction (settlement.owner) * @returns {number|null} the standing with the faction, or null (no * standing — or factions don't exist yet) */ factionStanding(factionId) { return null; // TODO(factions): see above — the placeholder on purpose. } /** * 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; } return { min: this.min, max: this.max, neutral: this.neutral, home: this.home, places }; } /** * 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. */ static fromJSON(data) { const d = data && typeof data === 'object' ? data : {}; const rep = new Reputation(d.min, d.max, d.neutral, d.home); 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)); } } return rep; } }