236 lines
9.9 KiB
JavaScript
236 lines
9.9 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 — 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.
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
}
|