import { config } from '../config/Config.js'; import { Rng } from '../utils/Rng.js'; import { NameGenerator } from '../utils/NameGenerator.js'; const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); /** * Turns a lightweight galaxy record (id, name, type, x, y, rNorm) into a * fully generated system: star, planets, moons, SETTLEMENTS, debris belt, * hazard flag. * * Deterministic contract: * same galaxy seed + same record id ⇒ identical content, every time. * The draw stream is derived from (seed, 'system', id) — NOT from the * galaxy-level sequence — so a system generated when the player arrives * is byte-for-byte identical to one generated during an up-front * generateAll(). That's what makes lazy generation safe. * * What the system contains is steered by the TYPE'S ATTRIBUTES in * data/systems.json (`types..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`). * * The galaxy is ALREADY LIVED IN: it was settled long before the player. * Colonies on habitable worlds, mining stations over resource worlds, * cloud bases riding gas giants, stations adrift in open space — and a * fair number of charted-but-unclaimed systems. Nothing here is hostile * yet: `owner` on every settlement is a reserved seam for the factions * and pirates we'll introduce later. */ export function generateSystemContent(galaxy, record, typeDefs = null) { const defs = typeDefs ?? config.get('systems.types', {}); const type = defs[record.type] ?? { label: record.type, attributes: {} }; const attr = type.attributes ?? {}; const rng = Rng.derive(galaxy.seed, 'system', record.id); // --- Star ------------------------------------------------------------- const starClasses = attr.star?.classes ?? { G: 30, K: 35, M: 35 }; const massTable = attr.star?.mass ?? {}; const starClass = rng.weighted(starClasses, 'M'); const mass = Array.isArray(massTable[starClass]) ? massTable[starClass] : [0.3, 1.2]; const star = { name: NameGenerator.star(rng), class: starClass, mass: Number(rng.range(mass[0], mass[1]).toFixed(2)), binary: false, }; if (rng.chance(attr.binaryChance ?? 0.05)) { star.binary = true; star.secondary = { name: NameGenerator.star(rng), class: rng.weighted(starClasses, starClass), }; } // --- Planets ---------------------------------------------------------- const pc = attr.planetCount ?? { min: 3, max: 8, mean: 5 }; const count = Math.round( clamp(pc.mean + (rng.next() - 0.5) * (pc.max - pc.min), pc.min, pc.max), ); const classWeights = attr.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 }; // Planet names come from a curated bank (data/naming.json → banks.planet), // dealt out per-system without repeats (see NameGenerator.planetDeck). // A dedicated derived stream keeps this order-independent (lazy === eager). const planetDeck = NameGenerator.planetDeck( Rng.derive(galaxy.seed, 'system', record.id, 'names', 'planets') ); // The player's home world sits at the origin of the STARTING system and is // 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'); let moons = 0; if (rng.chance(attr.moonChance ?? 0.3)) { // Jovian/ice worlds drag moon systems; terrestrials mostly don't. moons = pclass === 'gas' || pclass === 'ice' ? rng.int(1, 6) : rng.int(0, 2); } planets.push({ name: planetDeck[(isHome ? i : i - 1) % planetDeck.length], ordinal: i, class: pclass, moons, habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1), }); } // --- Settlements (the lived-in layer) --------------------------------- const settlements = generateSettlements({ rng, kindDefs: config.get('settlements.kinds', {}), spec: attr.settlements ?? {}, planets, stationDeck: NameGenerator.stationDeck( Rng.derive(galaxy.seed, 'system', record.id, 'names', 'stations') ), density: settlementDensity(galaxy, record), }); // --- Layout: orbits around the home world ----------------------------- // Planets and free-space stations are the system's layout objects — each // gets an x/y (see layoutSystem for the spacing rules and the N=11 case). const freeSpace = settlements.filter((s) => s.anchor?.type === 'space'); layoutSystem(galaxy.seed, record.id, planets, freeSpace); // --- Debris belt & system-level hazard -------------------------------- const belt = { present: rng.chance(attr.beltChance ?? 0.35), kind: rng.pick(['asteroid', 'debris']) ?? 'asteroid', }; const hazard = rng.chance(attr.hazard ?? 0.1); const content = { name: record.name, type: record.type, star, planets, settlements, belt, hazard, }; if (isHome) content.homeName = homeName; // the player's home world (starting system only) return content; } /** * Top-down layout of a system's objects — its planets and its free-space * stations — as one or two ORBITS (rings) around the home world, which * always sits at the system origin (the game renders a solid world there; * see GameScene). * * Each object gains x, y (world position). Planets also gain scale (size * multiplier, data/planets.json → classScale — the spacing rules below are * center-to-center, but the rendered discs still scale by class). * * The hard spacing rules (data/planets.json → solarSystem): * minSpacing — no two objects (planets, space stations, the home world) * may be closer than this, center to center; * maxNeighbor — whenever a system holds more than one object, every * object must be within this of at least one other. * * A ring of k objects at radius R around the origin satisfies both at once: * - object ⇄ home-world distance is R, so R ∈ [minSpacing, maxNeighbor] * takes care of the home world's own pair of constraints; * - the closest object-object pair on a regular k-gon is an edge, * 2·R·sin(π/k) — an edge is the shortest chord (vertices further around * are further), so edge ≥ minSpacing covers every pair; * - 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. * 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 ≥ * minSpacing apart. Smaller N all share one orbit. (Defensively, beyond * 11 objects the orbits chain outward — rings of ≤ 10, and a lone world * always fits radially outside the previous orbit — so this terminates * for any N.) * * What counts as an object: planets (all of them) and free-space * settlements (anchor.type 'space' — deep-space stations, waypoints). * Planet-bound settlements are features OF their planet (a colony sits on * it) and so are not layout objects of their own. * * Determinism: draws come from the dedicated fork (seed, 'system', id, * 'layout') — layout never perturbs the star/planet/settlement draws * above, and lazy (on-arrival) === eager (generateAll) is preserved. */ function layoutSystem(seed, systemId, planets, freeSpace) { const band = config.get('planets.solarSystem', {}); if (band.enabled === false) return; const MIN_SEP = band.minSpacing ?? 6144; const MAX_NBR = band.maxNeighbor ?? 10240; // Rendered size per class (visual only — the spacing rules are // center-to-center, not edge-to-edge). for (const p of planets) { p.scale = config.get(`planets.classScale.${p.class}`, 1) ?? 1; } // Slot assignment order: planets in orbital order, then free-space // stations in generation order (deep-space station, then waypoint) — // stable per system, so lazy === eager. const objects = [...planets, ...freeSpace]; const N = objects.length; if (N === 0) return; const TAU = Math.PI * 2; const lay = Rng.derive(seed, 'system', systemId, 'layout'); const edgeFactor = (k) => (k <= 1 ? 1 : 2 * Math.sin(Math.PI / k)); const slots = []; let prevR = 0; let prevAngle = 0; const placeRing = (k, lo, hi) => { const R = lay.range(lo, hi); // A LONE object on an outer orbit sits radially outside an // already-placed one: its guaranteed neighbor is then exactly // R − prevR away, whereas a random angle could leave it more than // maxNeighbor from every inner object. const th0 = k === 1 && prevR > 0 ? prevAngle : lay.range(0, TAU); for (let i = 0; i < k; i++) { const th = th0 + (k === 1 ? 0 : (TAU * i) / k); slots.push({ x: R * Math.cos(th), y: R * Math.sin(th) }); } prevR = R; prevAngle = th0; }; if (N <= 10) { // One orbit around the home world (k = 1: a single object, whose only // neighbor is the home world — fine, it's still within [MIN_SEP, MAX_NBR]). const f = edgeFactor(N); placeRing(N, Math.max(MIN_SEP, MIN_SEP / f), MAX_NBR); } else if (N === 11) { // Inner 3-ring: the home world must stay within MAX_NBR of it (R1 ≤ // MAX_NBR), and the outer 8-ring must sit at least MIN_SEP beyond it // at a radius where its edge is still ≤ MAX_NBR: // R1 ≤ MAX_NBR / edgeFactor(8) − MIN_SEP. const f8 = edgeFactor(8); placeRing( 3, Math.max(MIN_SEP, MIN_SEP / edgeFactor(3)), Math.min(MAX_NBR, MAX_NBR / f8 - MIN_SEP), ); const R1 = prevR; // Outer 8-ring: ring-mates are its near neighbors (edge in // [MIN_SEP, MAX_NBR]); every inner object is ≥ R2 − R1 ≥ MIN_SEP away. placeRing( 8, Math.max(MIN_SEP / f8, R1 + MIN_SEP), Math.min(MAX_NBR / f8, R1 + MAX_NBR), ); } else { // Beyond the data maximum: chain orbits outward. A ring of ≤ 10 // objects always has a valid radius as ring 1, a lone world always // fits on any outer orbit — the loop terminates for any N. let rem = N; while (rem > 0) { const k = Math.min(rem, 10); const f = edgeFactor(k); const lo = prevR === 0 ? Math.max(MIN_SEP, MIN_SEP / f) : Math.max(MIN_SEP / f, prevR + MIN_SEP); const hi = prevR === 0 ? MAX_NBR : Math.min(MAX_NBR / f, prevR + MAX_NBR); if (lo <= hi) { placeRing(k, lo, hi); rem -= k; } else { placeRing(1, prevR + MIN_SEP, prevR + MAX_NBR); rem -= 1; } } } for (let i = 0; i < objects.length; i++) { objects[i].x = slots[i].x; objects[i].y = slots[i].y; } } /** * Core→rim density: the settled heart of the galaxy has more activity per * system; the rim is thinner, lonelier. `factor` scales every settlement * chance (clamped to a floor so the rim isn't dead). 0 = no gradient. */ function settlementDensity(galaxy, record) { const g = galaxy?.params?.settlements?.gradient ?? {}; const falloff = Math.max(0, g.falloff ?? 0.7); const floor = clamp(g.floor ?? 0.22, 0, 1); const rNorm = clamp(record?.rNorm ?? 0, 0, 1); return clamp(1 - rNorm * falloff, floor, 1); } /** * Draw settlements for one system. Stable draw order: planet-bound kinds in * orbital order (colony, mining, cloud), then free-floating (deep-space * station, waypoint). Every roll goes through the system's own stream. */ function generateSettlements({ rng, kindDefs, spec, planets, stationDeck, density }) { const out = []; let nameIndex = 0; // next station name from the system's deck (no repeats) const make = (kind, anchor) => { const def = kindDefs[kind] ?? {}; out.push({ kind, name: stationDeck[nameIndex++ % stationDeck.length], anchor, population: logPopulation(rng, def.population), owner: null, // reserved: factions / pirates claim settlements later }); }; // Planet-bound, in orbital order. for (const p of planets) { if (p.habitable && roll(rng, spec.colony?.chance ?? 0.3, density)) { make('colony', { type: 'planet', ordinal: p.ordinal }); } 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. if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) { make('deepSpaceStation', { type: 'space' }); } if (roll(rng, spec.waypoint?.chance ?? 0.2, density)) { make('waypoint', { type: 'space' }); } return out; } /** One deterministic roll, scaled by the core→rim density factor. */ function roll(rng, chance, density) { return rng.chance(chance * density); } /** Does the planet satisfy the kind's requirements? */ function needs(planet, needsList, defaults) { const list = Array.isArray(needsList) && needsList.length ? needsList : defaults; return list.includes(planet.class); } /** Log-uniform population in [min, max] (a few towns to a few megacities). */ function logPopulation(rng, pop) { const min = pop?.min ?? 1; const max = Math.max(min, pop?.max ?? min); if (min <= 0 && rng.chance(0.5)) return 0; // e.g. unmanned waypoints return Math.round(Math.exp(rng.range(Math.log(Math.max(1, min)), Math.log(max)))); }