diff --git a/assets/images/originals/terran-planet-02.png b/assets/images/originals/terran-planet-02.png new file mode 100644 index 0000000..1180d85 Binary files /dev/null and b/assets/images/originals/terran-planet-02.png differ diff --git a/assets/videos/gasgiant-takeoff-01.mp4 b/assets/videos/gasgiant-takeoff-01.mp4 new file mode 100644 index 0000000..c4c3aae Binary files /dev/null and b/assets/videos/gasgiant-takeoff-01.mp4 differ diff --git a/data/manifest.json b/data/manifest.json index 5dfcec1..7008ca3 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -10,6 +10,7 @@ "galaxy.json", "systems.json", "settlements.json", + "reputation.json", "naming.json", "research.json", "builds.json", diff --git a/data/reputation.json b/data/reputation.json new file mode 100644 index 0000000..3ab74a2 --- /dev/null +++ b/data/reputation.json @@ -0,0 +1,7 @@ +{ + "_comment": "REPUTATION — the standing the player holds on each planet and space station (js/reputation/Reputation.js). min…max = the scale (max is the BEST reputation, min the worst); neutral = what a place starts at when the player has no standing with it (and no faction the player knows controls it); home = the standing the player's HOME WORLD is pinned to, always — nothing changes it. Place identity: planets are -p and settlements -s (SystemGenerator); the home world is the key 'home'. Values are integers; every write is clamped to [min, max]. Factions: every settlement's reserved `owner` field (null for now) is the controlling-faction check — when factions land, a place controlled by a faction the player has standing with inherits that standing (see Reputation.standingFor).", + "min": -20, + "max": 20, + "neutral": 0, + "home": 20 +} diff --git a/dev/reputation.test.mjs b/dev/reputation.test.mjs new file mode 100644 index 0000000..31cf3d8 --- /dev/null +++ b/dev/reputation.test.mjs @@ -0,0 +1,289 @@ +/** + * Reputation test (dev tool, run with Node — no browser): + * + * node dev/reputation.test.mjs + * + * Covers the standing layer (js/reputation/Reputation.js) and its seams: + * - the SCALE (data/reputation.json): min…max, integer steps, neutral, + * clamping of every write (set/change); + * - the HOME WORLD exception: pinned at +20 (read, set, change, and + * across a save round-trip) — nothing ever moves it; + * - the FACTION CHECK placeholder: standingFor() resolves + * home → stored → owner/faction → neutral, with factionStanding() + * stubbed (factions land later) and settlements' `owner` null today; + * - SAVE round-trips: toJSON/fromJSON (including corrupt and + * pre-reputation records — they load all-neutral); + * - the SAVE INTEGRATION (js/save/SaveData.js): captureState carries + * the standing, prepareLoad stages it in the registry, resetRunState + * clears it for a fresh run; + * - PLACE IDENTITY from the generator: planets `-p`, + * settlements `-s`, unique per system, stable per seed — + * and usable straight as reputation keys (standingFor(planetRecord)). + */ +import { pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href; + +// --- Load the real config (data/*.json) into the config singleton -------- +const { config } = await import(file('config/Config.js')); +const fs = await import('node:fs'); +const dataDir = join(__dirname, '../data'); +const configData = {}; +for (const f of fs.readdirSync(dataDir)) { + if (!f.endsWith('.json') || f === 'manifest.json') continue; + configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8')); +} +config.init(configData); + +const { Reputation, HOME_KEY } = await import(file('reputation/Reputation.js')); +const { captureState, prepareLoad, resetRunState } = await import(file('save/SaveData.js')); +const { Galaxy } = await import(file('galaxy/Galaxy.js')); +const { Discovery } = await import(file('galaxy/Discovery.js')); +const { SaveManager } = await import(file('save/SaveManager.js')); + +let failures = 0; +let pass = 0; +const check = (label, cond) => { + console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); + if (cond) pass++; + else failures++; +}; + +const MIN = config.get('reputation.min', -20); +const MAX = config.get('reputation.max', 20); +const NEUTRAL = config.get('reputation.neutral', 0); +const HOME = config.get('reputation.home', 20); + +// ---------------------------------------------------------------------- +// 1. Scale & the neutral default +// ---------------------------------------------------------------------- +{ + const rep = new Reputation(); + check('scale comes from data/reputation.json (−20…+20, neutral 0, home +20)', + rep.min === -20 && rep.max === 20 && rep.neutral === 0 && rep.home === 20); + check('an unknown place is neutral by default', rep.get('S000123-p3') === NEUTRAL); + check('a place with no id at all is neutral (not an error)', + rep.standingFor(null) === NEUTRAL && rep.standingFor({}) === NEUTRAL); + check('get() reads home through the raw path too', rep.get(HOME_KEY) === HOME); +} + +// ---------------------------------------------------------------------- +// 2. The home world exception — pinned at +20, nothing moves it +// ---------------------------------------------------------------------- +{ + const rep = new Reputation(); + check('home world is +20 (best reputation) at birth', rep.get(HOME_KEY) === 20); + check('set() is a no-op on the home world', rep.set(HOME_KEY, -20) === false); + check('change() is a no-op on the home world (both directions)', + rep.change(HOME_KEY, -50) === false && rep.change(HOME_KEY, 50) === false); + check('…and it is still +20 afterwards', rep.get(HOME_KEY) === 20); + check('a home-world entry smuggled into a save is dropped on load', + Reputation.fromJSON({ min: MIN, max: MAX, neutral: NEUTRAL, home: HOME, + places: { [HOME_KEY]: -20, 'S1-p1': 5 } }).get(HOME_KEY) === 20); +} + +// ---------------------------------------------------------------------- +// 3. The scale: clamping, integer steps, the mutation seams +// ---------------------------------------------------------------------- +{ + const rep = new Reputation(); + const k = 'S000042-p1'; + check('set() stores a standing on the scale', rep.set(k, 7) === true && rep.get(k) === 7); + check('set() clamps to the best reputation (+20)', rep.set(k, 99) === true && rep.get(k) === MAX); + check('set() clamps to the worst reputation (−20)', rep.set(k, -99) === true && rep.get(k) === MIN); + check('set() rounds to the integer step', rep.set(k, 4.6) === true && rep.get(k) === 5); + check('set() ignores non-numeric input (standing untouched)', + rep.set(k, 'famous') === false && rep.get(k) === 5); + check('set() to the same value reports "no change"', rep.set(k, 5) === false); + + check('change() shifts from the current standing, clamped at +20', + rep.change(k, 100) === true && rep.get(k) === MAX); + check('change() shifts downward, clamped at −20', + rep.change(k, -1000) === true && rep.get(k) === MIN); + check('change() ignores non-numeric deltas', rep.change(k, 'a lot') === false); + + // The custom-scale constructor (tests/dev tools pin their own scale). + const r2 = new Reputation(-10, 10, 0, 10); + check('a custom scale clamps its own way', r2.set('x', 15) && r2.get('x') === 10 && r2.get('y') === 0); + let threw = false; + try { new Reputation(5, 1); } catch { threw = true; } + check('an inverted scale (min > max) is rejected', threw === true); +} + +// ---------------------------------------------------------------------- +// 4. The faction check (placeholder) — standingFor()'s resolution order +// ---------------------------------------------------------------------- +{ + const rep = new Reputation(); + const colony = { id: 'S000007-s1', kind: 'colony', name: 'New Denver', population: 120000, owner: null }; + check('an unowned place reads neutral through standingFor()', rep.standingFor(colony) === NEUTRAL); + + rep.set('S000007-s1', 9); + check('a stored standing wins', rep.standingFor(colony) === 9); + + // Owner set (the factions layer will populate this): the check consults + // factionStanding() — stubbed to null today, so neutral again… + const owned = { id: 'S000008-s2', kind: 'deepSpaceStation', owner: 'pirates' }; + check('…an owned place with no stored standing still reads neutral (stub)', + rep.standingFor(owned) === NEUTRAL); + check('…but a STORED standing beats the (stub) faction check — stored first', + (rep.set('S000008-s2', -3), rep.standingFor(owned)) === -3); + + // And a stub that "has" standing (simulating the factions layer landing) + // would hand it straight to standingFor() — the seam is real: + const rep2 = new Reputation(); + const standIn = (id) => (id === 'pirates' ? 15 : null); + rep2.factionStanding = standIn; // monkey-patch: pretend the factions exist + check('when factionStanding() answers, standingFor() uses it for owned places', + rep2.standingFor({ id: 'S000009-s1', owner: 'pirates' }) === 15 + && rep2.standingFor({ id: 'S000010-s1', owner: null }) === 0 + && rep2.standingFor({ id: 'S000011-s1' }) === 0); + + check('the home world beats everything (stored, owned, faction)', + rep2.standingFor({ id: HOME_KEY, owner: 'pirates' }) === rep2.home); +} + +// ---------------------------------------------------------------------- +// 5. Serialization — save-ready, and forgiving of old/corrupt records +// ---------------------------------------------------------------------- +{ + const rep = new Reputation(); + rep.set('S000001-p2', 12); + rep.set('S000002-s3', -8); + const json = rep.toJSON(); + check('toJSON() keeps the scale + the stored standings', + json.min === MIN && json.max === MAX && json.places['S000001-p2'] === 12 && json.places['S000002-s3'] === -8); + const back = Reputation.fromJSON(JSON.parse(JSON.stringify(json))); + check('fromJSON() round-trips every standing', + back.get('S000001-p2') === 12 && back.get('S000002-s3') === -8 && back.get('S000003-p1') === NEUTRAL); + + check('out-of-range values in a save are clamped back onto the scale', + Reputation.fromJSON({ min: MIN, max: MAX, neutral: NEUTRAL, home: HOME, places: { a: 999, b: -999 } }) + .get('a') === MAX); + check('garbage entries are dropped, good ones kept', + Reputation.fromJSON({ places: { a: 'famous', b: 5, 0: 3 } }).get('b') === 5 + && Reputation.fromJSON({ places: { a: 'famous' } }).get('a') === NEUTRAL); + check('null / missing records load as a fresh, all-neutral standing', + Reputation.fromJSON(null).get('anywhere') === NEUTRAL + && Reputation.fromJSON(undefined).home === HOME); + check('a scale snapshot travels with the save (old save, old rules)', + Reputation.fromJSON({ min: -5, max: 5, neutral: 0, home: 5 }).get(HOME_KEY) === 5); +} + +// ---------------------------------------------------------------------- +// 6. Save integration (js/save/SaveData.js) — the record carries standing +// ---------------------------------------------------------------------- +{ + const SEED = 'REPUTATIONTEST'; + const galaxy = Galaxy.create(SEED, { systemCount: 8 }); + const system = galaxy.byId.get(galaxy.currentSystemId); + + const rep = new Reputation(); + rep.set(`${system.id}-p1`, 11); + + const fakeScene = { + registry: { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }, + galaxy, + systemRecord: { name: system.name }, + ship: { x: 1, y: 2, rotation: 0.5 }, + discovery: new Discovery(540), + reputation: rep, + tetherField: { tethers: [{ id: 'home', x: 0, y: 0, level: 1, label: system.name }] }, + playTimeMs: 99, + }; + + const rec = captureState(fakeScene); + check('captureState() puts the standing in the record (and it validates)', + rec.reputation?.places?.[`${system.id}-p1`] === 11 + && rec.reputation?.places?.[HOME_KEY] === undefined + && SaveManager.validateRecord(rec) === null); + + // A scene from before reputation existed (no field) still saves… + const legacy = captureState({ ...fakeScene, reputation: null }); + check('…with a fresh all-neutral reputation standing in for the missing one', + legacy.reputation && Object.keys(legacy.reputation.places).length === 0 + && legacy.reputation.home === HOME); + + // …and prepareLoad() stages it back into the registry for the next scene. + const reg = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }; + prepareLoad(reg, rec); + const loaded = reg.get('reputation'); + check('prepareLoad() stages a live Reputation with the saved standing', + loaded instanceof Reputation && loaded.get(`${system.id}-p1`) === 11 && loaded.get(HOME_KEY) === HOME); + + // A record from before reputation existed loads as fresh + all-neutral. + const reg2 = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }; + const legacyRec = { ...rec }; + delete legacyRec.reputation; + prepareLoad(reg2, legacyRec); + check('a pre-reputation save loads all-neutral (old saves keep working)', + reg2.get('reputation') instanceof Reputation && reg2.get('reputation').get(`${system.id}-p1`) === NEUTRAL); + + // New Game wipes the standing (a fresh run meets a fresh galaxy). + resetRunState(reg); + check('resetRunState() clears the standing for a new run', reg.get('reputation') === null); +} + +// ---------------------------------------------------------------------- +// 7. Place identity — the generator's stable keys, usable as-is +// ---------------------------------------------------------------------- +{ + const galaxy = Galaxy.create('PLACEID', { systemCount: 12 }); + let shapeOk = true; + let uniqueOk = true; + for (const r of galaxy.records) { + const c = galaxy.ensureContent(r.id); + for (const p of c.planets) { + if (p.id !== `${r.id}-p${p.ordinal}`) shapeOk = false; + } + const pids = new Set(c.planets.map((p) => p.id)); + if (pids.size !== c.planets.length) uniqueOk = false; + c.settlements.forEach((s, i) => { + if (s.id !== `${r.id}-s${i + 1}`) shapeOk = false; + }); + const sids = new Set(c.settlements.map((s) => s.id)); + if (sids.size !== c.settlements.length) uniqueOk = false; + // No planet and settlement id may collide, and the home world's key + // is reserved ('home' — not a system id, so it never matches). + for (const s of c.settlements) if (pids.has(s.id) || s.id === HOME_KEY) uniqueOk = false; + } + check('every planet id is -p and every settlement id -s', shapeOk); + check('place ids are unique per system (and never the home key)', uniqueOk); + + // Same seed ⇒ same place ids (the save's standing lines up with the + // regenerated galaxy) — and identical whether content was generated + // lazily (as in play) or eagerly (generateAll). + const again = Galaxy.create('PLACEID', { systemCount: 12 }); + const eager = Galaxy.create('PLACEID', { systemCount: 12 }).generateAll(); + const idOf = (g) => g.records.map((r) => g.ensureContent(r.id).planets.map((p) => p.id)).join('|'); + check('same seed ⇒ identical place ids (lazy and eager alike)', + idOf(galaxy) === idOf(again) && idOf(galaxy) === idOf(eager)); + + // The keys are directly usable — standingFor() accepts a generated + // planet or settlement record (owner seam included). Use a system + // that actually HAS a settlement, so planet and place are distinct. + const rep = new Reputation(); + const sys = galaxy.records.find((r) => galaxy.ensureContent(r.id).settlements.length > 0); + const c = galaxy.ensureContent(sys.id); + const somePlanet = c.planets[0]; + const someSettlement = c.settlements[0]; + check('a system with settlements was found (test precondition)', !!sys && !!someSettlement); + check('a generated record is its own reputation key (neutral at first)', + rep.standingFor(somePlanet) === NEUTRAL && rep.standingFor(someSettlement) === NEUTRAL); + rep.change(somePlanet.id, 4); + check('…and standing written against it reads back through the record', + rep.standingFor(somePlanet) === 4 && rep.standingFor(someSettlement) === NEUTRAL); + check('the home world (the fixed key) is +20 wherever it is asked', + rep.standingFor({ id: HOME_KEY }) === HOME); +} + +// ---------------------------------------------------------------------- + +if (failures > 0) { + console.error(`\n${failures} reputation test(s) FAILED`); + process.exit(1); +} +console.log(`\nAll reputation tests passed (${pass} checks).`); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index d86968f..9f468f1 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -259,6 +259,43 @@ the seams are in place. no camera math beyond culling. The scene drives `tick()`/`draw()` from `update()` alongside the TimeClock/tween stepping. +## Reputation — standing on planets & space stations (data layer; factions later) + +The player holds a REPUTATION (standing) on each planet and space station: +- **Scale** — `data/reputation.json`: `min…max` (−20…+20, max = best), + integer steps, `neutral` = the standing with a place the player has no + standing with (0), `home` = the home world's standing (+20). +- **The home-world exception** — the player's home world is ALWAYS +20: + pinned. Nothing sets, changes, or clears it (it's not even storable — + the key is derived from the scale itself). +- **The faction check (placeholder)** — `Reputation.standingFor(place)` + resolves: **home → stored standing → the place's `owner` → faction + standing → neutral**. `owner` is the settlements' reserved seam (always + `null` until factions exist) and `Reputation.factionStanding(id)` is a + marked `TODO(factions)` stub returning null — so today the whole galaxy + reads neutral (home: +20). When factions land: populate `owner` in + generation + fill `factionStanding()`; the resolution order is final. +- **The mutation seams** — `set(key, value)` / `change(key, delta)`, both + clamped to the scale; "ways to influence reputation" land on these. +- **Place identity** — every planet and settlement now carries a stable + `id` from the generator (seed-deterministic: planets + `-p`, settlements `-s`, n = draw order); + the home world is the fixed key `'home'` (`Reputation.HOME_KEY`, same id + discovery uses). Same seed ⇒ same ids ⇒ saved standing lines up with the + regenerated galaxy. Lazy === eager holds (id is content, from the + per-system stream's own record/ordinal). +- **Module & save** — `js/reputation/Reputation.js` (pure, no Phaser — + the Discovery pattern): `toJSON()`/`fromJSON()`; the save record carries + `reputation` (scale snapshot + stored standings); the scene keeps one + instance in the shared registry (New Game resets it via + `resetRunState`). A save from before reputation exists loads as fresh + all-neutral — old saves keep working. No UI yet: nothing changes or + shows standing until the influence mechanics arrive. +- **Tests** — `dev/reputation.test.mjs` (scale, home pinning, clamping, + faction-check order incl. a monkey-patched "factions exist" pass, + save round-trips + legacy/corrupt records, capture/prepare/reset + integration, generator place-id shape/uniqueness/stability). + ## Phaser version - Pinned: **Phaser 4.2.1** ("Giedi"), vendored in `lib/phaser.min.js`. @@ -341,7 +378,14 @@ no camera math beyond culling. The scene drives `tick()`/`draw()` from planets/stations and upgrade levels (the `add`/`setLevel`/`onChange` seams are in place; the costs + panel + research gate come next) - [ ] Factions & pirates: claim settlements (`owner`), flags, borders, - and the player's place in a populated galaxy + and the player's place in a populated galaxy (the reputation layer + already resolves standing through `owner` — `Reputation. + factionStanding()` is the stub to fill) +- [x] Reputation (data layer): standing on each planet & space station, + −20…+20 (best = +20), neutral 0 default, home world pinned at +20, + faction check as a placeholder on the `owner` seam; stable place ids + from the generator; save-ready + Node-tested + (js/reputation/Reputation.js, data/reputation.json) - [ ] Landing & exploration: settlements become points of interest you can approach (the data — kind, anchor, population — is already there) - [x] The player's loop, laid down as data + seams: research (time-based, diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index 4b42fa9..5f28f99 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -32,6 +32,18 @@ const DEG = Math.PI / 180; * fair number of charted-but-unclaimed systems. Nothing here is hostile * yet: `owner` on every settlement is a reserved seam for the factions * and pirates we'll introduce later. + * + * Place identity (the reputation/trading/faction keys): every planet and + * settlement carries a stable `id`, seed-deterministic because it is + * built from the system id + the object's position in its generated list: + * planets `-p` (p1…pn, orbital order) + * settlements `-s` (s1…, draw order: planet-bound + * kinds in orbital order, then + * free-space) + * Same seed ⇒ same ids ⇒ a reputation saved against them lines up with + * the regenerated galaxy (see js/reputation/Reputation.js). The player's + * home world is not one of the generated planets — it has the fixed key + * 'home'. */ export function generateSystemContent(galaxy, record, typeDefs = null) { const defs = typeDefs ?? config.get('systems.types', {}); @@ -85,6 +97,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { moons = pclass === 'gas' || pclass === 'ice' ? rng.int(1, 6) : rng.int(0, 2); } planets.push({ + id: `${record.id}-p${i}`, // reputation/trading key (stable per seed) name: planetDeck[(isHome ? i : i - 1) % planetDeck.length], ordinal: i, class: pclass, @@ -95,6 +108,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { // --- Settlements (the lived-in layer) --------------------------------- const settlements = generateSettlements({ rng, + systemId: record.id, kindDefs: config.get('settlements.kinds', {}), spec: attr.settlements ?? {}, planets, @@ -582,14 +596,17 @@ function settlementDensity(galaxy, record) { * Draw settlements for one system. Stable draw order: planet-bound kinds in * orbital order (colony, mining, cloud), then free-floating (deep-space * station, waypoint). Every roll goes through the system's own stream. + * Each settlement gets a stable `id` (`-s`, n = its position + * in this order) — the reputation/factions key. */ -function generateSettlements({ rng, kindDefs, spec, planets, stationDeck, density }) { +function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density }) { const out = []; let nameIndex = 0; // next station name from the system's deck (no repeats) const make = (kind, anchor) => { const def = kindDefs[kind] ?? {}; out.push({ + id: `${systemId}-s${out.length + 1}`, // reputation/factions key (stable per seed) kind, name: stationDeck[nameIndex++ % stationDeck.length], anchor, diff --git a/js/reputation/Reputation.js b/js/reputation/Reputation.js new file mode 100644 index 0000000..5f9e72e --- /dev/null +++ b/js/reputation/Reputation.js @@ -0,0 +1,235 @@ +/** + * 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. + } + + /** 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; + } +} diff --git a/js/save/SaveData.js b/js/save/SaveData.js index 51b953c..bc83006 100644 --- a/js/save/SaveData.js +++ b/js/save/SaveData.js @@ -9,6 +9,7 @@ * seed, galaxyName, currentSystemId, systemName, * ship: { x, y, heading }, * discovery: Discovery.toJSON(), + * reputation: Reputation.toJSON(), * tethers: [{ id, x, y, level, label }], * playTimeMs } * @@ -28,6 +29,7 @@ import { config } from '../config/Config.js'; import { Galaxy } from '../galaxy/Galaxy.js'; import { Discovery } from '../galaxy/Discovery.js'; +import { Reputation } from '../reputation/Reputation.js'; import { SAVE_FORMAT, SaveManager } from './SaveManager.js'; export const PENDING_RESTORE_KEY = 'orbit.pendingRestore'; @@ -36,7 +38,7 @@ export const PENDING_RESTORE_KEY = 'orbit.pendingRestore'; * Snapshot the live game into a save record. * * @param {object} scene the GameScene (reads: registry, galaxy, - * systemRecord, ship, discovery, tetherField, playTimeMs) + * systemRecord, ship, discovery, reputation, tetherField, playTimeMs) * @returns {object} the record (ready for SaveManager.put) */ export function captureState(scene) { @@ -56,6 +58,7 @@ export function captureState(scene) { heading: Number(scene.ship.rotation), }, discovery: scene.discovery ? scene.discovery.toJSON() : { distance: 540, bySystem: {} }, + reputation: scene.reputation ? scene.reputation.toJSON() : new Reputation().toJSON(), tethers: (scene.tetherField?.tethers ?? []).map((t) => ({ id: t.id, x: t.x, @@ -72,10 +75,13 @@ export function captureState(scene) { /** * Stage a record for play: rebuild the galaxy from its seed, restore the - * discovery state, and park the live-state (ship/tethers/playtime) in the - * registry — the next GameScene.create() consumes it via + * discovery + reputation state, and park the live-state (ship/tethers/ + * playtime) in the registry — the next GameScene.create() consumes it via * consumeRestore(). * + * A record from before reputation existed (no `reputation` field) stages + * a fresh, all-neutral reputation — old saves keep loading. + * * @throws {Error} when the record fails validation — the caller toasts it. */ export function prepareLoad(registry, record) { @@ -93,6 +99,7 @@ export function prepareLoad(registry, record) { registry.set('galaxy', galaxy); registry.set('seed', seed); registry.set('discovery', Discovery.fromJSON(record.discovery ?? { distance: 540, bySystem: {} })); + registry.set('reputation', Reputation.fromJSON(record.reputation)); registry.set(PENDING_RESTORE_KEY, { ship: record.ship, tethers: Array.isArray(record.tethers) ? record.tethers : [], @@ -113,10 +120,12 @@ export function consumeRestore(registry) { /** * "New Game" from the main menu: a fresh run must not inherit the - * previous one's discovery state or a half-staged restore. + * previous one's discovery state, its standing with the old galaxy, or a + * half-staged restore. */ export function resetRunState(registry) { registry.set('discovery', null); + registry.set('reputation', null); registry.set(PENDING_RESTORE_KEY, null); } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index b7a1de8..5ec1ef7 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -6,6 +6,7 @@ import { Rng } from '../utils/Rng.js'; import { Galaxy } from '../galaxy/Galaxy.js'; import { formatSystemReport } from '../galaxy/SystemReport.js'; import { Discovery } from '../galaxy/Discovery.js'; +import { Reputation } from '../reputation/Reputation.js'; import { ScrambleDecode, decodeDur } from '../utils/Decode.js'; import { Ship } from '../entities/Ship.js'; import { Planet } from '../entities/Planet.js'; @@ -258,6 +259,20 @@ export class GameScene extends Phaser.Scene { this.discovery = new Discovery(config.get('game.discovery.distance', 540)); this.registry.set('discovery', this.discovery); } + + // REPUTATION — the player's standing on each planet and space station + // (js/reputation/Reputation.js): −20…+20 (data/reputation.json), + // neutral 0 everywhere the player has no standing, the home world + // pinned at +20. The faction check (settlements' reserved `owner` + // seam) is a placeholder until factions land. Nothing the player can + // see or influence yet — this is the data layer + the save seam the + // influence mechanics plug into. Like discovery it lives in the shared + // registry (survives scene restarts; New Game resets it). + this.reputation = this.registry.get('reputation') ?? null; + if (!this.reputation) { + this.reputation = new Reputation(); + this.registry.set('reputation', this.reputation); + } // The command deck's bottom strip (config: data/actionbar.json) — the // compass lays out its arrows/name tags ABOVE it so a tag is never // buried under the deck, and the hint sits above it too.