Compare commits

..

No commits in common. "9d40103cce690d5f49e4990840534174837e6a80" and "7cf96bb2373e1de269df67f1ccb486bbc2a48478" have entirely different histories.

18 changed files with 28 additions and 1835 deletions

View File

@ -17,34 +17,13 @@ python3 -m http.server 8080
> Must be served over **http(s)** — opening `index.html` via `file://` won't
> work, because the game uses ES modules and `fetch`es its JSON config.
## Current state — v0.2: a seedable galaxy
## Current state — v0.1 foundation
- Main menu with **New Game** and a **Galaxy Seed** panel: the seed is
displayed, editable (click it and type), and rerollable — and the menu
shows what that seed builds (the galaxy's name, system count, archetype
count) **before** you commit. Same seed ⇒ same galaxy.
- **Procedural galaxy**: 40,000 star systems in a seeded disk + core +
spiral arms (`data/galaxy.json`), typed into six themed archetypes
(`data/systems.json`) with per-type distribution weights and radial
bands — the first "how does the galaxy lay itself out" rules.
- **Two-level generation**: the whole galaxy roster is generated at New
Game (~70 ms); each system's planets/moons/belts/settlements are
generated lazily on arrival, deterministically (seed + system id), so
lazy and eager give identical results.
- **A lived-in galaxy**: the galaxy was settled long before you arrive.
Systems host colonies on habitable worlds, mining stations over resource
worlds, cloud bases riding gas giants, stations adrift in open space, and
beacons — or report *charted · unclaimed* when nobody's there. Rates are
per-archetype (`data/systems.json`) and thin out from the settled core to
the wilder rim (`data/galaxy.json`). Each settlement has a name,
population, and an `owner` seam reserved for the factions/pirates to come.
The current system's dossier (name, identity, what's there) shows
top-left in the game scene.
- Game screen with a basic top-down ship: **click anywhere to fly there**
in infinite, unbounded space (system boundaries/jumps come next)
- Camera gently trails the ship; the **parallax starfield** streams past
while it flies and the view slowly recenters (≈1.5 s) once the ship
comes to rest
- Main menu with a **New Game** button
- Game screen with a basic top-down ship: **click anywhere to fly there** in
infinite, unbounded space (borders/sectors come later)
- Camera gently trails the ship; the **parallax starfield** streams past while it
flies and the view slowly recenters (≈1.5 s) once the ship comes to rest
- Config-driven setup: every tunable value lives in `data/*.json`
## Project layout
@ -55,22 +34,17 @@ orbit/
├── data/ # ← ALL tunable config (edit these freely)
│ ├── manifest.json # which config files exist
│ ├── game.json # dimensions, colors, starfield, …
│ ├── menu.json # menu text, colors, button layout, seed panel
│ ├── ship.json # ship feel: thrust, drag, maxSpeed, …
│ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…)
│ ├── systems.json # system archetypes: theme, attributes, distribution
│ ├── settlements.json # the lived-in layer: settlement kinds & populations
│ └── naming.json # syllable pools for names
│ ├── menu.json # menu text, colors, button layout
│ └── ship.json # ship feel: thrust, drag, maxSpeed, …
├── lib/ # vendored third-party libs (Phaser 4.2.1)
├── js/
│ ├── main.js # entry point: load config → boot Phaser
│ ├── config/ # Config singleton, ConfigLoader, game config
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
│ ├── entities/ # Ship (own behavior)
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport
│ ├── ui/ # MenuButton (reusable)
│ ├── visuals/ # Starfield (decorative)
│ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)
│ ├── utils/ # small helpers (Color)
│ └── vendor/ # shim to the vendored Phaser
└── docs/PROJECT_NOTES.md # ← project conventions: read this
```
@ -88,7 +62,6 @@ orbit/
```sh
node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node
node dev/starfield.test.mjs # runs the real Starfield.create() in Node
node dev/galaxy.test.mjs # galaxy determinism, distribution, lazy vs eager
```
`dev/test-game.html` boots straight into the GameScene (no menu click),

View File

@ -1,19 +0,0 @@
{
"systemCount": 40000,
"radius": 20000,
"layout": {
"coreFraction": 0.25,
"bulgeSigma": 0.09,
"diskSkew": 1.7,
"flatten": 0.62,
"spiral": { "enabled": true, "arms": 2, "twist": 2.6, "strength": 0.5 }
},
"distribution": {
"rules": []
},
"startingSystem": { "policy": "center" },
"settlements": {
"gradient": { "falloff": 0.7, "floor": 0.22 }
},
"neighbors": 8
}

View File

@ -1,6 +1,6 @@
{
"name": "Orbit",
"version": "0.2.0",
"version": "0.1.0",
"width": 1280,
"height": 720,
"backgroundColor": "#04060d",

View File

@ -2,9 +2,6 @@
"files": [
"game.json",
"menu.json",
"ship.json",
"galaxy.json",
"systems.json",
"naming.json"
"ship.json"
]
}

View File

@ -18,26 +18,8 @@
"buttons": {
"newGame": {
"label": "New Game",
"position": { "x": 0.5, "y": 0.60 },
"position": { "x": 0.5, "y": 0.62 },
"fontSize": 24
}
},
"seed": {
"label": "GALAXY SEED",
"position": { "x": 0.5, "y": 0.755 },
"labelFontSize": 12,
"fontSize": 24,
"fieldWidth": 300,
"fieldHeight": 46,
"rerollLabel": "reroll",
"rerollFontSize": 14,
"colors": {
"label": "#54608a",
"value": "#e9edf8",
"fieldBg": "#0b1226",
"border": "#2a3a63",
"activeBorder": "#41c7ff",
"hint": "#54608a"
}
}
}

View File

@ -1,25 +0,0 @@
{
"_comment": "Syllable pools for deterministic name synthesis (NameGenerator.js). Edit freely — names are drawn per-entity from the galaxy seed, so changing pools changes every name, consistently.",
"star": {
"syllables": ["ka", "vel", "thu", "ori", "an", "esh", "mar", "dy", "neth", "avi", "cor", "lu", "tan", "ys", "brei", "hal", "ion", "sol", "qua", "ren"],
"minParts": 2,
"maxParts": 4
},
"planet": {
"roots": ["aurel", "bryn", "cald", "dross", "emrys", "fen", "gale", "hyra", "ilv", "jor", "kest", "lun", "mora", "neth", "orb", "pyre", "quill", "ross", "sable", "tarn", "ulric", "vex", "wren", "xan", "yrra", "zeph"]
},
"galaxy": {
"syllables": ["an", "dro", "me", "mil", "ky", "cen", "tau", "rhi", "sa", "vel", "pyr", "os", "eth", "ni"],
"minParts": 3,
"maxParts": 5
},
"station": {
"nouns": {
"colony": ["Reach", "Haven", "Landing", "Hold", "City", "Gate"],
"miningStation": ["Rig", "Dredge", "Claim", "Works", "Yard"],
"cloudBase": ["Skyspire", "Drift", "Aerie", "Balcony", "Cloudspire"],
"deepSpaceStation": ["Relay", "Gate", "Beacon", "Haven", "Anchor"],
"waypoint": ["Waypoint", "Beacon", "Marker", "Light"]
}
}
}

View File

@ -1,40 +0,0 @@
{
"_comment": "The galaxy is already LIVED IN: systems host settlements — colonies on habitable worlds, mining stations over resource worlds, cloud bases riding gas giants, stations adrift in open space. kinds = the shared vocabulary (label, theme, population range, anchor type). HOW OFTEN each kind appears, and what world it needs, is a per-type attribute in data/systems.json (types.<id>.attributes.settlements). Core→rim density gradient lives in data/galaxy.json (settlements.gradient).",
"kinds": {
"colony": {
"label": "Colony",
"description": "A settled habitable world — cities, farms, life.",
"theme": { "color": "#7ce8a4" },
"population": { "min": 40000, "max": 9000000 },
"anchor": "planet"
},
"miningStation": {
"label": "Mining Station",
"description": "Orbital rigs and habitats striping minerals from a resource world.",
"theme": { "color": "#e8b45f" },
"population": { "min": 80, "max": 4000 },
"anchor": "planet"
},
"cloudBase": {
"label": "Cloud Base",
"description": "A mining city riding the upper clouds of a gas giant.",
"theme": { "color": "#6fc3e8" },
"population": { "min": 500, "max": 120000 },
"anchor": "planet"
},
"deepSpaceStation": {
"label": "Deep-Space Station",
"description": "A free-floating station adrift in open system space — refinery, gate, or trading post.",
"theme": { "color": "#c9a7ff" },
"population": { "min": 200, "max": 60000 },
"anchor": "space"
},
"waypoint": {
"label": "Waypoint",
"description": "A small navigation beacon — the faint trace of a crossed galaxy.",
"theme": { "color": "#8fa0c9" },
"population": { "min": 0, "max": 12 },
"anchor": "space"
}
}
}

View File

@ -1,161 +0,0 @@
{
"_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet count spread, planet class weights, moon/belt chances, habitability, hazard. distribution.weight sets how common the type is; distribution.radiusBand ([inner, outer] as a fraction of galaxy radius) is the first proximity rule — richer distribution rules slot into galaxy.json `distribution.rules` later.",
"types": {
"main": {
"label": "Main Sequence",
"description": "An ordinary star and its worlds — the galaxy's working majority.",
"theme": { "color": "#9fb4e8" },
"distribution": { "weight": 34, "radiusBand": null },
"attributes": {
"star": {
"classes": { "G": 30, "K": 40, "M": 30 },
"mass": { "G": [0.8, 1.4], "K": [0.5, 0.8], "M": [0.1, 0.5] }
},
"binaryChance": 0.05,
"planetCount": { "min": 4, "max": 9, "mean": 6.5 },
"planetClasses": { "rocky": 45, "gas": 25, "ice": 18, "lava": 12 },
"moonChance": 0.3,
"beltChance": 0.35,
"habitability": 0.1,
"hazard": 0.1,
"settlements": {
"colony": { "chance": 0.45 },
"miningStation": { "chance": 0.3, "needs": ["rocky", "lava", "ice"] },
"cloudBase": { "chance": 0.35, "needs": ["gas"] },
"deepSpaceStation": { "chance": 0.25 },
"waypoint": { "chance": 0.35 }
}
}
},
"redDwarf": {
"label": "Red Dwarf",
"description": "A small, long-lived M star with close-in, moon-rich worlds.",
"theme": { "color": "#e8927c" },
"distribution": { "weight": 26, "radiusBand": [0.0, 0.6] },
"attributes": {
"star": {
"classes": { "M": 85, "K": 15 },
"mass": { "M": [0.08, 0.35], "K": [0.5, 0.8] }
},
"binaryChance": 0.12,
"planetCount": { "min": 3, "max": 7, "mean": 5 },
"planetClasses": { "rocky": 50, "gas": 15, "ice": 28, "lava": 7 },
"moonChance": 0.45,
"beltChance": 0.3,
"habitability": 0.22,
"hazard": 0.12,
"settlements": {
"colony": { "chance": 0.6 },
"miningStation": { "chance": 0.35, "needs": ["rocky", "ice"] },
"cloudBase": { "chance": 0.1, "needs": ["gas"] },
"deepSpaceStation": { "chance": 0.2 },
"waypoint": { "chance": 0.3 }
}
}
},
"binary": {
"label": "Binary",
"description": "Two stars, one system. Tangled orbits, wide spacings, rich debris.",
"theme": { "color": "#c9a7ff" },
"distribution": { "weight": 10, "radiusBand": [0.15, 0.95] },
"attributes": {
"star": {
"classes": { "F": 25, "G": 40, "K": 35 },
"mass": { "F": [1.0, 1.6], "G": [0.8, 1.4], "K": [0.5, 0.8] }
},
"binaryChance": 1.0,
"planetCount": { "min": 2, "max": 6, "mean": 4 },
"planetClasses": { "rocky": 30, "gas": 40, "ice": 20, "lava": 10 },
"moonChance": 0.5,
"beltChance": 0.6,
"habitability": 0.06,
"hazard": 0.18,
"settlements": {
"colony": { "chance": 0.1 },
"miningStation": { "chance": 0.3, "needs": ["rocky", "gas"] },
"cloudBase": { "chance": 0.5, "needs": ["gas"] },
"deepSpaceStation": { "chance": 0.3 },
"waypoint": { "chance": 0.3 }
}
}
},
"habitable": {
"label": "Habitable",
"description": "Temperate, well-lit, and quietly crowded with life. Rare.",
"theme": { "color": "#7ce8a4" },
"distribution": { "weight": 10, "radiusBand": [0.2, 0.75] },
"attributes": {
"star": {
"classes": { "G": 70, "K": 30 },
"mass": { "G": [0.85, 1.3], "K": [0.6, 0.85] }
},
"binaryChance": 0.03,
"planetCount": { "min": 3, "max": 8, "mean": 5.5 },
"planetClasses": { "rocky": 62, "gas": 18, "ice": 14, "lava": 6 },
"moonChance": 0.35,
"beltChance": 0.25,
"habitability": 0.5,
"hazard": 0.05,
"settlements": {
"colony": { "chance": 0.85 },
"miningStation": { "chance": 0.2, "needs": ["rocky", "ice"] },
"cloudBase": { "chance": 0.2, "needs": ["gas"] },
"deepSpaceStation": { "chance": 0.25 },
"waypoint": { "chance": 0.4 }
}
}
},
"nebula": {
"label": "Nebula",
"description": "Young, bright, and still messy — debris where planets should be.",
"theme": { "color": "#5fd4d0" },
"distribution": { "weight": 12, "radiusBand": [0.4, 1.0] },
"attributes": {
"star": {
"classes": { "A": 20, "F": 30, "G": 50 },
"mass": { "A": [1.4, 2.1], "F": [1.0, 1.6], "G": [0.8, 1.4] }
},
"binaryChance": 0.1,
"planetCount": { "min": 1, "max": 5, "mean": 3 },
"planetClasses": { "rocky": 20, "gas": 25, "ice": 15, "lava": 40 },
"moonChance": 0.2,
"beltChance": 0.8,
"habitability": 0.02,
"hazard": 0.35,
"settlements": {
"colony": { "chance": 0.05 },
"miningStation": { "chance": 0.5, "needs": ["lava", "rocky"] },
"cloudBase": { "chance": 0.3, "needs": ["gas"] },
"deepSpaceStation": { "chance": 0.2 },
"waypoint": { "chance": 0.45 }
}
}
},
"void": {
"label": "Void",
"description": "Old, cold, and mostly empty. The rim's quiet dead ends.",
"theme": { "color": "#7d88a8" },
"distribution": { "weight": 8, "radiusBand": [0.7, 1.0] },
"attributes": {
"star": {
"classes": { "M": 90, "K": 10 },
"mass": { "M": [0.08, 0.3], "K": [0.4, 0.6] }
},
"binaryChance": 0.08,
"planetCount": { "min": 0, "max": 4, "mean": 1.5 },
"planetClasses": { "rocky": 20, "gas": 10, "ice": 55, "lava": 15 },
"moonChance": 0.1,
"beltChance": 0.2,
"habitability": 0.02,
"hazard": 0.5,
"settlements": {
"colony": { "chance": 0.02 },
"miningStation": { "chance": 0.3, "needs": ["ice"] },
"cloudBase": { "chance": 0.05, "needs": ["gas"] },
"deepSpaceStation": { "chance": 0.15 },
"waypoint": { "chance": 0.25 }
}
}
}
}
}

View File

@ -1,319 +0,0 @@
/**
* Galaxy & system generation test (dev tool, run with Node no browser):
*
* node dev/galaxy.test.mjs
*
* Runs the REAL Rng, NameGenerator, SystemGenerator, and Galaxy from js/
* against the real data/*.json config, then asserts the determinism
* contract the whole game rests on:
* - same seed identical roster (ids, names, types, positions);
* - different seed different galaxy;
* - type distribution matches the weights in data/systems.json;
* - type radius bands (the first proximity rule) are respected;
* - every generated system obeys its type's attribute bounds;
* - lazy (on-arrival) content === eager (generateAll) content;
* - spatial-hash neighbor queries agree with brute force;
* - starting system policy works.
* Also reports generation timing for the default 40,000-system galaxy.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Load the real config (data/*.json) into the config singleton ------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
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 { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const { NameGenerator } = await import(pathToFileURL(join(__dirname, '../js/utils/NameGenerator.js')).href);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const deepEq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const SEED = 'orbit-determinism-test';
const types = config.section('systems.types', {});
const typeIds = Object.keys(types);
const weights = typeIds.map((id) => Math.max(0, types[id].distribution?.weight ?? 1));
const totalW = weights.reduce((s, w) => s + w, 0);
const expected = Object.fromEntries(typeIds.map((id, i) => [id, weights[i] / totalW]));
// ----------------------------------------------------------------------
// 1. Rng: determinism, streams, weighted
// ----------------------------------------------------------------------
{
const a = new Rng('same-seed');
const b = new Rng('same-seed');
const seqA = Array.from({ length: 5 }, () => a.next());
const seqB = Array.from({ length: 5 }, () => b.next());
check('same seed ⇒ same sequence', deepEq(seqA, seqB));
check('different seed ⇒ different sequence', !deepEq(seqA, Array.from({ length: 5 }, () => new Rng('other').next())));
const f1 = Rng.derive('seed', 'system', 'S001');
const f2 = Rng.derive('seed', 'system', 'S001');
check('derive() is stable for same labels', deepEq([f1.next(), f1.next()], [f2.next(), f2.next()]));
check('derive() streams differ for different labels', Rng.derive('seed', 'system', 'S001').next() !== Rng.derive('seed', 'system', 'S002').next());
const c = new Rng('parent');
const fork = c.fork('child');
c.next(); // parent use must not disturb the fork
const fresh = Rng.derive('parent', 'child');
check('fork independent of parent state', deepEq([fork.next(), fork.next()], [fresh.next(), fresh.next()]));
const w = new Rng('w');
const draws = Array.from({ length: 10000 }, () => w.weighted({ a: 30, b: 70 }));
const shareA = draws.filter((x) => x === 'a').length / draws.length;
check(`weighted() respects weights (a=${(shareA * 100).toFixed(1)}% ≈ 30%)`, Math.abs(shareA - 0.3) < 0.03);
const ints = Array.from({ length: 1000 }, () => new Rng('i').int(3, 7));
check('int() stays in [min,max]', ints.every((n) => Number.isInteger(n) && n >= 3 && n <= 7));
const sh = new Rng('s').shuffle([1, 2, 3, 4, 5]);
check('shuffle() is a permutation', [...sh].sort((x, y) => x - y).join() === '1,2,3,4,5');
const n = new Rng('n');
const norms = Array.from({ length: 4000 }, () => n.normal(0, 1));
const mean = norms.reduce((s, v) => s + v, 0) / norms.length;
const sd = Math.sqrt(norms.reduce((s, v) => s + v * v, 0) / norms.length);
check(`normal() ≈ N(0,1) (mean ${mean.toFixed(2)}, sd ${sd.toFixed(2)})`, Math.abs(mean) < 0.08 && Math.abs(sd - 1) < 0.08);
const s1 = Rng.randomSeedString(8);
check('randomSeedString() length & charset', /^[a-z2-9]{8}$/.test(s1));
const star = NameGenerator.star(Rng.derive('x', 'name', 'S1'));
check('star name synthesis', typeof star === 'string' && star.length >= 3 && star === star.charAt(0).toUpperCase() + star.slice(1));
}
// ----------------------------------------------------------------------
// 2. Galaxy: determinism & seed sensitivity (full default size)
// ----------------------------------------------------------------------
let big;
{
const t0 = performance.now();
big = Galaxy.create(SEED);
const tCreate = performance.now() - t0;
const n = config.get('galaxy.systemCount', 0);
check(`roster size = galaxy.systemCount (${n})`, big.records.length === n);
check('ids are stable & unique', new Set(big.records.map((r) => r.id)).size === n);
check('current system is part of the roster', big.byId.has(big.currentSystemId));
const again = Galaxy.create(SEED);
check('same seed ⇒ identical roster (names, types, x, y)', deepEq(big.records, again.records));
check('same seed ⇒ same starting system', big.currentSystemId === again.currentSystemId);
check('same seed ⇒ same galaxy name', big.name === again.name);
const other = Galaxy.create('totally-different');
check('different seed ⇒ different galaxy', !deepEq(big.records, other.records));
// Type distribution vs configured weights.
const counts = {};
for (const r of big.records) counts[r.type] = (counts[r.type] ?? 0) + 1;
const nSys = big.records.length;
let distOk = true;
for (const id of typeIds) {
const obs = (counts[id] ?? 0) / nSys;
const sd = Math.sqrt((expected[id] * (1 - expected[id])) / nSys);
if (Math.abs(obs - expected[id]) > 4 * sd + 0.004) {
distOk = false;
console.log(` type ${id}: observed ${(obs * 100).toFixed(1)}% vs expected ${(expected[id] * 100).toFixed(1)}%`);
}
}
check('type distribution matches configured weights (±4σ)', distOk);
// Radius bands (the first "proximity" rule).
const R = Math.max(1, big.params.radius ?? 20000);
const flatten = big.params.layout?.flatten ?? 0.62;
let bandOk = true;
for (const r of big.records) {
const band = types[r.type].distribution?.radiusBand;
if (!Array.isArray(band) || band.length !== 2) continue;
const rNorm = Math.sqrt(r.x * r.x + (r.y / flatten) ** 2) / R;
if (rNorm < band[0] - 1e-9 || rNorm > band[1] + 1e-9) {
bandOk = false;
break;
}
}
check('radius bands (proximity rule) respected by every system', bandOk);
// Lazy contents: attribute bounds across the WHOLE galaxy.
const t1 = performance.now();
let boundsOk = true;
for (const r of big.records) {
const content = big.ensureContent(r.id);
const attr = types[r.type].attributes ?? {};
const pc = attr.planetCount ?? { min: 0, max: 99 };
if (content.planets.length < pc.min || content.planets.length > pc.max) {
boundsOk = false;
break;
}
if (!content.star || typeof content.star.class !== 'string') boundsOk = false;
}
const tGen = performance.now() - t1;
check('every system obeys its type attribute bounds (planetCount etc.)', boundsOk);
check('lazy content generation over all 40k systems', big.generatedCount === nSys);
// Lazy === eager: fresh galaxy (unopened) vs fully generated one.
const fresh = Galaxy.create(SEED);
const sample = [big.records[0].id, big.records[999].id, big.records[nSys - 1].id];
const lazyEager = sample.every((id) => deepEq(fresh.ensureContent(id), big.ensureContent(id)));
check('lazy (on-arrival) content === content already generated', lazyEager);
const eager = Galaxy.create(SEED).generateAll();
check('generateAll() (eager mode) identical to lazy', sample.every((id) => deepEq(eager.contentOf(id), fresh.contentOf(id))));
console.log(` timing: roster(${nSys}) ${tCreate.toFixed(0)} ms · all contents ${tGen.toFixed(0)} ms`);
}
// ----------------------------------------------------------------------
// 3. Small galaxy: neighbors & starting system vs brute force
// ----------------------------------------------------------------------
{
const small = Galaxy.create('small', { systemCount: 300 });
const recs = small.records;
const bruteNearest = (x, y, k) =>
recs
.map((r) => ({ d2: (r.x - x) ** 2 + (r.y - y) ** 2, record: r }))
.sort((a, b) => a.d2 - b.d2)
.slice(0, k)
.map((e) => e.record.id);
let nnOk = true;
for (let i = 0; i < 20; i++) {
const probe = recs[(i * 17) % recs.length];
const k = 5;
const got = small
.neighborsOf(probe.id, k)
.map((r) => r.id)
.sort();
const want = bruteNearest(probe.x, probe.y, k + 1)
.filter((id) => id !== probe.id)
.slice(0, k)
.sort();
if (!deepEq(got, want)) {
nnOk = false;
break;
}
}
check('neighborsOf() matches brute-force k-nearest (300-system galaxy)', nnOk);
const point = { x: 1234.5, y: -777.25 };
const gotP = small.nearest(point.x, point.y, 3).map((r) => r.id).sort();
const wantP = bruteNearest(point.x, point.y, 3).sort();
check('nearest(point, k) matches brute force', deepEq(gotP, wantP));
const centerPolicy = Galaxy.create('center-policy', { systemCount: 250, startingSystem: { policy: 'random' } });
check('random starting policy picks a roster member', centerPolicy.byId.has(centerPolicy.currentSystemId));
const center = Galaxy.create('center-policy', { systemCount: 250 });
const centerRecs = center.records;
const trueCenter = centerRecs.slice().sort((a, b) => (a.x ** 2 + a.y ** 2) - (b.x ** 2 + b.y ** 2))[0].id;
const gridNearest = center.nearest(0, 0, 1)[0].id;
check('center starting policy picks the record nearest the origin', center.currentSystem().id === trueCenter && gridNearest === trueCenter);
}
// ----------------------------------------------------------------------
// 4. Sanity: content shape & theming hooks
// ----------------------------------------------------------------------
{
const g = Galaxy.create('content-shape');
const sys = g.currentSystem();
const c = g.ensureContent(sys.id);
check('content has star/planets/settlements/belt/hazard', !!c.star && Array.isArray(c.planets) && Array.isArray(c.settlements) && !!c.belt && typeof c.hazard === 'boolean');
check('planets have name/ordinal/class/moons/habitable', c.planets.every((p) => p.name && p.ordinal >= 1 && p.class && Number.isInteger(p.moons) && typeof p.habitable === 'boolean'));
check('type themes are defined (UI hook)', typeIds.every((id) => typeof types[id].theme?.color === 'string'));
check('galaxy name is deterministic per seed', Galaxy.create('content-shape').name === g.name);
}
// ----------------------------------------------------------------------
// 5. The lived-in layer: settlements, gradient, report
// ----------------------------------------------------------------------
{
const kinds = config.get('settlements.kinds', {});
check('settlement kinds defined with theme + population range', Object.keys(kinds).length >= 4 && Object.values(kinds).every((k) => typeof k.theme?.color === 'string' && k.population?.min >= 0 && k.population?.max >= k.population?.min));
// Structure across a big sample of the full galaxy.
const big2 = Galaxy.create(SEED);
const sample = big2.records.slice(0, 3000);
let structOk = true;
for (const r of sample) {
const c = big2.ensureContent(r.id);
for (const s of c.settlements) {
if (!kinds[s.kind]) { structOk = false; break; }
if (s.anchor.type === 'planet') {
const planet = c.planets.find((p) => p.ordinal === s.anchor.ordinal);
if (!planet) structOk = false;
} else if (s.anchor.type !== 'space') structOk = false;
const pop = kinds[s.kind].population;
if (s.population < pop.min || s.population > pop.max) structOk = false;
if (s.owner !== null) structOk = false; // reserved seam, unused for now
if (!s.name) structOk = false;
}
if (!structOk) break;
}
check('settlement structure: known kinds, valid anchors, population in range, owner=null (faction seam)', structOk);
// The lived-in mix actually shows up across the sample.
const seen = new Set();
let anchorRulesOk = true;
for (const r of sample) {
const c = big2.ensureContent(r.id);
for (const s of c.settlements) {
seen.add(s.kind);
const p = c.planets.find((pl) => pl.ordinal === s.anchor.ordinal);
if (s.kind === 'colony' && !p?.habitable) anchorRulesOk = false;
if (s.kind === 'cloudBase' && p?.class !== 'gas') anchorRulesOk = false;
}
}
check('the lived-in mix appears (colonies, miners, cloud bases, stations, beacons)', ['colony', 'miningStation', 'cloudBase', 'deepSpaceStation', 'waypoint'].every((k) => seen.has(k)));
check('colonies sit on habitable worlds; cloud bases ride gas giants', anchorRulesOk);
// Not everything is inhabited — some systems stay unclaimed.
let unclaimed = 0;
for (const r of sample) if ((big2.ensureContent(r.id).settlements).length === 0) unclaimed++;
check(`some systems are charted-but-unclaimed (${unclaimed}/${sample.length} in sample)`, unclaimed > 0);
// Core→rim gradient: the settled heart is denser than the wilder rim.
const withCount = sample.map((r) => ({ rNorm: r.rNorm, n: big2.ensureContent(r.id).settlements.length }));
withCount.sort((a, b) => a.rNorm - b.rNorm);
const third = Math.floor(withCount.length / 3);
const inner = withCount.slice(0, third);
const outer = withCount.slice(-third);
const avg = (arr) => arr.reduce((s, x) => s + x.n, 0) / arr.length;
check(`core→rim settlement gradient (inner ${avg(inner).toFixed(2)}/system > outer ${avg(outer).toFixed(2)}/system)`, avg(inner) > avg(outer));
// Station naming + the pure report formatter.
const stName = NameGenerator.station(Rng.derive('x', 'station', 'S1'), 'cloudBase', 'Kest');
check('settlement names are anchor + noun ("Kest Skyspire"-style)', /Kest [A-Z]\w+/.test(stName));
const { formatSystemReport, formatPop } = await import(
pathToFileURL(join(__dirname, '../js/galaxy/SystemReport.js')).href
);
const anySys = big2.currentSystem();
const report = formatSystemReport(big2.ensureContent(anySys.id));
check('report has title/subtitle/settlements/summary', !!report.title && !!report.subtitle && Array.isArray(report.settlements) && typeof report.summary === 'string');
check('report lines name their anchor world or open space', report.settlements.every((s) => /on \w+ [IVX]+|in open space/.test(s.text)));
check('report population sums match', report.population === report.settlements.reduce((s, x) => s + x.population, 0));
check('formatPop() scales (1.2k / 9.0M)', formatPop(1234) === '1.2k' && formatPop(9000000) === '9.0M' && formatPop(12) === '12');
// Unclaimed systems read as "charted · unclaimed" in the report.
const unclaimedRec = sample.map((r) => big2.ensureContent(r.id)).find((c) => c.settlements.length === 0);
check('unclaimed systems report "charted · unclaimed"', unclaimedRec && formatSystemReport(unclaimedRec).summary === 'charted · unclaimed');
// Lazy === eager still holds with the lived-in layer (spot check).
const fresh2 = Galaxy.create(SEED);
check('settlements: lazy content === content from a fresh galaxy', deepEq(fresh2.ensureContent(big2.records[0].id).settlements, big2.ensureContent(big2.records[0].id).settlements));
}
console.log(failures === 0 ? '\nAll galaxy tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -3,14 +3,6 @@
Working agreements and conventions for building this game. Read this before
adding new systems — these are the rules that keep the project scalable.
## Commits (important)
- **Brian makes the commits, manually.** After finishing a change, leave the
work staged/unstaged in the working tree with a suggested commit message
(in the chat reply, or a note here), and do NOT run `git commit` yourself.
This gives Brian the chance to review the code before it enters history.
- Amends/reverts are fine if asked explicitly.
## What we're building
A procedurally generated space RPG in the spirit of **Privateer**, but in
@ -43,11 +35,9 @@ trading/economy, stations, quests — to be scoped as we go.
- **Rule of thumb:** if a value might ever change (balance, layout, copy,
colors), it belongs in JSON, not code. Code owns *behavior*, JSON owns
*parameters*.
- Split config by concern as the game grows: world generation already has
`data/galaxy.json` (shape/scale), `data/systems.json` (archetypes), and
`data/naming.json` (name pools); next up might be `data/economy.json`,
`data/ships.json`, `data/crew.json`, etc. One file per system beats one
giant file.
- Split config by concern as the game grows: `data/sectors.json` for world
generation, `data/economy.json`, `data/ships.json`, `data/crew.json`, etc.
One file per system beats one giant file.
## Code is modular & class-based (important)
@ -58,9 +48,7 @@ trading/economy, stations, quests — to be scoped as we go.
- `js/entities/` — in-world objects that own their behavior (Ship, NPC, …)
- `js/ui/` — reusable UI components (MenuButton, panels, HUD)
- `js/visuals/` — decorative, non-interactive visuals (Starfield, …)
- `js/galaxy/` — the world model: seeded Galaxy, system archetypes,
lazy content generation (Galaxy, SystemGenerator)
- `js/utils/` — small pure helpers (Color, Rng, NameGenerator)
- `js/utils/` — small pure helpers (Color, math, rng)
- `js/config/` — config loading + Phaser game config
- `js/vendor/` — shims to pinned third-party libraries
- Scenes stay thin: they compose entities/UI and wire input. They don't hold
@ -75,77 +63,6 @@ trading/economy, stations, quests — to be scoped as we go.
- Import Phaser only through `js/vendor/phaser.js` — the single place that
changes if we swap framework versions.
## World model — the galaxy (important)
The galaxy is **seeded and two-level**. The seed is chosen on the main
menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
1. **Roster**`Galaxy.create(seed)` builds every system's *identity*
(id, name, type, x, y) up front. 40,000 systems is ~70 ms, so the whole
galaxy is always known: the player can never "discover" a layout that
wasn't already implied by the seed.
2. **Contents** — planets/moons/belts/**settlements**/hazards are
generated **lazily** on first arrival (`galaxy.ensureContent(id)`),
then cached. Each system's draw stream is `Rng.derive(seed, 'system', id)`
— independent of generation order — so lazy and eager
(`galaxy.generateAll()`) results are identical. Pick whichever is
cheaper at runtime; correctness never depends on it.
**Determinism rules (keep them!):**
- All generation draws go through `Rng` (`js/utils/Rng.js`). Never
`Math.random()` in a generation path.
- Anything that must be order-independent (names, contents) draws from a
`Rng.derive(seed, <purpose>, id)` stream — never from the galaxy-level
sequence. The roster loop is allowed to use its own sequence because
the loop order itself is part of the seed contract.
- Seeds are trimmed; seed → hash → stream is one-way.
- Dev tools that assert determinism: `dev/galaxy.test.mjs`.
**System archetypes** live in `data/systems.json`. Each type is:
- **themable**`theme.color` etc. for UI;
- **attribute-driven**`attributes` steer the SystemGenerator (star
classes, binary chance, planet count spread, planet class weights,
moon/belt chances, habitability, hazard). New attribute key = JSON +
a few lines in `SystemGenerator.js`;
- **distributed**`distribution.weight` (how common) and
`distribution.radiusBand` (first proximity rule: e.g. `void` systems
live in the outer rim). Richer galaxy-level rules (clustering,
faction borders, adjacency affinity) will slot into
`data/galaxy.json``distribution.rules[]`, read in
`Galaxy._generate()` — the hook is marked in code.
The player's **current system** starts at `galaxy.currentSystem()`
(`startingSystem.policy`: `center` or `random`). Jumping between systems
(the eventual star map / jump drives) will use `galaxy.neighborsOf(id)`
and the spatial hash already built for it.
**The galaxy is already lived in.** It was settled long before the
player arrives. Every system's content can include **settlements**:
`colony` (on a habitable world), `miningStation` (over a resource
world), `cloudBase` (riding a gas giant), `deepSpaceStation` (adrift in
open space), `waypoint` (a small beacon — the faint trace of a crossed
galaxy). Not everything is inhabited: many systems report "charted ·
unclaimed". Model & seams:
- **Kinds vocabulary**`data/settlements.json` (label, description,
theme color, population range, anchor type). Add a kind = JSON + naming
pool; the generator picks it up by name.
- **Per-type rates**`types.<id>.attributes.settlements` in
`data/systems.json`: `chance` + optional `needs` (planet classes) per
kind. Same attribute-driven pattern as everything else.
- **Core→rim gradient**`galaxy.settlements.gradient` in
`data/galaxy.json`: the settled heart is denser (factor 1.0), the rim
is thinner (clamped to `floor`). Each record carries `rNorm` (0 =
center, 1 = rim) so density is per-system, not global.
- **Reserved seam: `owner`** — every settlement has `owner: null`. That's
where **factions and pirates** will plug in later (claim, flag,
relations). Deliberately absent for now — no factions yet.
- **Pure report formatter**`js/galaxy/SystemReport.js`
(`formatSystemReport(content)` → title/subtitle/settlements/summary).
The GameScene HUD renders it; future star map / terminal UI reuse it.
- Landing/exploration (a future feature) will treat settlements as points
of interest: the data already says what's there and where (anchor =
planet ordinal or open space).
## Phaser version
- Pinned: **Phaser 4.2.1** ("Giedi"), vendored in `lib/phaser.min.js`.
@ -156,29 +73,11 @@ unclaimed". Model & seams:
## Roadmap (working list, intentionally rough)
- [x] v0.1 foundation — menu → New Game → click-to-fly ship
- [x] Galaxy seed on the main menu (displayed, editable, rerollable;
same seed → same galaxy, shown before you commit)
- [x] Two-level worldgen: seeded galaxy roster (40k systems) + lazy,
order-independent system contents; system archetypes in JSON
(theme + attributes + distribution weight/radius band)
- [x] The lived-in layer: settlements (colonies, mining stations, cloud
bases, deep-space stations, beacons) with per-type rates, a
core→rim density gradient, populations, and a `owner` seam reserved
for the factions/pirates to come; readable as a HUD dossier
(`SystemReport`)
- [ ] Factions & pirates: claim settlements (`owner`), flags, borders,
and the player's place in a populated galaxy
- [ ] Landing & exploration: settlements become points of interest you
can approach (the data — kind, anchor, population — is already there)
- [ ] Richer galaxy distribution rules (`galaxy.distribution.rules[]`:
clustering by type, borders, adjacency affinity) — hook marked in
`Galaxy._generate()`
- [ ] World model in play: the ship still flies unbounded open space;
wire in current-system boundaries, jumps between systems (use
`galaxy.neighborsOf`), and a star map scene
- [ ] Decide the world model: **infinite open space for now** (ship flies
unbounded; borders/sectors planned for later) — affects camera,
starfield, and world gen
- [ ] Procedural star map / sector generation (driven by `data/sectors.json`)
- [ ] Ship input beyond click-to-fly (throttle/brake keys, manual rotation)
- [ ] HUD (speed, fuel/crew) — the current system's dossier (name,
identity, settlements) is already shown top-left
- [ ] Save/load (the `config` + entity split should make this tractable;
a save = seed + player state, since the galaxy regenerates)
- [ ] HUD (speed, sector name, later: fuel/crew)
- [ ] Save/load (the `config` + entity split should make this tractable)
- [ ] Economy/trading loop (the Privateer heart)

View File

@ -28,12 +28,9 @@ class Config {
return Object.prototype.hasOwnProperty.call(this.data, name);
}
/**
* @returns {object} the value at `name` if it's an object dotted
* paths allowed (`section('systems.types')` works) else `fallback`.
*/
/** @returns {object} the whole section as a plain object */
section(name, fallback = {}) {
const value = this.get(name, undefined);
const value = this.data[name];
return (value && typeof value === 'object') ? value : fallback;
}

View File

@ -1,303 +0,0 @@
import { config } from '../config/Config.js';
import { Rng } from '../utils/Rng.js';
import { NameGenerator } from '../utils/NameGenerator.js';
import { generateSystemContent } from './SystemGenerator.js';
const TAU = Math.PI * 2;
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const wrapPI = (a) => {
const t = (a + Math.PI) % TAU;
return (t < 0 ? t + TAU : t) - Math.PI;
};
/**
* The Galaxy an immense, procedurally generated collection of star
* systems, fully determined by its seed.
*
* Two-level generation (the "grand scale" design):
*
* 1. GALAXY ROSTER generated once, up front, when New Game is pressed:
* `systemCount` lightweight records ({ id, name, type, x, y }).
* This is cheap: ~40,000 systems is a fraction of a second and a few
* MB. It fixes the shape of the galaxy, where every system sits, and
* what KIND each one is for the entire galaxy, from the seed alone.
*
* 2. SYSTEM CONTENTS planets, moons, belts, hazards generated LAZILY,
* the first time the player arrives (`ensureContent(id)`), then
* cached. Because each system's draw stream is derived from
* (seed, 'system', id), a lazy generation is identical to an eager
* one so this is a pure performance choice, never a correctness one.
* `generateAll()` exists for exactly that, if it's ever "just as easy".
*
* Determinism contract:
* same seed same roster (positions, types, names), same contents,
* in any order of generation. Dev/test tools rely on this.
*
* Extension points for later world rules (see docs/PROJECT_NOTES.md):
* - data/galaxy.json `distribution.rules[]` proximity/clustering rules
* (e.g. "void systems cluster in the outer rim", faction borders).
* Read in _generate(); today only per-type weight + radiusBand
* (from data/systems.json) apply.
* - more layout knobs in data/galaxy.json `layout`.
*/
export class Galaxy {
constructor(seed, params, typeDefs) {
this.seed = seed;
this.params = params;
this.typeDefs = typeDefs;
this.records = [];
this.byId = new Map();
this.contentCache = new Map();
this.currentSystemId = null;
this.name = NameGenerator.galaxy(Rng.derive(seed, 'galaxy', 'name'));
}
/**
* Build a galaxy from a seed (trimmed; ' abc ' and 'abc' are the same
* galaxy). `overrides` shallow-merges over data/galaxy.json used by
* dev tools (e.g. a 300-system galaxy for brute-force tests).
*/
static create(seed, overrides = {}) {
if (seed === undefined || seed === null || String(seed).trim().length === 0) {
throw new Error('Galaxy.create() needs a non-empty seed');
}
const params = { ...config.section('galaxy', {}), ...overrides };
const typeDefs = config.get('systems.types', {});
if (Object.keys(typeDefs).length === 0) {
throw new Error('No system types found — is data/systems.json listed in data/manifest.json?');
}
const count = Math.floor(params.systemCount ?? 40000);
if (!(count >= 1)) {
throw new Error(`galaxy.systemCount must be a whole number >= 1 (got ${params.systemCount})`);
}
const galaxy = new Galaxy(String(seed).trim(), params, typeDefs);
galaxy._generate(count);
return galaxy;
}
// ------------------------------------------------------------------
// Roster generation (level 1)
// ------------------------------------------------------------------
_generate(count) {
const g = Rng.derive(this.seed, 'layout');
const L = this.params.layout ?? {};
const R = Math.max(1, this.params.radius ?? 20000);
const flatten = clamp(L.flatten ?? 0.62, 0.05, 1);
// Spiral arms (optional; `enabled: false` or strength 0 = off).
const spiral = L.spiral ?? {};
const arms = spiral.enabled === true ? Math.max(0, Math.floor(spiral.arms ?? 0)) : 0;
const twist = spiral.twist ?? 2.5;
const strength = clamp(spiral.strength ?? 0.5, 0, 1);
const armPhase = g.next() * TAU; // one phase for the whole galaxy
// Type selection: weights from data/systems.json (distribution.weight).
// FUTURE: galaxy.distribution.rules[] proximity/clustering rules hook in
// here, before position sampling.
const typeIds = Object.keys(this.typeDefs);
const typeWeights = {};
for (const id of typeIds) {
typeWeights[id] = Math.max(0, this.typeDefs[id].distribution?.weight ?? 1);
}
let centerId = null;
let centerD2 = Infinity;
const records = this.records;
for (let i = 1; i <= count; i++) {
const id = `S${String(i).padStart(6, '0')}`;
const type = g.weighted(typeWeights, typeIds[0]);
// Radius: a center-weighted shape sample, re-anchored into the
// type's radial band (the first "proximity" rule: e.g. void systems
// live out in the rim, habitable ones in the mid-galaxy).
const shape = this._sampleShape(g, L); // [0,1], dense toward center
const band = this.typeDefs[type]?.distribution?.radiusBand;
const rNorm = Array.isArray(band) && band.length === 2
? clamp(band[0] + (band[1] - band[0]) * shape, 0, 1)
: shape;
let theta = g.next() * TAU;
if (arms >= 2) {
theta = this._snapToArm(theta, rNorm, armPhase, arms, twist, strength);
}
const r = R * rNorm;
const x = r * Math.cos(theta);
const y = r * Math.sin(theta) * flatten;
const d2 = x * x + y * y;
if (d2 < centerD2) {
centerD2 = d2;
centerId = id;
}
// Name comes from a per-record fork so roster generation order can
// never leak into it. rNorm (0 = galactic center, 1 = rim) is kept on
// the record: the settlement generator uses it (core→rim density),
// and it's handy for any future "where am I in the galaxy" rules.
const name = NameGenerator.star(Rng.derive(this.seed, 'name', id));
const rec = { id, name, type, x, y, rNorm };
records.push(rec);
this.byId.set(id, rec);
}
// Starting system (the player's home port).
const policy = this.params.startingSystem?.policy ?? 'center';
this.currentSystemId =
policy === 'random' ? `S${String(g.int(1, count)).padStart(6, '0')}` : (centerId ?? records[0]?.id);
// Spatial hash for fast neighbor queries (jump ranges, proximity rules,
// the eventual star map).
const area = Math.PI * R * R * flatten;
this.cellSize = Math.max(8, Math.sqrt(area / count) * 1.4);
this.grid = new Map();
for (const rec of records) {
const key = `${Math.floor(rec.x / this.cellSize)},${Math.floor(rec.y / this.cellSize)}`;
let cell = this.grid.get(key);
if (!cell) {
cell = [];
this.grid.set(key, cell);
}
cell.push(rec);
}
}
/** Center-weighted radius sample in [0,1]: core bulge + disk. */
_sampleShape(g, L) {
if (g.chance(L.coreFraction ?? 0.25)) {
const sigma = Math.max(0.01, L.bulgeSigma ?? 0.09);
return Math.min(1, Math.abs(g.normal(0, sigma)));
}
return Math.min(1, Math.pow(g.next(), Math.max(0.1, L.diskSkew ?? 1.7)));
}
/** Ease `theta` toward the nearest spiral arm (by `strength`). */
_snapToArm(theta, rNorm, armPhase, arms, twist, strength) {
if (strength <= 0) return theta;
const step = TAU / arms;
const base = armPhase + twist * rNorm;
const k = Math.floor((((theta - base) % TAU) + TAU) % TAU / step);
let bestD = Infinity;
let bestA = base + k * step;
for (const cand of [k - 1, k, k + 1]) {
const a = base + cand * step;
const d = Math.abs(wrapPI(a - theta));
if (d < bestD) {
bestD = d;
bestA = a;
}
}
return theta + wrapPI(bestA - theta) * strength;
}
// ------------------------------------------------------------------
// Queries
// ------------------------------------------------------------------
/**
* The k nearest systems to a world-space point (k=1 by default).
* Ring-expands the spatial hash; exact as long as k systems exist.
*/
nearest(x, y, k = 1) {
const c = this.cellSize;
const cx = Math.floor(x / c);
const cy = Math.floor(y / c);
const best = [];
const consider = (rec) => {
const d2 = (rec.x - x) ** 2 + (rec.y - y) ** 2;
best.push({ d2, record: rec });
best.sort((a, b) => a.d2 - b.d2);
if (best.length > k) best.length = k;
};
const maxRing = Math.min(1024, Math.ceil((4 * this.params.radius) / c) + 1);
for (let ring = 0; ring <= maxRing; ring++) {
for (let dx = -ring; dx <= ring; dx++) {
for (let dy = -ring; dy <= ring; dy++) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) continue;
const cell = this.grid.get(`${cx + dx},${cy + dy}`);
if (cell) for (const rec of cell) consider(rec);
}
}
// Everything left unscanned is at least ring·c away; if the kth
// best is already closer, the answer is final.
if (best.length >= k) {
const bound = ring * c;
if (best[k - 1].d2 <= bound * bound) break;
}
}
return best.slice(0, k).map((e) => e.record);
}
/** The k nearest OTHER systems to a system (jump-range candidate list). */
neighborsOf(id, k = null) {
const rec = this.byId.get(id);
if (!rec) throw new Error(`Unknown system "${id}"`);
const need = k ?? Math.floor(this.params.neighbors ?? 8);
const out = [];
for (const cand of this.nearest(rec.x, rec.y, need + 1)) {
if (cand.id !== id) out.push(cand);
if (out.length >= need) break;
}
return out;
}
/** @returns {object} the player's current (starting) system record */
currentSystem() {
return this.byId.get(this.currentSystemId) ?? this.records[0];
}
// ------------------------------------------------------------------
// Contents (level 2, lazy)
// ------------------------------------------------------------------
/**
* Generate (once) and return a system's full contents. Safe to call from
* "the player arrived here" the result is identical to what an
* up-front generateAll() would have produced.
*/
ensureContent(id) {
const cached = this.contentCache.get(id);
if (cached) return cached;
const record = this.byId.get(id);
if (!record) throw new Error(`Unknown system "${id}"`);
const content = generateSystemContent(this, record);
this.contentCache.set(id, content);
return content;
}
/** Alias of ensureContent() — reads nicer at call sites. */
contentOf(id) {
return this.ensureContent(id);
}
/**
* Eager mode: generate every system now. Deterministically identical to
* lazy generation (per-system seeded streams) use it if profiling ever
* shows "just do it all at once" is fine.
*/
generateAll() {
for (const rec of this.records) this.ensureContent(rec.id);
return this;
}
get generatedCount() {
return this.contentCache.size;
}
// ------------------------------------------------------------------
/** Debug/console summary. */
summary() {
const byType = {};
for (const r of this.records) byType[r.type] = (byType[r.type] ?? 0) + 1;
return {
name: this.name,
seed: this.seed,
systems: this.records.length,
byType,
generated: this.generatedCount,
currentSystemId: this.currentSystemId,
};
}
}

View File

@ -1,187 +0,0 @@
import { config } from '../config/Config.js';
import { Rng } from '../utils/Rng.js';
import { NameGenerator, ordinalLabel } from '../utils/NameGenerator.js';
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
/**
* Turns a lightweight galaxy record (id, name, type, x, y, rNorm) into a
* fully generated system: star, planets, moons, SETTLEMENTS, debris belt,
* hazard flag.
*
* Deterministic contract:
* same galaxy seed + same record id identical content, every time.
* The draw stream is derived from (seed, 'system', id) NOT from the
* galaxy-level sequence so a system generated when the player arrives
* is byte-for-byte identical to one generated during an up-front
* generateAll(). That's what makes lazy generation safe.
*
* What the system contains is steered by the TYPE'S ATTRIBUTES in
* data/systems.json (`types.<id>.attributes`): star classes, binary
* chance, planet count spread, planet class weights, moon/belt chances,
* habitability, hazard, and the lived-in layer `settlements` (chance
* + required planet class per settlement kind). Settlement kinds and their
* population ranges live in data/settlements.json; the corerim density
* gradient in data/galaxy.json (`settlements.gradient`).
*
* The galaxy is ALREADY LIVED IN: it was settled long before the player.
* Colonies on habitable worlds, mining stations over resource worlds,
* cloud bases riding gas giants, stations adrift in open space and a
* 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.
*/
export function generateSystemContent(galaxy, record, typeDefs = null) {
const defs = typeDefs ?? config.get('systems.types', {});
const type = defs[record.type] ?? { label: record.type, attributes: {} };
const attr = type.attributes ?? {};
const rng = Rng.derive(galaxy.seed, 'system', record.id);
// --- Star -------------------------------------------------------------
const starClasses = attr.star?.classes ?? { G: 30, K: 35, M: 35 };
const massTable = attr.star?.mass ?? {};
const starClass = rng.weighted(starClasses, 'M');
const mass = Array.isArray(massTable[starClass]) ? massTable[starClass] : [0.3, 1.2];
const star = {
name: NameGenerator.star(rng),
class: starClass,
mass: Number(rng.range(mass[0], mass[1]).toFixed(2)),
binary: false,
};
if (rng.chance(attr.binaryChance ?? 0.05)) {
star.binary = true;
star.secondary = {
name: NameGenerator.star(rng),
class: rng.weighted(starClasses, starClass),
};
}
// --- Planets ----------------------------------------------------------
const pc = attr.planetCount ?? { min: 3, max: 8, mean: 5 };
const count = Math.round(
clamp(pc.mean + (rng.next() - 0.5) * (pc.max - pc.min), pc.min, pc.max),
);
const classWeights = attr.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 };
const planets = [];
for (let i = 1; i <= count; i++) {
const pclass = rng.weighted(classWeights, 'rocky');
let moons = 0;
if (rng.chance(attr.moonChance ?? 0.3)) {
// Jovian/ice worlds drag moon systems; terrestrials mostly don't.
moons = pclass === 'gas' || pclass === 'ice' ? rng.int(1, 6) : rng.int(0, 2);
}
const root = NameGenerator.planetRoot(rng);
planets.push({
name: `${root} ${ordinalLabel(i)}`,
ordinal: i,
root,
class: pclass,
moons,
habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1),
});
}
// --- Settlements (the lived-in layer) ---------------------------------
const settlements = generateSettlements({
rng,
kindDefs: config.get('settlements.kinds', {}),
spec: attr.settlements ?? {},
planets,
starName: star.name,
density: settlementDensity(galaxy, record),
});
// --- Debris belt & system-level hazard --------------------------------
const belt = {
present: rng.chance(attr.beltChance ?? 0.35),
kind: rng.pick(['asteroid', 'debris']) ?? 'asteroid',
};
const hazard = rng.chance(attr.hazard ?? 0.1);
return {
name: record.name,
type: record.type,
star,
planets,
settlements,
belt,
hazard,
};
}
/**
* Corerim density: the settled heart of the galaxy has more activity per
* system; the rim is thinner, lonelier. `factor` scales every settlement
* chance (clamped to a floor so the rim isn't dead). 0 = no gradient.
*/
function settlementDensity(galaxy, record) {
const g = galaxy?.params?.settlements?.gradient ?? {};
const falloff = Math.max(0, g.falloff ?? 0.7);
const floor = clamp(g.floor ?? 0.22, 0, 1);
const rNorm = clamp(record?.rNorm ?? 0, 0, 1);
return clamp(1 - rNorm * falloff, floor, 1);
}
/**
* 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.
*/
function generateSettlements({ rng, kindDefs, spec, planets, starName, density }) {
const out = [];
const make = (kind, anchor, rootWord) => {
const def = kindDefs[kind] ?? {};
out.push({
kind,
name: NameGenerator.station(rng, kind, rootWord),
anchor,
population: logPopulation(rng, def.population),
owner: null, // reserved: factions / pirates claim settlements later
});
};
// Planet-bound, in orbital order.
for (const p of planets) {
if (p.habitable && roll(rng, spec.colony?.chance ?? 0.3, density)) {
make('colony', { type: 'planet', ordinal: p.ordinal }, p.root);
}
if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) &&
roll(rng, spec.miningStation?.chance ?? 0.2, density)) {
make('miningStation', { type: 'planet', ordinal: p.ordinal }, p.root);
}
if (needs(p, spec.cloudBase?.needs, ['gas']) &&
roll(rng, spec.cloudBase?.chance ?? 0.15, density)) {
make('cloudBase', { type: 'planet', ordinal: p.ordinal }, p.root);
}
}
// Free-floating, out in the dark.
if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) {
make('deepSpaceStation', { type: 'space' }, starName);
}
if (roll(rng, spec.waypoint?.chance ?? 0.2, density)) {
make('waypoint', { type: 'space' }, starName);
}
return out;
}
/** One deterministic roll, scaled by the core→rim density factor. */
function roll(rng, chance, density) {
return rng.chance(chance * density);
}
/** Does the planet satisfy the kind's requirements? */
function needs(planet, needsList, defaults) {
const list = Array.isArray(needsList) && needsList.length ? needsList : defaults;
return list.includes(planet.class);
}
/** Log-uniform population in [min, max] (a few towns to a few megacities). */
function logPopulation(rng, pop) {
const min = pop?.min ?? 1;
const max = Math.max(min, pop?.max ?? min);
if (min <= 0 && rng.chance(0.5)) return 0; // e.g. unmanned waypoints
return Math.round(Math.exp(rng.range(Math.log(Math.max(1, min)), Math.log(max))));
}

View File

@ -1,59 +0,0 @@
import { config } from '../config/Config.js';
/**
* Formats a generated system into a readable "dossier" for UI.
*
* Pure no Phaser so it's testable in Node and reusable anywhere a
* system needs describing (the HUD today; the star map, tooltips, saves,
* or a terminal UI later). Scenes stay thin: they render what this
* returns.
*
* const report = formatSystemReport(content);
* // { title, subtitle, settlements: [{ text, color, population }],
* // summary, population }
*/
export function formatSystemReport(content, typeDefs = null, kindDefs = null) {
const types = typeDefs ?? config.get('systems.types', {});
const kinds = kindDefs ?? config.get('settlements.kinds', {});
const typeDef = types[content.type] ?? {};
const planetName = (ordinal) => {
const p = content.planets.find((pl) => pl.ordinal === ordinal);
return p ? p.name : `planet ${ordinal}`;
};
const settlements = [];
for (const s of content.settlements ?? []) {
const def = kinds[s.kind] ?? {};
const where =
s.anchor?.type === 'planet' ? `on ${planetName(s.anchor.ordinal)}` : 'in open space';
settlements.push({
text: `${s.name} · ${def.label ?? s.kind} ${where}`,
color: def.theme?.color ?? '#8fa0c9',
population: s.population ?? 0,
});
}
const population = settlements.reduce((sum, s) => sum + s.population, 0);
const n = settlements.length;
const summary =
n === 0
? 'charted · unclaimed'
: `${n} settlement${n === 1 ? '' : 's'} · pop ~${formatPop(population)}`;
const planetN = content.planets.length;
return {
title: content.name,
subtitle: `${typeDef.label ?? content.type} system · star ${content.star.class} · ${planetN} planet${planetN === 1 ? '' : 's'}`,
settlements,
summary,
population,
};
}
/** 1234 → "1.2k", 9000000 → "9.0M" */
export function formatPop(n) {
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`;
return String(n);
}

View File

@ -1,16 +1,13 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js';
import { Rng } from '../utils/Rng.js';
import { Galaxy } from '../galaxy/Galaxy.js';
import { formatSystemReport } from '../galaxy/SystemReport.js';
import { Ship } from '../entities/Ship.js';
import { Starfield } from '../visuals/Starfield.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
/**
* The game world (v0.2: one ship in the current system's open space).
* The game world (v0.1: one ship in open space).
* Click anywhere to fly there.
*/
export class GameScene extends Phaser.Scene {
@ -28,9 +25,6 @@ export class GameScene extends Phaser.Scene {
this.ship = new Ship(this, 0, 0);
this.ship.setDepth(10);
// We are, after all, in a system. Show the player which one.
this.createSystemHud();
// Center the camera on the ship from the very first frame.
this.cameras.main.setScroll(-this.scale.width / 2, -this.scale.height / 2);
@ -56,60 +50,6 @@ export class GameScene extends Phaser.Scene {
});
}
/**
* Top-left HUD: the current system's dossier name, identity, and what
*'s ALREADY THERE: colonies, mining stations, cloud bases, stations adrift
* in open space (or "charted · unclaimed" when nobody's settled here).
* Formatted by the pure SystemReport helper; this method only renders.
*
* The galaxy comes from the shared registry (built by the menu from the
* chosen seed). Dev boots that skip the menu (dev/test-game.html) get a
* fresh dev galaxy so the scene always works standalone.
*
* The system's CONTENTS are generated here, on arrival the first touch
* of the lazy level-2 generation. (Roster/positions were fixed at the
* menu's New Game click; this is just the "build the room" part.)
*/
createSystemHud() {
this.galaxy = this.registry.get('galaxy') ?? null;
if (!this.galaxy) {
const seed = Rng.randomSeedString(8);
this.galaxy = Galaxy.create(seed);
this.registry.set('galaxy', this.galaxy);
this.registry.set('seed', seed);
console.warn(`[orbit] no galaxy in the registry — generated a dev galaxy (seed "${seed}")`);
}
const current = this.galaxy.currentSystem();
const content = this.galaxy.ensureContent(current.id);
const report = formatSystemReport(content);
const fam = FONT_FALLBACK;
let y = 14;
const line = (text, style) => {
this.add
.text(16, y, text, style)
.setOrigin(0, 0)
.setScrollFactor(0) // UI: pinned to the screen, not the world
.setDepth(30);
y += 20 + (style.fontSize === '17px' ? 6 : 0);
};
line(report.title, {
fontFamily: fam,
fontSize: '17px',
fontStyle: 'bold',
color: toColor(this.galaxy.typeDefs?.[current.type]?.theme?.color ?? '#9fb4e8'),
});
line(report.subtitle, { fontFamily: fam, fontSize: '12px', color: '#8fa0c9' });
y += 2;
for (const s of report.settlements) {
line(s.text, { fontFamily: fam, fontSize: '13px', color: toColor(s.color, 0x8fa0c9) });
}
line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' });
line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' });
}
update(_time, delta) {
this.ship.update(_time, delta);
this.updateCamera(delta);

View File

@ -1,27 +1,11 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js';
import { MenuButton } from '../ui/MenuButton.js';
import { Rng } from '../utils/Rng.js';
import { NameGenerator } from '../utils/NameGenerator.js';
import { Galaxy } from '../galaxy/Galaxy.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const MONO = "'Cascadia Mono', 'Consolas', 'Menlo', monospace";
const SEED_STORAGE_KEY = 'orbit.galaxySeed';
const SEED_MAX = 32;
const SEED_CHAR = /^[A-Za-z0-9._-]$/;
/**
* Main menu. All text/colors/layout come from data/menu.json.
*
* The Galaxy Seed panel is where a new universe is chosen:
* - the seed is displayed, editable (click it, type; Backspace; Enter to
* commit), and rerollable;
* - the same seed always builds the same galaxy (Galaxy.create), and the
* panel shows what that seed contains before you commit;
* - "New Game" builds the galaxy roster from the seed and hands it to the
* rest of the game via the shared registry.
*/
export class MenuScene extends Phaser.Scene {
constructor() {
@ -60,40 +44,17 @@ export class MenuScene extends Phaser.Scene {
})
.setOrigin(0.5);
// New Game
// Buttons (menu.json lists each one; add more here as the menu grows)
const btn = menu.buttons?.newGame ?? {};
new MenuButton(
this,
(btn.position?.x ?? 0.5) * width,
(btn.position?.y ?? 0.60) * height,
(btn.position?.y ?? 0.62) * height,
btn.label ?? 'New Game',
() => this.startNewGame(),
() => this.scene.start('GameScene'),
btn,
);
// Galaxy seed panel
this.createSeedPanel(menu, fontFamily, cx, height);
// Global input: typing goes to the seed field; a click elsewhere blurs it.
this.input.keyboard.on('keydown', (e) => this.onSeedKey(e));
this.input.on('pointerdown', (pointer) => {
if (this.seedFieldHit && pointer.over(this.seedFieldHit)) {
this.setSeedFocus(true);
} else if (this.seedFocused) {
this.setSeedFocus(false);
}
});
this.cursorTimer = this.time.addEvent({
delay: 430,
loop: true,
callback: () => {
if (this.seedFocused) {
this.cursorOn = !this.cursorOn;
this.seedText.setText(this.seedValue + (this.cursorOn ? ' \u258c' : ' '));
}
},
});
// Version footer
this.add
.text(width - 16, height - 14, config.get('game.version', ''), {
@ -103,181 +64,4 @@ export class MenuScene extends Phaser.Scene {
})
.setOrigin(1, 0.5);
}
// ------------------------------------------------------------------
// Galaxy seed panel
// ------------------------------------------------------------------
createSeedPanel(menu, fontFamily, cx, height) {
const cfg = menu.seed ?? {};
const colors = cfg.colors ?? {};
const fieldW = cfg.fieldWidth ?? 300;
const fieldH = cfg.fieldHeight ?? 46;
const cy = (cfg.position?.y ?? 0.755) * height;
this.seedValue = this.loadSavedSeed() ?? Rng.randomSeedString(8);
this.seedFocused = false;
this.cursorOn = true;
// Label
this.add
.text(cx, cy - fieldH / 2 - 10, cfg.label ?? 'GALAXY SEED', {
fontFamily,
fontSize: `${cfg.labelFontSize ?? 12}px`,
color: colors.label ?? '#54608a',
letterSpacing: 3,
})
.setOrigin(0.5, 1);
// Field (click to edit)
this.seedFieldHit = this.add
.rectangle(cx, cy, fieldW, fieldH, toColor(colors.fieldBg ?? '#0b1226'), 1)
.setStrokeStyle(1, toColor(colors.border ?? '#2a3a63'), 0.9);
this.seedFieldHit.setInteractive({ useHandCursor: true });
this.seedFieldHit.on('pointerdown', () => this.setSeedFocus(true));
this.seedText = this.add
.text(cx - fieldW / 2 + 14, cy, '', {
fontFamily: MONO,
fontSize: `${cfg.fontSize ?? 24}px`,
color: colors.value ?? '#e9edf8',
})
.setOrigin(0, 0.5);
// Reroll
new MenuButton(
this,
cx + fieldW / 2 + 26,
cy,
cfg.rerollLabel ?? 'reroll',
() => {
this.seedValue = Rng.randomSeedString(8);
this.setSeedFocus(false);
},
{
fontSize: cfg.rerollFontSize ?? 14,
paddingX: 18,
paddingY: 10,
},
);
// Hint: what this seed will build (galaxy name + scale), live-updated.
this.seedHint = this.add
.text(cx, cy + fieldH / 2 + 12, '', {
fontFamily,
fontSize: '12px',
color: colors.hint ?? '#54608a',
})
.setOrigin(0.5, 0);
this.drawSeed();
}
drawSeed() {
if (!this.seedText || this.seedDead) return;
this.seedText.setText(this.seedValue + (this.seedFocused ? (this.cursorOn ? ' \u258c' : ' ') : ''));
const cfg = config.get('menu.seed.colors', {});
const border = this.seedFocused
? toColor(cfg.activeBorder ?? '#41c7ff')
: toColor(cfg.border ?? '#2a3a63');
this.seedFieldHit.setStrokeStyle(1, border, this.seedFocused ? 1 : 0.9);
const count = config.get('galaxy.systemCount', 0);
const archetypes = Object.keys(config.get('systems.types', {})).length;
let hint = `${Number(count).toLocaleString('en-US')} systems \u00b7 ${archetypes} archetypes \u00b7 same seed \u2192 same galaxy`;
if (this.seedValue.trim()) {
const gname = NameGenerator.galaxy(Rng.derive(this.seedValue.trim(), 'galaxy', 'name'));
hint = `the galaxy of ${gname} \u00b7 ${hint}`;
}
this.seedHint.setText(hint);
}
setSeedFocus(focused) {
this.seedFocused = !!focused;
this.cursorOn = true;
this.drawSeed();
}
onSeedKey(e) {
const key =
e && typeof e.key === 'string' && e.key.length > 0
? e.key
: e?.keyCode === 13 ? 'Enter'
: e?.keyCode === 8 ? 'Backspace'
: '';
if (key === 'Enter' || key === 'Escape') {
this.setSeedFocus(false);
return;
}
if (key === 'Backspace') {
if (this.seedFocused) {
this.seedValue = this.seedValue.slice(0, -1);
this.drawSeed();
}
return;
}
if (SEED_CHAR.test(key)) {
this.setSeedFocus(true);
if (this.seedValue.length < SEED_MAX) {
this.seedValue += key;
this.drawSeed();
}
}
}
// ------------------------------------------------------------------
startNewGame() {
let seed = this.seedValue.trim();
if (!seed) {
seed = Rng.randomSeedString(8);
this.seedValue = seed;
this.drawSeed();
}
try {
this.setSeedFocus(false);
const galaxy = Galaxy.create(seed);
this.registry.set('galaxy', galaxy);
this.registry.set('seed', seed);
this.saveSeed(seed);
console.info(`orbit \u2014 the galaxy of ${galaxy.name} (seed ${seed})`, galaxy.summary());
this.scene.start('GameScene');
} catch (err) {
console.error(err);
const msg = this.add
.text(this.scale.width / 2, this.scale.height - 64, `could not build the galaxy: ${err.message}`, {
fontFamily: FONT_FALLBACK,
fontSize: '14px',
color: '#ff9b9b',
})
.setOrigin(0.5);
this.time.delayedCall(4000, () => msg.destroy());
}
}
/** Guard for post-shutdown input events (e.g. a click that also fired
* "New Game" on the same frame). */
shutdown() {
this.seedDead = true;
if (this.cursorTimer) this.time.removeEvent(this.cursorTimer);
}
loadSavedSeed() {
try {
const v = globalThis.localStorage?.getItem?.(SEED_STORAGE_KEY);
return typeof v === 'string' && v.trim() ? v.trim() : null;
} catch {
return null;
}
}
saveSeed(seed) {
try {
globalThis.localStorage?.setItem?.(SEED_STORAGE_KEY, seed);
} catch {
/* private mode / no storage — fine */
}
}
}

View File

@ -1,95 +0,0 @@
import { config } from '../config/Config.js';
/**
* Deterministic name synthesis. Everything is drawn from the caller's
* Rng (so names are part of the seed), with syllable pools coming from
* data/naming.json. Code-level fallbacks keep this working even if the
* file is missing.
*
* const rng = Rng.derive(seed, 'name', 'S000123');
* const name = NameGenerator.star(rng); // "Kethavoryn"
*/
const FALLBACK = {
star: {
syllables: ['ka', 'vel', 'thu', 'ori', 'an', 'esh', 'mar', 'dy', 'neth', 'avi', 'cor', 'lu', 'tan', 'ys', 'brei', 'hal', 'ion'],
minParts: 2,
maxParts: 4,
},
planet: {
roots: ['aurel', 'bryn', 'cald', 'dross', 'emrys', 'fen', 'gale', 'hyra', 'ilv', 'jor', 'kest', 'lun', 'mora', 'neth', 'orb', 'pyre', 'quill', 'ross', 'sable', 'tarn', 'ulric', 'vex', 'wren', 'xan', 'yrra', 'zeph'],
},
galaxy: {
syllables: ['an', 'dro', 'me', 'mil', 'ky', 'cen', 'tau', 'rhi', 'sa', 'vel', 'pyr', 'os'],
minParts: 3,
maxParts: 5,
},
station: {
nouns: {
colony: ['Reach', 'Haven', 'Landing'],
miningStation: ['Rig', 'Dredge', 'Claim'],
cloudBase: ['Skyspire', 'Drift', 'Aerie'],
deepSpaceStation: ['Relay', 'Beacon', 'Anchor'],
waypoint: ['Waypoint', 'Beacon', 'Light'],
},
},
};
const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII', 'XIII', 'XIV', 'XV', 'XVI', 'XVII', 'XVIII', 'XIX', 'XX'];
function section(name, fallback) {
const v = config.get(`naming.${name}`);
return (v && typeof v === 'object') ? v : fallback;
}
function capitalize(s) {
return s.length ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
function joinSyllables(rng, syllables, minParts, maxParts) {
const parts = rng.int(minParts, maxParts);
let out = '';
for (let i = 0; i < parts; i++) out += rng.pick(syllables) ?? 'a';
return capitalize(out);
}
/** "1" → "I" … "20" → "XX"; beyond that, plain digits. */
export function ordinalLabel(n) {
return ROMAN[n - 1] ?? String(n);
}
export const NameGenerator = {
/** A star / system name: "Kethavoryn". */
star(rng) {
const s = section('star', FALLBACK.star);
const syllables = Array.isArray(s.syllables) && s.syllables.length ? s.syllables : FALLBACK.star.syllables;
return joinSyllables(rng, syllables, s.minParts ?? 2, s.maxParts ?? 4);
},
/** A planet root word: "Aurel" (compose the ordinal yourself). */
planetRoot(rng) {
const p = section('planet', FALLBACK.planet);
const roots = Array.isArray(p.roots) && p.roots.length ? p.roots : FALLBACK.planet.roots;
return capitalize(rng.pick(roots) ?? 'Novus');
},
/** A galaxy name: "Vethani". */
galaxy(rng) {
const g = section('galaxy', FALLBACK.galaxy);
const syllables = Array.isArray(g.syllables) && g.syllables.length ? g.syllables : FALLBACK.galaxy.syllables;
return joinSyllables(rng, syllables, g.minParts ?? 3, g.maxParts ?? 5);
},
/**
* A settlement name from its anchor + kind: "Kest Reach", "Cortan Relay".
* `anchorWord` is the planet's root word (planet-bound settlements) or the
* star's name (free-floating ones).
*/
station(rng, kind, anchorWord) {
const cfg = section('station', FALLBACK.station);
const all = cfg.nouns ?? FALLBACK.station.nouns;
const pool = Array.isArray(all?.[kind]) && all[kind].length ? all[kind] : (FALLBACK.station.nouns[kind] ?? ['Post']);
const noun = rng.pick(pool) ?? 'Post';
return `${anchorWord ?? 'Outer'} ${noun}`;
},
};

View File

@ -1,171 +0,0 @@
/**
* Deterministic, seedable PRNG for procedural generation.
*
* Rules of the road (see docs/PROJECT_NOTES.md "World model"):
* - same seed + same call sequence same results, always;
* - every derived stream is LABELLED (e.g. 'system:S000123'), so
* generating system A before or after system B never changes what
* either one draws. That's the property that lets us generate the
* whole galaxy's *roster* up front but defer each system's actual
* contents until the player arrives lazy and eager generation
* produce identical results.
*
* Pure ES module, no Phaser runs in the browser and in Node
* (dev/*.test.mjs).
*/
// xmur3 — 32-bit string hash (the "mur" family).
function xmur3(str) {
let h = 1779033703 ^ str.length;
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
return () => {
h = (h ^ (h >>> 16)) >>> 0;
h = Math.imul(h, 2246822507);
h = (h ^ (h >>> 13)) >>> 0;
h = Math.imul(h, 3266489909);
return (h ^ (h >>> 16)) >>> 0;
};
}
function hashSeed(seed) {
return xmur3(String(seed))();
}
// mulberry32 — tiny, fast 32-bit generator. Plenty for worldgen;
// not for anything cryptographic.
function mulberry32(state) {
let a = state >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export class Rng {
/** @param {string|number} seed — anything; it's hashed to a 32-bit state */
constructor(seed) {
if (seed === undefined || seed === null || String(seed).length === 0) {
throw new Error('Rng requires a non-empty seed');
}
this.seed = String(seed);
this._gen = mulberry32(hashSeed(this.seed));
this._spareNormal = null;
}
/** @returns {number} uniform float in [0, 1) */
next() {
return this._gen();
}
/** @returns {number} uniform float in [min, max) */
range(min, max) {
return min + this.next() * (max - min);
}
/** @returns {number} uniform integer in [min, max], inclusive */
int(min, max) {
return Math.floor(this.next() * (max - min + 1)) + min;
}
/** @returns {boolean} true with probability p */
chance(p) {
return this.next() < Math.min(1, Math.max(0, p ?? 0));
}
/** @returns {*} a random element of the array (undefined if empty) */
pick(arr) {
if (!Array.isArray(arr) || arr.length === 0) return undefined;
return arr[Math.floor(this.next() * arr.length)];
}
/**
* Weighted pick. Entries: { a: 30, b: 70 } or [[ 'a', 30 ], [ 'b', 70 ]].
* @returns {*} a key, or `fallback` if every weight is 0
*/
weighted(entries, fallback = undefined) {
const pairs = Array.isArray(entries) ? entries : Object.entries(entries);
if (pairs.length === 0) return fallback;
const total = pairs.reduce((sum, [, w]) => sum + (w > 0 ? w : 0), 0);
if (total <= 0) return fallback;
let u = this.next() * total;
for (const [key, w] of pairs) {
if (w <= 0) continue;
if (u < w) return key;
u -= w;
}
return pairs[pairs.length - 1][0];
}
/** FisherYates. Returns a NEW array; the input is untouched. */
shuffle(arr) {
const out = Array.from(arr);
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(this.next() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
/** Standard normal (BoxMuller, with a cached spare), scaled to mean/sd. */
normal(mean = 0, sd = 1) {
if (this._spareNormal !== null) {
const spare = this._spareNormal;
this._spareNormal = null;
return mean + sd * spare;
}
let u = 0, v = 0, s = 0;
do {
u = 2 * this.next() - 1;
v = 2 * this.next() - 1;
s = u * u + v * v;
} while (s >= 1 || s === 0);
const m = Math.sqrt((-2 * Math.log(s)) / s);
this._spareNormal = v * m;
return mean + sd * (u * m);
}
/**
* A child stream derived from THIS seed plus labels. Independent of the
* parent's state: drawing from the parent afterwards does not change it,
* and two forks with the same labels always agree.
*/
fork(...labels) {
return Rng.derive(this.seed, ...labels);
}
/**
* Derive an independent stream from a seed + purpose labels, e.g.
* Rng.derive(galaxySeed, 'system', 'S000123')
* Same arguments same stream, always.
*/
static derive(seed, ...labels) {
const key = [seed, ...labels].map((x) => String(x)).join('\u0000');
return new Rng(key);
}
/**
* A fresh, NON-deterministic seed string (for "roll a new galaxy").
* Deliberately NOT one of the PRNG streams this is the human's
* free will picking a point in seed space.
*/
static randomSeedString(len = 8) {
const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
let out = '';
const cryptoObj = globalThis.crypto;
if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') {
const buf = new Uint32Array(len);
cryptoObj.getRandomValues(buf);
for (let i = 0; i < len; i++) out += alphabet[buf[i] % alphabet.length];
} else {
for (let i = 0; i < len; i++) {
out += alphabet[Math.floor(Math.random() * alphabet.length)];
}
}
return out;
}
}