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), }); } layoutSystemPlanets(galaxy.seed, record.id, planets); // --- 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), }); // --- 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 worlds (the play field around the origin, * where the player's home world sits). * * Each planet record gains: * x, y — world position (the game renders it there), * scale — size multiplier (data/planets.json → classScale, so gas * giants read bigger than rockies). * * Worlds are scattered in the annulus minOrbit–maxOrbit px from the * origin and kept at least minEdgeGap px (edge-to-edge) apart from each * other and from the origin (data/planets.json → solarSystem). * * Determinism: draws come from a DEDICATED fork — (seed, 'system', id, * 'layout') — so layout never perturbs the star/planet/settlement draws * above, and lazy (on-arrival) === eager (generateAll) is preserved. */ function layoutSystemPlanets(seed, systemId, planets) { const band = config.get('planets.solarSystem', {}); const minOrbit = band.minOrbit ?? 2600; const maxOrbit = band.maxOrbit ?? 8800; const minGap = band.minEdgeGap ?? 1200; const baseR = (config.get('planets.frameWidth', 1024) * config.get('planets.scale', 1)) / 2; const lay = Rng.derive(seed, 'system', systemId, 'layout'); // The origin is occupied by a world (the home world) — respect it. const placed = [{ x: 0, y: 0, r: baseR }]; for (const p of planets) { const scale = config.get(`planets.classScale.${p.class}`, 1) ?? 1; const r = baseR * scale; let ok = false; let x = 0; let y = 0; for (let attempt = 0; attempt < 24 && !ok; attempt++) { const ang = lay.range(0, Math.PI * 2); const d = lay.range(minOrbit, maxOrbit); x = d * Math.cos(ang); y = d * Math.sin(ang); ok = placed.every((q) => Math.hypot(x - q.x, y - q.y) >= q.r + r + minGap); } if (!ok) { // The band is (nearly) full — spiral outward. A deterministic // escape hatch: with the default band and ≤ 9 worlds per system // this never actually triggers. const ang = lay.range(0, Math.PI * 2); const d = maxOrbit + r + minGap + placed.length * (r + minGap); x = d * Math.cos(ang); y = d * Math.sin(ang); } p.scale = scale; p.x = x; p.y = y; placed.push({ x, y, r }); } } /** * 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)))); }