382 lines
16 KiB
JavaScript
382 lines
16 KiB
JavaScript
/**
|
||
* 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 `<systemId>-p<ordinal>`, settlements
|
||
* `<systemId>-s<n>` (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<string, number>} place key → standing (the home world never appears here) */
|
||
this.places = new Map();
|
||
/** @type {Map<string, number>} 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<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`)? */
|
||
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;
|
||
}
|
||
}
|