orbit/js/reputation/Reputation.js

253 lines
11 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 — 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 `<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
*/
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();
}
/** 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…(maxmin+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;
}
}