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 generated lazily on arrival, deterministically (seed + system id), so
lazy and eager give identical results. lazy and eager give identical results.
- **A lived-in galaxy**: the galaxy was settled long before you arrive. - **A lived-in galaxy**: the galaxy was settled long before you arrive.
Systems host colonies on habitable worlds, mining stations over resource Every planet is settled (for now): colonies on habitable worlds, mining
worlds, cloud bases riding gas giants, stations adrift in open space, and stations over the rest, cloud bases riding gas giants — plus stations
beacons — or report *charted · unclaimed* when nobody's there. Rates are adrift in open space and beacons, whose odds are per-archetype
per-archetype (`data/systems.json`) and thin out from the settled core to (`data/systems.json`) and thin out from the settled core to the wilder
the wilder rim (`data/galaxy.json`). Each settlement has a name, rim (`data/galaxy.json`). Barren systems with nothing adrift report
population, and an `owner` seam reserved for the factions/pirates to come. *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 The current system's dossier (name, identity, what's there) shows
top-left in the game scene. top-left in the game scene.
- **Names with a point of view** (`data/naming.json`): planets draw from a - **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": { "kinds": {
"colony": { "colony": {
"label": "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": { "types": {
"main": { "main": {
"label": "Main Sequence", "label": "Main Sequence",
@ -12,16 +13,12 @@
"mass": { "G": [0.8, 1.4], "K": [0.5, 0.8], "M": [0.1, 0.5] } "mass": { "G": [0.8, 1.4], "K": [0.5, 0.8], "M": [0.1, 0.5] }
}, },
"binaryChance": 0.05, "binaryChance": 0.05,
"planetCount": { "min": 4, "max": 9, "mean": 6.5 },
"planetClasses": { "rocky": 45, "gas": 25, "ice": 18, "lava": 12 }, "planetClasses": { "rocky": 45, "gas": 25, "ice": 18, "lava": 12 },
"moonChance": 0.3, "moonChance": 0.3,
"beltChance": 0.35, "beltChance": 0.35,
"habitability": 0.1, "habitability": 0.1,
"hazard": 0.1, "hazard": 0.1,
"settlements": { "settlements": {
"colony": { "chance": 0.45 },
"miningStation": { "chance": 0.3, "needs": ["rocky", "lava", "ice"] },
"cloudBase": { "chance": 0.35, "needs": ["gas"] },
"deepSpaceStation": { "chance": 0.25 }, "deepSpaceStation": { "chance": 0.25 },
"waypoint": { "chance": 0.35 } "waypoint": { "chance": 0.35 }
} }
@ -38,16 +35,12 @@
"mass": { "M": [0.08, 0.35], "K": [0.5, 0.8] } "mass": { "M": [0.08, 0.35], "K": [0.5, 0.8] }
}, },
"binaryChance": 0.12, "binaryChance": 0.12,
"planetCount": { "min": 3, "max": 7, "mean": 5 },
"planetClasses": { "rocky": 50, "gas": 15, "ice": 28, "lava": 7 }, "planetClasses": { "rocky": 50, "gas": 15, "ice": 28, "lava": 7 },
"moonChance": 0.45, "moonChance": 0.45,
"beltChance": 0.3, "beltChance": 0.3,
"habitability": 0.22, "habitability": 0.22,
"hazard": 0.12, "hazard": 0.12,
"settlements": { "settlements": {
"colony": { "chance": 0.6 },
"miningStation": { "chance": 0.35, "needs": ["rocky", "ice"] },
"cloudBase": { "chance": 0.1, "needs": ["gas"] },
"deepSpaceStation": { "chance": 0.2 }, "deepSpaceStation": { "chance": 0.2 },
"waypoint": { "chance": 0.3 } "waypoint": { "chance": 0.3 }
} }
@ -64,16 +57,12 @@
"mass": { "F": [1.0, 1.6], "G": [0.8, 1.4], "K": [0.5, 0.8] } "mass": { "F": [1.0, 1.6], "G": [0.8, 1.4], "K": [0.5, 0.8] }
}, },
"binaryChance": 1.0, "binaryChance": 1.0,
"planetCount": { "min": 2, "max": 6, "mean": 4 },
"planetClasses": { "rocky": 30, "gas": 40, "ice": 20, "lava": 10 }, "planetClasses": { "rocky": 30, "gas": 40, "ice": 20, "lava": 10 },
"moonChance": 0.5, "moonChance": 0.5,
"beltChance": 0.6, "beltChance": 0.6,
"habitability": 0.06, "habitability": 0.06,
"hazard": 0.18, "hazard": 0.18,
"settlements": { "settlements": {
"colony": { "chance": 0.1 },
"miningStation": { "chance": 0.3, "needs": ["rocky", "gas"] },
"cloudBase": { "chance": 0.5, "needs": ["gas"] },
"deepSpaceStation": { "chance": 0.3 }, "deepSpaceStation": { "chance": 0.3 },
"waypoint": { "chance": 0.3 } "waypoint": { "chance": 0.3 }
} }
@ -90,16 +79,12 @@
"mass": { "G": [0.85, 1.3], "K": [0.6, 0.85] } "mass": { "G": [0.85, 1.3], "K": [0.6, 0.85] }
}, },
"binaryChance": 0.03, "binaryChance": 0.03,
"planetCount": { "min": 3, "max": 8, "mean": 5.5 },
"planetClasses": { "rocky": 62, "gas": 18, "ice": 14, "lava": 6 }, "planetClasses": { "rocky": 62, "gas": 18, "ice": 14, "lava": 6 },
"moonChance": 0.35, "moonChance": 0.35,
"beltChance": 0.25, "beltChance": 0.25,
"habitability": 0.5, "habitability": 0.5,
"hazard": 0.05, "hazard": 0.05,
"settlements": { "settlements": {
"colony": { "chance": 0.85 },
"miningStation": { "chance": 0.2, "needs": ["rocky", "ice"] },
"cloudBase": { "chance": 0.2, "needs": ["gas"] },
"deepSpaceStation": { "chance": 0.25 }, "deepSpaceStation": { "chance": 0.25 },
"waypoint": { "chance": 0.4 } "waypoint": { "chance": 0.4 }
} }
@ -116,16 +101,12 @@
"mass": { "A": [1.4, 2.1], "F": [1.0, 1.6], "G": [0.8, 1.4] } "mass": { "A": [1.4, 2.1], "F": [1.0, 1.6], "G": [0.8, 1.4] }
}, },
"binaryChance": 0.1, "binaryChance": 0.1,
"planetCount": { "min": 1, "max": 5, "mean": 3 },
"planetClasses": { "rocky": 20, "gas": 25, "ice": 15, "lava": 40 }, "planetClasses": { "rocky": 20, "gas": 25, "ice": 15, "lava": 40 },
"moonChance": 0.2, "moonChance": 0.2,
"beltChance": 0.8, "beltChance": 0.8,
"habitability": 0.02, "habitability": 0.02,
"hazard": 0.35, "hazard": 0.35,
"settlements": { "settlements": {
"colony": { "chance": 0.05 },
"miningStation": { "chance": 0.5, "needs": ["lava", "rocky"] },
"cloudBase": { "chance": 0.3, "needs": ["gas"] },
"deepSpaceStation": { "chance": 0.2 }, "deepSpaceStation": { "chance": 0.2 },
"waypoint": { "chance": 0.45 } "waypoint": { "chance": 0.45 }
} }
@ -142,16 +123,12 @@
"mass": { "M": [0.08, 0.3], "K": [0.4, 0.6] } "mass": { "M": [0.08, 0.3], "K": [0.4, 0.6] }
}, },
"binaryChance": 0.08, "binaryChance": 0.08,
"planetCount": { "min": 0, "max": 4, "mean": 1.5 },
"planetClasses": { "rocky": 20, "gas": 10, "ice": 55, "lava": 15 }, "planetClasses": { "rocky": 20, "gas": 10, "ice": 55, "lava": 15 },
"moonChance": 0.1, "moonChance": 0.1,
"beltChance": 0.2, "beltChance": 0.2,
"habitability": 0.02, "habitability": 0.02,
"hazard": 0.5, "hazard": 0.5,
"settlements": { "settlements": {
"colony": { "chance": 0.02 },
"miningStation": { "chance": 0.3, "needs": ["ice"] },
"cloudBase": { "chance": 0.05, "needs": ["gas"] },
"deepSpaceStation": { "chance": 0.15 }, "deepSpaceStation": { "chance": 0.15 },
"waypoint": { "chance": 0.25 } "waypoint": { "chance": 0.25 }
} }

View File

@ -244,10 +244,10 @@ check(
return { planets: c.planets.length, clusters: c.asteroids.length }; return { planets: c.planets.length, clusters: c.asteroids.length };
}); });
const avg = (xs) => xs.reduce((s, x) => s + x, 0) / xs.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); const poor = rows.filter((r) => r.planets <= 2);
check( 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)), 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'); const g = Galaxy.create('discovery-layout-test');
let layoutOk = true; let layoutOk = true;
let layoutWhy = ''; 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) => { const probe = (systems) => {
for (const rec of systems) { for (const rec of systems) {
const c = g.ensureContent(rec.id); const c = g.ensureContent(rec.id);
@ -144,7 +145,9 @@ const probe = (systems) => {
} }
objs.push({ x: s.x, y: s.y }); 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). // 1) No two objects closer than minSpacing (center to center).
for (let i = 0; i < objs.length && layoutOk; i++) { for (let i = 0; i < objs.length && layoutOk; i++) {
for (let j = i + 1; j < objs.length; j++) { 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}`, `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, 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. // Determinism on a system that has BOTH planet and station objects.
const rec0 = g.records.find((r) => const rec0 = g.records.find((r) =>

View File

@ -146,21 +146,28 @@ let big;
} }
check('radius bands (proximity rule) respected by every system', bandOk); 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 t1 = performance.now();
const pc = config.get('systems.planetCount', { noneChance: 0, min: 0, max: 99 });
let boundsOk = true; let boundsOk = true;
let emptyCount = 0;
for (const r of big.records) { for (const r of big.records) {
const content = big.ensureContent(r.id); const content = big.ensureContent(r.id);
const attr = types[r.type].attributes ?? {}; const n = content.planets.length;
const pc = attr.planetCount ?? { min: 0, max: 99 }; if (n === 0) emptyCount++;
if (content.planets.length < pc.min || content.planets.length > pc.max) { if (!(n === 0 || (n >= pc.min && n <= pc.max))) {
boundsOk = false; boundsOk = false;
break; break;
} }
if (!content.star || typeof content.star.class !== 'string') boundsOk = false; if (!content.star || typeof content.star.class !== 'string') boundsOk = false;
} }
const tGen = performance.now() - t1; 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); check('lazy content generation over all 40k systems', big.generatedCount === nSys);
// Lazy === eager: fresh galaxy (unopened) vs fully generated one. // 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('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); 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. // Not everything is inhabited — some systems stay unclaimed.
let unclaimed = 0; let unclaimed = 0;
for (const r of sample) if ((big2.ensureContent(r.id).settlements).length === 0) unclaimed++; 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); const nonHomeRec = big2.records.find((r) => r.id !== homeRec.id);
check('other systems have no home world', !('homeName' in big2.ensureContent(nonHomeRec.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). // Lazy === eager still holds with the lived-in layer (spot check).
const fresh2 = Galaxy.create(SEED); 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)); 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: **System archetypes** live in `data/systems.json`. Each type is:
- **themable**`theme.color` etc. for UI; - **themable**`theme.color` etc. for UI;
- **attribute-driven**`attributes` steer the SystemGenerator (star - **attribute-driven**`attributes` steer the SystemGenerator (star
classes, binary chance, planet count spread, planet class weights, classes, binary chance, planet class weights, moon/belt chances,
moon/belt chances, habitability, hazard). New attribute key = JSON + habitability, hazard, free-space settlement odds). New attribute key =
a few lines in `SystemGenerator.js`; 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 - **distributed**`distribution.weight` (how common) and
`distribution.radiusBand` (first proximity rule: e.g. `void` systems `distribution.radiusBand` (first proximity rule: e.g. `void` systems
live in the outer rim). Richer galaxy-level rules (clustering, 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. and the spatial hash already built for it.
**The galaxy is already lived in.** It was settled long before the **The galaxy is already lived in.** It was settled long before the
player arrives. Every system's content can include **settlements**: player arrives. **Every planet is settled (for now)**: each world hosts
`colony` (on a habitable world), `miningStation` (over a resource the one settlement kind that fits its class (`data/settlements.json →
world), `cloudBase` (riding a gas giant), `deepSpaceStation` (adrift in allPlanetsSettled` + `settledKindByClass`) — `colony` on a habitable
open space), `waypoint` (a small beacon — the faint trace of a crossed rocky world, `miningStation` over every other rocky/ice/lava world,
galaxy). Not everything is inhabited: many systems report "charted · `cloudBase` riding every gas giant. Plus the free-space kinds:
unclaimed". Model & seams: `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, - **Kinds vocabulary**`data/settlements.json` (label, description,
theme color, population range, anchor type). Add a kind = JSON + naming theme color, population range, anchor type). Add a kind = JSON + naming
pool; the generator picks it up by name. pool; the generator picks it up by name.
- **Per-type rates**`types.<id>.attributes.settlements` in - **The settled rule**`data/settlements.json → allPlanetsSettled` +
`data/systems.json`: `chance` + optional `needs` (planet classes) per `settledKindByClass` (class → kind, with a `habitable` override for
kind. Same attribute-driven pattern as everything else. 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 - **Core→rim gradient**`galaxy.settlements.gradient` in
`data/galaxy.json`: the settled heart is denser (factor 1.0), the rim `data/galaxy.json`: the settled heart is denser (factor 1.0), the rim
is thinner (clamped to `floor`). Each record carries `rNorm` (0 = 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 center-to-center, between ANY two objects — planets, stations, the home
world) and `maxNeighbor` (10240 px — whenever a system holds more than 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 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 orbit's near neighbor is the home world at the origin). The current data
share one orbit; the 11-object maximum (9 planets + 2 stations) splits max is 6 objects (4 planets + 2 stations), which always shares one
into a 3-ring + 8-ring. Planet-bound settlements (colonies, mining orbit; the 11-object two-orbit case (3-ring + 8-ring) is kept as a
stations, cloud bases) are features of their planet, not layout objects. 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, Draws come from the dedicated fork `Rng.derive(seed, 'system', id,
'layout')` — layout results are seed-deterministic *and* don't perturb 'layout')` — layout results are seed-deterministic *and* don't perturb
the content stream (lazy === eager is preserved). Class sizes/tints in 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. visual/feel parameter.
**Upgrade economics (planned):** radius(level) = 5120 × 2^(level1) — **Upgrade economics (planned):** radius(level) = 5120 × 2^(level1) —
level 2 = 10240 px, which reaches every object of a ≤ 10-object system level 2 = 10240 px, which reaches every object of the current 6-object
(all within 10240 px of the home world); the far 8-ring of the rare maximum (all within 10240 px of the home world); the defensive two-orbit
11-object system (up to ~13380 px out) fits inside level 3 (20480 px). Extra fallback (11 objects, up to ~13380 px out) would fit inside level 3
tethers anchored on planets/stations will be the "expand your reach" verb; (20480 px). Extra tethers anchored on planets/stations will be the
the seams are in place. "expand your reach" verb; the seams are in place.
**Phaser v4 note:** the barrier is two `Graphics` layers redrawn per frame **Phaser v4 note:** the barrier is two `Graphics` layers redrawn per frame
(`clear()` → one `strokePath` per pass) — no textures, no physics bodies, (`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 * What the system contains is steered by the TYPE'S ATTRIBUTES in
* data/systems.json (`types.<id>.attributes`): star classes, binary * data/systems.json (`types.<id>.attributes`): star classes, binary
* chance, planet count spread, planet class weights, moon/belt chances, * chance, planet class weights, moon/belt chances, habitability, hazard,
* habitability, hazard, and the lived-in layer `settlements` (chance * and the lived-in layer `settlements` (per-type odds for the
* + required planet class per settlement kind). Settlement kinds and their * free-space kinds). Planet COUNT is a global rule (data/systems.json
* population ranges live in data/settlements.json; the corerim density * `planetCount`): noneChance of systems are barren, the rest hold 24
* gradient in data/galaxy.json (`settlements.gradient`). * 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. * The galaxy is ALREADY LIVED IN: it was settled long before the player.
* Colonies on habitable worlds, mining stations over resource worlds, * EVERY planet hosts a settlement (for now data/settlements.json
* cloud bases riding gas giants, stations adrift in open space and a * allPlanetsSettled + settledKindByClass): colonies on habitable worlds,
* fair number of charted-but-unclaimed systems. Nothing here is hostile * mining stations over the rest, cloud bases riding gas giants. The
* yet: `owner` on every settlement is a reserved seam for the factions * charted-but-unclaimed systems are the barren ones that also roll no
* and pirates we'll introduce later. * 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 * Place identity (the reputation/trading/faction keys): every planet and
* settlement carries a stable `id`, seed-deterministic because it is * settlement carries a stable `id`, seed-deterministic because it is
@ -71,10 +80,17 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
} }
// --- Planets ---------------------------------------------------------- // --- Planets ----------------------------------------------------------
const pc = attr.planetCount ?? { min: 3, max: 8, mean: 5 }; // How many worlds the system holds (data/systems.json → planetCount):
const count = Math.round( // noneChance of systems are barren (ZERO planets); the rest get a
clamp(pc.mean + (rng.next() - 0.5) * (pc.max - pc.min), pc.min, pc.max), // 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 }; const classWeights = attr.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 };
// Planet names come from a curated bank (data/naming.json → banks.planet), // Planet names come from a curated bank (data/naming.json → banks.planet),
// dealt out per-system without repeats (see NameGenerator.planetDeck). // 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 // 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 // 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). // from the rest of the deck (all distinct within the system).
const isHome = record.id === galaxy?.currentSystemId;
const homeName = isHome ? planetDeck[0] : null; const homeName = isHome ? planetDeck[0] : null;
const planets = []; const planets = [];
for (let i = 1; i <= count; i++) { 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; let moons = 0;
if (rng.chance(attr.moonChance ?? 0.3)) { if (rng.chance(attr.moonChance ?? 0.3)) {
// Jovian/ice worlds drag moon systems; terrestrials mostly don't. // 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 * - every object's nearest neighbor is then the home world, at
* R maxNeighbor. * R maxNeighbor.
* That radius range is non-empty for k 10 (k = 11 would need * That radius range is non-empty for k 10 (k = 11 would need
* R 10905, which already strands the home world beyond maxNeighbor). So * R 10905, which already strands the home world beyond maxNeighbor).
* the 11-object maximum (9 planets + 2 stations the most the current * The current data's maximum is 6 objects (4 planets + 2 free-space
* data can produce) gets two orbits: an inner 3-ring and an outer 8-ring. * 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 * The outer ring's near neighbor is its ring-mate (edge stays in
* [minSpacing, maxNeighbor]); the inner ring keeps the home world within * [minSpacing, maxNeighbor]); the inner ring keeps the home world within
* maxNeighbor; and any inner/outer pair is at least R_outer R_inner * 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 * Draw settlements for one system. Stable draw order: planet-bound
* orbital order (colony, mining, cloud), then free-floating (deep-space * settlements in orbital order, then free-floating (deep-space station,
* station, waypoint). Every roll goes through the system's own stream. * waypoint). Every roll goes through the system's own stream. Each
* Each settlement gets a stable `id` (`<systemId>-s<n>`, n = its position * settlement gets a stable `id` (`<systemId>-s<n>`, n = its position in
* in this order) the reputation/factions key. * 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 }) { function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density }) {
const out = []; const out = [];
@ -616,21 +643,31 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
}; };
// Planet-bound, in orbital order. // Planet-bound, in orbital order.
for (const p of planets) { if (config.get('settlements.allPlanetsSettled', true) === true) {
if (p.habitable && roll(rng, spec.colony?.chance ?? 0.3, density)) { // Fully settled: every world hosts the kind that fits its class.
make('colony', { type: 'planet', ordinal: p.ordinal }); 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 });
} }
if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) && } else {
roll(rng, spec.miningStation?.chance ?? 0.2, density)) { // The old probabilistic layer (per-type chances + class needs).
make('miningStation', { type: 'planet', ordinal: p.ordinal }); for (const p of planets) {
} if (p.habitable && roll(rng, spec.colony?.chance ?? 0.3, density)) {
if (needs(p, spec.cloudBase?.needs, ['gas']) && make('colony', { type: 'planet', ordinal: p.ordinal });
roll(rng, spec.cloudBase?.chance ?? 0.15, density)) { }
make('cloudBase', { type: 'planet', ordinal: p.ordinal }); if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) &&
roll(rng, spec.miningStation?.chance ?? 0.2, density)) {
make('miningStation', { type: 'planet', ordinal: p.ordinal });
}
if (needs(p, spec.cloudBase?.needs, ['gas']) &&
roll(rng, spec.cloudBase?.chance ?? 0.15, density)) {
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)) { if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) {
make('deepSpaceStation', { type: 'space' }); make('deepSpaceStation', { type: 'space' });
} }
@ -641,6 +678,16 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
return out; 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. */ /** One deterministic roll, scaled by the core→rim density factor. */
function roll(rng, chance, density) { function roll(rng, chance, density) {
return rng.chance(chance * density); return rng.chance(chance * density);