Add reputation data layer with stable place identity
- Introduce Reputation module (js/reputation/Reputation.js) tracking player standing on planets and space stations on a −20…+20 scale, with the home world pinned at +20 - Add data/reputation.json config for scale bounds, neutral default, and home-world standing - Assign stable seed-deterministic ids to planets (<systemId>-p<ordinal>) and settlements (<systemId>-s<n>) in SystemGenerator so saved standing lines up with regenerated galaxies - Wire reputation into the save system (captureState/prepareLoad/resetRunState) with backward-compatible loading of pre-reputation saves as all-neutral - Add faction-check placeholder: standingFor() resolves home → stored → owner/faction → neutral, with factionStanding() stubbed for the upcoming factions layer - Register a single Reputation instance in GameScene via the shared registry (same pattern as Discovery) - Add dev/reputation.test.mjs covering scale clamping, home pinning, serialization round-trips, legacy/corrupt record handling, save integration, and place-id stability
This commit is contained in:
parent
7b48e030ef
commit
0c30985588
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
Binary file not shown.
|
|
@ -10,6 +10,7 @@
|
|||
"galaxy.json",
|
||||
"systems.json",
|
||||
"settlements.json",
|
||||
"reputation.json",
|
||||
"naming.json",
|
||||
"research.json",
|
||||
"builds.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 <systemId>-p<ordinal> and settlements <systemId>-s<n> (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
|
||||
}
|
||||
|
|
@ -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 `<sysId>-p<ordinal>`,
|
||||
* settlements `<sysId>-s<n>`, 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 <systemId>-p<ordinal> and every settlement id <systemId>-s<n>', 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).`);
|
||||
|
|
@ -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
|
||||
`<systemId>-p<ordinal>`, settlements `<systemId>-s<n>`, 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,
|
||||
|
|
|
|||
|
|
@ -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 `<systemId>-p<ordinal>` (p1…pn, orbital order)
|
||||
* settlements `<systemId>-s<n>` (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` (`<systemId>-s<n>`, 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,
|
||||
|
|
|
|||
|
|
@ -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 `<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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue