orbit/js/reputation/Reputation.js

382 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
}
}