Add a lived-in layer: settlements in generated systems

The galaxy was settled long before the player arrives. Systems now
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 empty.

- data/settlements.json: the shared kind vocabulary (label, theme,
  population range, anchor type); add a kind = JSON + naming pool.
- Per-archetype rates in data/systems.json (chance + required
  planet class) and a core→rim density gradient in data/galaxy.json
  (falloff/floor), keyed off each record's new rNorm.
- Draws stay in a stable order on the system's own Rng stream, so
  lazy and eager generation still produce identical content.
- Every settlement keeps owner: null, a reserved seam for the
  factions/pirates that will claim them later.
- NameGenerator.station() names from anchor + kind ("Kest Skyspire").
- SystemReport.js is a pure, testable formatter (dossier lines,
  "charted · unclaimed" for empty); GameScene's HUD renders it and
  Node tests cover structure, mix, gradient, naming, lazy === eager.
This commit is contained in:
Brian Fertig 2026-09-03 11:25:03 -06:00
parent eafc3c4314
commit 9d40103cce
12 changed files with 467 additions and 52 deletions

View File

@ -28,10 +28,18 @@ python3 -m http.server 8080
(`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 are generated lazily on
arrival, deterministically (seed + system id), so lazy and eager give
identical results. The current system's name/identity shows top-left in
the game scene.
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
@ -51,6 +59,7 @@ orbit/
│ ├── 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
├── lib/ # vendored third-party libs (Phaser 4.2.1)
├── js/
@ -58,7 +67,7 @@ orbit/
│ ├── config/ # Config singleton, ConfigLoader, game config
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
│ ├── entities/ # Ship (own behavior)
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport
│ ├── ui/ # MenuButton (reusable)
│ ├── visuals/ # Starfield (decorative)
│ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)

View File

@ -12,5 +12,8 @@
"rules": []
},
"startingSystem": { "policy": "center" },
"settlements": {
"gradient": { "falloff": 0.7, "floor": 0.22 }
},
"neighbors": 8
}

View File

@ -12,5 +12,14 @@
"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"]
}
}
}

40
data/settlements.json Normal file
View File

@ -0,0 +1,40 @@
{
"_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

@ -17,7 +17,14 @@
"moonChance": 0.3,
"beltChance": 0.35,
"habitability": 0.1,
"hazard": 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": {
@ -36,7 +43,14 @@
"moonChance": 0.45,
"beltChance": 0.3,
"habitability": 0.22,
"hazard": 0.12
"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": {
@ -55,7 +69,14 @@
"moonChance": 0.5,
"beltChance": 0.6,
"habitability": 0.06,
"hazard": 0.18
"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": {
@ -74,7 +95,14 @@
"moonChance": 0.35,
"beltChance": 0.25,
"habitability": 0.5,
"hazard": 0.05
"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": {
@ -93,7 +121,14 @@
"moonChance": 0.2,
"beltChance": 0.8,
"habitability": 0.02,
"hazard": 0.35
"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": {
@ -112,7 +147,14 @@
"moonChance": 0.1,
"beltChance": 0.2,
"habitability": 0.02,
"hazard": 0.5
"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

@ -229,11 +229,91 @@ let big;
const g = Galaxy.create('content-shape');
const sys = g.currentSystem();
const c = g.ensureContent(sys.id);
check('content has star/planets/belt/hazard', !!c.star && Array.isArray(c.planets) && !!c.belt && typeof c.hazard === 'boolean');
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,6 +3,14 @@
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
@ -76,12 +84,12 @@ menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
(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/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.
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
@ -111,6 +119,33 @@ The player's **current system** starts at `galaxy.currentSystem()`
(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`.
@ -126,6 +161,15 @@ and the spatial hash already built for it.
- [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()`
@ -133,8 +177,8 @@ and the spatial hash already built for it.
wire in current-system boundaries, jumps between systems (use
`galaxy.neighborsOf`), and a star map scene
- [ ] Ship input beyond click-to-fly (throttle/brake keys, manual rotation)
- [ ] HUD (speed, sector name, later: fuel/crew) — system name/identity
already shown top-left
- [ ] 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)
- [ ] Economy/trading loop (the Privateer heart)

View File

@ -133,9 +133,11 @@ export class Galaxy {
}
// Name comes from a per-record fork so roster generation order can
// never leak into it.
// 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 };
const rec = { id, name, type, x, y, rNorm };
records.push(rec);
this.byId.set(id, rec);
}

View File

@ -5,8 +5,9 @@ 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) into a fully
* generated system: star, planets, moons, debris belt, hazard flag.
* 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.
@ -15,12 +16,20 @@ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
* is byte-for-byte identical to one generated during an up-front
* generateAll(). That's what makes lazy generation safe.
*
* What the system actually contains is steered by the TYPE'S ATTRIBUTES
* in data/systems.json (`types.<id>.attributes`): star classes, binary
* 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. Theme (`types.<id>.theme`) is for UI. Add new
* attribute keys here + in the JSON and types become richer without
* touching the galaxy generator.
* 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', {});
@ -61,15 +70,27 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
// 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: `${NameGenerator.planetRoot(rng)} ${ordinalLabel(i)}`,
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),
@ -82,7 +103,85 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
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))));
}

59
js/galaxy/SystemReport.js Normal file
View File

@ -0,0 +1,59 @@
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

@ -3,6 +3,7 @@ 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';
@ -56,7 +57,10 @@ export class GameScene extends Phaser.Scene {
}
/**
* Top-left HUD: the current system's name and identity.
* 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
@ -78,30 +82,32 @@ export class GameScene extends Phaser.Scene {
const current = this.galaxy.currentSystem();
const content = this.galaxy.ensureContent(current.id);
const typeDef = this.galaxy.typeDefs?.[current.type] ?? {};
const report = formatSystemReport(content);
const fam = FONT_FALLBACK;
this.add
.text(16, 14, content.name, {
fontFamily: fam,
fontSize: '17px',
fontStyle: 'bold',
color: toColor(typeDef.theme?.color ?? '#9fb4e8'),
})
.setOrigin(0, 0)
.setScrollFactor(0) // UI: pinned to the screen, not the world
.setDepth(30);
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);
};
const label = typeDef.label ?? current.type;
this.add
.text(16, 38, `${label} \u00b7 star ${content.star.class} \u00b7 ${content.planets.length} planets \u00b7 seed ${this.galaxy.seed}`, {
fontFamily: fam,
fontSize: '12px',
color: '#8fa0c9',
})
.setOrigin(0, 0)
.setScrollFactor(0)
.setDepth(30);
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) {

View File

@ -24,6 +24,15 @@ const FALLBACK = {
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'];
@ -70,4 +79,17 @@ export const NameGenerator = {
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}`;
},
};