diff --git a/README.md b/README.md index e21af65..c5d56e6 100644 --- a/README.md +++ b/README.md @@ -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 2–4 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 diff --git a/assets/videos/rocky-land-03.mp4 b/assets/videos/rocky-land-03.mp4 new file mode 100644 index 0000000..8d5bca7 Binary files /dev/null and b/assets/videos/rocky-land-03.mp4 differ diff --git a/data/settlements.json b/data/settlements.json index 8d8ecc9..4db3ba2 100644 --- a/data/settlements.json +++ b/data/settlements.json @@ -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..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", diff --git a/data/systems.json b/data/systems.json index 19f1d9f..7de86c4 100644 --- a/data/systems.json +++ b/data/systems.json @@ -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 } } diff --git a/dev/asteroids.test.mjs b/dev/asteroids.test.mjs index 714d2e1..164adca 100644 --- a/dev/asteroids.test.mjs +++ b/dev/asteroids.test.mjs @@ -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)), ); } diff --git a/dev/discovery.test.mjs b/dev/discovery.test.mjs index 5bb3f03..fe31aa8 100644 --- a/dev/discovery.test.mjs +++ b/dev/discovery.test.mjs @@ -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) => diff --git a/dev/galaxy.test.mjs b/dev/galaxy.test.mjs index d679d85..8285834 100644 --- a/dev/galaxy.test.mjs +++ b/dev/galaxy.test.mjs @@ -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)); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 5d18f78..1ff6eac 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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] (2–4). + 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..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..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^(level−1) — -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, diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index a3f116e..e5da46a 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -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..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 core→rim 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 2–4 + * worlds. Settlement kinds and their population ranges live in + * data/settlements.json; the core→rim 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` (`-s`, 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` (`-s`, 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 (core→rim scaled). */ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density }) { const out = []; @@ -616,21 +643,31 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe }; // 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 }); + 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 }); } - 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 }); + } 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 }); + } + 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)) { 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);