Make every planet settled and simplify system planet counts to a global

- Planet count is now a single global rule (20% barren, else 2–4 planets)
  instead of per-type spreads; the starting system always holds a gas giant
  plus a rocky world beside the home world.
- Every planet hosts one settlement by class (habitable rocky → colony, other
  rocky/ice/lava → mining station, gas → cloud base); free-space stations and
  beacons still roll per archetype with the core→rim gradient.
- Old probabilistic planet-settlement path kept behind allPlanetsSettled flag.
- Layout max drops to 6 objects (4 planets + 2 stations); the 11-object
  two-orbit case remains as a defensive fallback.
- Update tests, docs, and add rocky-land video asset.
This commit is contained in:
Brian Fertig 2026-09-05 20:31:24 -06:00
parent 4fd1f05359
commit 09dfd00dd3
9 changed files with 197 additions and 98 deletions

View File

@ -45,12 +45,15 @@ node dev/server.mjs 8080
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.
Every planet is settled (for now): colonies on habitable worlds, mining
stations over the rest, cloud bases riding gas giants — plus stations
adrift in open space and beacons, whose odds are per-archetype
(`data/systems.json`) and thin out from the settled core to the wilder
rim (`data/galaxy.json`). Barren systems with nothing adrift report
*charted · unclaimed*. Systems hold 24 planets, or none (~20% are
barren); the starting system always holds the home world, a gas giant,
and a rocky world. 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.
- **Names with a point of view** (`data/naming.json`): planets draw from a

Binary file not shown.

View File

@ -1,5 +1,12 @@
{
"_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).",
"_comment": "The galaxy is already LIVED IN: it was settled long before the player. For now EVERY planet hosts a settlement (allPlanetsSettled): each world gets the one kind that fits its class (settledKindByClass — a habitable rocky world earns a colony, every other world gets its mining outfit, gas giants ride cloud bases). Flip allPlanetsSettled to false to return to the old probabilistic layer (per-type chance + needs in data/systems.json). The free-space kinds (deepSpaceStation, waypoint) still roll per type. kinds = the shared vocabulary (label, theme, population range, anchor type). Core→rim density gradient lives in data/galaxy.json (settlements.gradient).",
"allPlanetsSettled": true,
"settledKindByClass": {
"rocky": { "habitable": "colony", "default": "miningStation" },
"gas": "cloudBase",
"ice": "miningStation",
"lava": "miningStation"
},
"kinds": {
"colony": {
"label": "Colony",

View File

@ -1,5 +1,6 @@
{
"_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.",
"_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). planetCount is a GLOBAL rule, not per-type: noneChance of all systems are barren (no planets), the rest hold a uniform whole number in [min, max]; the STARTING system always holds exactly two generated planets (gas giant + rocky) beside the home world. Every planet is settled by rule (data/settlements.json → allPlanetsSettled + settledKindByClass). 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.",
"planetCount": { "noneChance": 0.2, "min": 2, "max": 4 },
"types": {
"main": {
"label": "Main Sequence",
@ -12,16 +13,12 @@
"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 }
}
@ -38,16 +35,12 @@
"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 }
}
@ -64,16 +57,12 @@
"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 }
}
@ -90,16 +79,12 @@
"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 }
}
@ -116,16 +101,12 @@
"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 }
}
@ -142,16 +123,12 @@
"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

@ -244,10 +244,10 @@ check(
return { planets: c.planets.length, clusters: c.asteroids.length };
});
const avg = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
const rich = rows.filter((r) => r.planets >= 6);
const rich = rows.filter((r) => r.planets >= 4);
const poor = rows.filter((r) => r.planets <= 2);
check(
`inverse rule (aggregate): avg clusters — ≤2 planets: ${avg(poor.map((r) => r.clusters)).toFixed(2)} > ≥6 planets: ${avg(rich.map((r) => r.clusters)).toFixed(2)}`,
`inverse rule (aggregate): avg clusters — ≤2 planets: ${avg(poor.map((r) => r.clusters)).toFixed(2)} > ≥4 planets: ${avg(rich.map((r) => r.clusters)).toFixed(2)}`,
rich.length > 0 && poor.length > 0 && avg(poor.map((r) => r.clusters)) > avg(rich.map((r) => r.clusters)),
);
}

View File

@ -114,7 +114,8 @@ const MAX_NBR = band.maxNeighbor;
const g = Galaxy.create('discovery-layout-test');
let layoutOk = true;
let layoutWhy = '';
let sawTwoOrbits = false; // the N = 11 case (9 planets + 2 stations)
let sawMaxObjects = false; // the N = 6 maximum (4 planets + 2 stations)
let maxObjectsSeen = 0;
const probe = (systems) => {
for (const rec of systems) {
const c = g.ensureContent(rec.id);
@ -144,7 +145,9 @@ const probe = (systems) => {
}
objs.push({ x: s.x, y: s.y });
}
if (objs.length - 1 === 11) sawTwoOrbits = true;
const nObjects = objs.length - 1;
if (nObjects > maxObjectsSeen) maxObjectsSeen = nObjects;
if (nObjects === 6) sawMaxObjects = true;
// 1) No two objects closer than minSpacing (center to center).
for (let i = 0; i < objs.length && layoutOk; i++) {
for (let j = i + 1; j < objs.length; j++) {
@ -176,7 +179,8 @@ check(
`layout: 5000 systems obey minSpacing (no pair <${MIN_SEP}px center-to-center) & maxNeighbor (every object ≤${MAX_NBR}px from a neighbor)${layoutOk ? '' : ' — ' + layoutWhy}`,
layoutOk,
);
check('layout: the 11-object two-orbit case (9 planets + 2 stations) occurs in the sample', sawTwoOrbits);
check(`layout: no system exceeds the 6-object maximum (4 planets + 2 stations) — max in sample: ${maxObjectsSeen}`, maxObjectsSeen <= 6);
check('layout: the 6-object maximum (4 planets + 2 stations) occurs in the sample', sawMaxObjects);
// Determinism on a system that has BOTH planet and station objects.
const rec0 = g.records.find((r) =>

View File

@ -146,21 +146,28 @@ let big;
}
check('radius bands (proximity rule) respected by every system', bandOk);
// Lazy contents: attribute bounds across the WHOLE galaxy.
// Lazy contents: the global planet-count rule across the WHOLE galaxy.
const t1 = performance.now();
const pc = config.get('systems.planetCount', { noneChance: 0, min: 0, max: 99 });
let boundsOk = true;
let emptyCount = 0;
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) {
const n = content.planets.length;
if (n === 0) emptyCount++;
if (!(n === 0 || (n >= pc.min && n <= 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(`every system has 0 or [${pc.min}, ${pc.max}] planets (the global planetCount rule)`, boundsOk);
const emptyShare = emptyCount / nSys;
check(
`${Math.round((pc.noneChance ?? 0) * 100)}% of systems are barren (observed ${(emptyShare * 100).toFixed(1)}%)`,
Math.abs(emptyShare - (pc.noneChance ?? 0.2)) < 0.01,
);
check('lazy content generation over all 40k systems', big.generatedCount === nSys);
// Lazy === eager: fresh galaxy (unopened) vs fully generated one.
@ -278,6 +285,29 @@ let big;
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);
// The all-planets-settled rule (for now): EVERY world hosts a settlement,
// the kind that fits its class (data/settlements.json → settledKindByClass
// — habitable rocky ⇒ colony, other rocky/ice/lava ⇒ mining station,
// gas ⇒ cloud base).
let allSettledOk = true;
for (const r of sample) {
const c = big2.ensureContent(r.id);
for (const p of c.planets) {
const anchored = c.settlements.some(
(s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === p.ordinal,
);
const kindOk = c.settlements.filter(
(s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === p.ordinal,
).every((s) =>
(p.class === 'gas' && s.kind === 'cloudBase') ||
(p.class !== 'gas' && (s.kind === 'colony' || s.kind === 'miningStation'))
);
if (!anchored || !kindOk) { allSettledOk = false; break; }
}
if (!allSettledOk) break;
}
check('every planet is settled with a class-fitting kind (colonies only on habitable rocky worlds)', allSettledOk);
// 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++;
@ -338,6 +368,20 @@ let big;
const nonHomeRec = big2.records.find((r) => r.id !== homeRec.id);
check('other systems have no home world', !('homeName' in big2.ensureContent(nonHomeRec.id)));
// The starting system always holds exactly three planets: the home world
// (the origin, not a generated planet) + a gas giant + a rocky world —
// both generated worlds settled (gas ⇒ cloud base, rocky ⇒ colony or
// mining station).
check(
'starting system = home world + gas giant + rocky world (exactly 2 generated: gas, then rocky)',
homeC.planets.length === 2 && homeC.planets[0].class === 'gas' && homeC.planets[1].class === 'rocky',
);
check(
'starting system: gas giant rides a cloud base; rocky world hosts a colony or mining station',
homeC.settlements.some((s) => s.kind === 'cloudBase' && s.anchor?.ordinal === 1) &&
homeC.settlements.some((s) => (s.kind === 'colony' || s.kind === 'miningStation') && s.anchor?.ordinal === 2),
);
// 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));

View File

@ -104,9 +104,16 @@ menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
**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`;
classes, binary chance, planet class weights, moon/belt chances,
habitability, hazard, free-space settlement odds). New attribute key =
JSON + a few lines in `SystemGenerator.js`;
- **planet count is global, not per-type** — `data/systems.json →
planetCount`: noneChance (20%) of all systems are barren (no planets
at all), the rest hold a uniform whole number in [min, max] (24).
Exception: the **starting system** always holds exactly two generated
planets — a gas giant and a rocky world — which with the home world
(the origin, the player's homestead, not a generated planet) makes its
three planets, always.
- **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,
@ -120,18 +127,26 @@ The player's **current system** starts at `galaxy.currentSystem()`
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:
player arrives. **Every planet is settled (for now)**: each world hosts
the one settlement kind that fits its class (`data/settlements.json →
allPlanetsSettled` + `settledKindByClass`) — `colony` on a habitable
rocky world, `miningStation` over every other rocky/ice/lava world,
`cloudBase` riding every gas giant. Plus the free-space kinds:
`deepSpaceStation` (adrift in open space) and `waypoint` (a small beacon
— the faint trace of a crossed galaxy), still rolled per archetype and
thinned core→rim. The charted-but-unclaimed systems are the barren ones
that also roll no free-space station. 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.
- **The settled rule**`data/settlements.json → allPlanetsSettled` +
`settledKindByClass` (class → kind, with a `habitable` override for
rocky worlds). Flip `allPlanetsSettled` off to return to the old
probabilistic layer (per-type `chance` + `needs` — the code path is
kept behind the flag).
- **Per-type free-space rates**`types.<id>.attributes.settlements` in
`data/systems.json`: `chance` per free-space kind (deep-space station,
waypoint). 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 =
@ -179,10 +194,12 @@ world** — solid, rendered, flyable-to. Rules and seams:
center-to-center, between ANY two objects — planets, stations, the home
world) and `maxNeighbor` (10240 px — whenever a system holds more than
one object, every object is within it of at least one other; the inner
orbit's near neighbor is the home world at the origin). ≤ 10 objects
share one orbit; the 11-object maximum (9 planets + 2 stations) splits
into a 3-ring + 8-ring. Planet-bound settlements (colonies, mining
stations, cloud bases) are features of their planet, not layout objects.
orbit's near neighbor is the home world at the origin). The current data
max is 6 objects (4 planets + 2 stations), which always shares one
orbit; the 11-object two-orbit case (3-ring + 8-ring) is kept as a
defensive fallback for any future data that could produce it.
Planet-bound settlements (colonies, mining stations, cloud bases) are
features of their planet, not layout objects.
Draws come from the dedicated fork `Rng.derive(seed, 'system', id,
'layout')` — layout results are seed-deterministic *and* don't perturb
the content stream (lazy === eager is preserved). Class sizes/tints in
@ -248,11 +265,11 @@ The ship starts with **one level-1 tether** anchored on the home world
visual/feel parameter.
**Upgrade economics (planned):** radius(level) = 5120 × 2^(level1) —
level 2 = 10240 px, which reaches every object of a ≤ 10-object system
(all within 10240 px of the home world); the far 8-ring of the rare
11-object system (up to ~13380 px out) fits inside level 3 (20480 px). Extra
tethers anchored on planets/stations will be the "expand your reach" verb;
the seams are in place.
level 2 = 10240 px, which reaches every object of the current 6-object
maximum (all within 10240 px of the home world); the defensive two-orbit
fallback (11 objects, up to ~13380 px out) would fit inside level 3
(20480 px). Extra tethers anchored on planets/stations will be the
"expand your reach" verb; the seams are in place.
**Phaser v4 note:** the barrier is two `Graphics` layers redrawn per frame
(`clear()` → one `strokePath` per pass) — no textures, no physics bodies,

View File

@ -20,18 +20,27 @@ const DEG = Math.PI / 180;
*
* 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`).
* chance, planet class weights, moon/belt chances, habitability, hazard,
* and the lived-in layer `settlements` (per-type odds for the
* free-space kinds). Planet COUNT is a global rule (data/systems.json
* `planetCount`): noneChance of systems are barren, the rest hold 24
* worlds. 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.
* EVERY planet hosts a settlement (for now data/settlements.json
* allPlanetsSettled + settledKindByClass): colonies on habitable worlds,
* mining stations over the rest, cloud bases riding gas giants. The
* charted-but-unclaimed systems are the barren ones that also roll no
* free-space station. Nothing here is hostile yet: `owner` on every
* settlement is a reserved seam for the factions and pirates we'll
* introduce later.
*
* The STARTING system is special: the player's home world sits at the
* origin (not a generated planet, fixed key 'home'), and the system always
* holds exactly two more planets a gas giant and a rocky world. With the
* home world, three planets, always.
*
* Place identity (the reputation/trading/faction keys): every planet and
* settlement carries a stable `id`, seed-deterministic because it is
@ -71,10 +80,17 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
}
// --- 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),
);
// How many worlds the system holds (data/systems.json → planetCount):
// noneChance of systems are barren (ZERO planets); the rest get a
// uniform whole number in [min, max]. The STARTING system is the one
// exception: it always holds exactly TWO generated planets — a gas
// giant and a rocky world — which with the home world (the origin, the
// player's homestead, not a generated planet) makes its three planets.
const isHome = record.id === galaxy?.currentSystemId;
const pc = attr.planetCount ?? config.get('systems.planetCount', { noneChance: 0.2, min: 2, max: 4 });
const count = isHome
? 2
: rng.chance(pc.noneChance ?? 0.2) ? 0 : rng.int(pc.min ?? 2, pc.max ?? 4);
const classWeights = attr.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 };
// Planet names come from a curated bank (data/naming.json → banks.planet),
// dealt out per-system without repeats (see NameGenerator.planetDeck).
@ -86,11 +102,12 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
// not one of the generated planets. It takes the first name off that
// system's deck so it can never clash with a planet; the planets then draw
// from the rest of the deck (all distinct within the system).
const isHome = record.id === galaxy?.currentSystemId;
const homeName = isHome ? planetDeck[0] : null;
const planets = [];
for (let i = 1; i <= count; i++) {
const pclass = rng.weighted(classWeights, 'rocky');
// The starting system's two worlds are fixed (gas giant, then rocky);
// everywhere else the class rolls from the type's weights.
const pclass = isHome ? (i === 1 ? 'gas' : 'rocky') : rng.weighted(classWeights, 'rocky');
let moons = 0;
if (rng.chance(attr.moonChance ?? 0.3)) {
// Jovian/ice worlds drag moon systems; terrestrials mostly don't.
@ -178,9 +195,11 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
* - every object's nearest neighbor is then the home world, at
* R maxNeighbor.
* That radius range is non-empty for k 10 (k = 11 would need
* R 10905, which already strands the home world beyond maxNeighbor). So
* the 11-object maximum (9 planets + 2 stations the most the current
* data can produce) gets two orbits: an inner 3-ring and an outer 8-ring.
* R 10905, which already strands the home world beyond maxNeighbor).
* The current data's maximum is 6 objects (4 planets + 2 free-space
* stations), which always fits a single orbit; the 11-object case is kept
* as a defensive fallback (an inner 3-ring and an outer 8-ring) for any
* future data that could produce it.
* The outer ring's near neighbor is its ring-mate (edge stays in
* [minSpacing, maxNeighbor]); the inner ring keeps the home world within
* maxNeighbor; and any inner/outer pair is at least R_outer R_inner
@ -593,11 +612,19 @@ function settlementDensity(galaxy, record) {
}
/**
* Draw settlements for one system. Stable draw order: planet-bound kinds in
* orbital order (colony, mining, cloud), then free-floating (deep-space
* station, waypoint). Every roll goes through the system's own stream.
* Each settlement gets a stable `id` (`<systemId>-s<n>`, n = its position
* in this order) the reputation/factions key.
* Draw settlements for one system. Stable draw order: planet-bound
* settlements in orbital order, then free-floating (deep-space station,
* waypoint). Every roll goes through the system's own stream. Each
* settlement gets a stable `id` (`<systemId>-s<n>`, n = its position in
* this order) the reputation/factions key.
*
* The planet-bound layer is a RULE, not a roll (for now): the galaxy is
* fully settled, so EVERY planet hosts the one kind that fits its class
* (data/settlements.json allPlanetsSettled + settledKindByClass a
* habitable rocky world earns a colony, every other world gets its mining
* outfit, gas giants ride cloud bases). Flip allPlanetsSettled off and
* the old per-type odds (spec.chance + needs) take over again. The
* free-space kinds still roll per type (corerim scaled).
*/
function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density }) {
const out = [];
@ -616,6 +643,15 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
};
// Planet-bound, in orbital order.
if (config.get('settlements.allPlanetsSettled', true) === true) {
// Fully settled: every world hosts the kind that fits its class.
const byClass = config.get('settlements.settledKindByClass', {});
for (const p of planets) {
const kind = settledKindFor(p, byClass, kindDefs);
if (kind) make(kind, { type: 'planet', ordinal: p.ordinal });
}
} else {
// The old probabilistic layer (per-type chances + class needs).
for (const p of planets) {
if (p.habitable && roll(rng, spec.colony?.chance ?? 0.3, density)) {
make('colony', { type: 'planet', ordinal: p.ordinal });
@ -629,8 +665,9 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
make('cloudBase', { type: 'planet', ordinal: p.ordinal });
}
}
}
// Free-floating, out in the dark.
// Free-floating, out in the dark (per-type odds, core→rim scaled).
if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) {
make('deepSpaceStation', { type: 'space' });
}
@ -641,6 +678,16 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
return out;
}
/** The settled kind a planet hosts (settledKindByClass entry → kind id). */
function settledKindFor(planet, byClass, kindDefs) {
const entry = byClass[planet.class];
if (!entry) return null;
const kind = typeof entry === 'string'
? entry
: (planet.habitable ? (entry.habitable ?? entry.default) : entry.default);
return kind && kindDefs[kind] ? kind : null;
}
/** One deterministic roll, scaled by the core→rim density factor. */
function roll(rng, chance, density) {
return rng.chance(chance * density);