import { config } from '../config/Config.js'; import { Rng } from '../utils/Rng.js'; import { NameGenerator, ordinalLabel } 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 }; 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); } const root = NameGenerator.planetRoot(rng); planets.push({ name: `${root} ${ordinalLabel(i)}`, ordinal: i, root, 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, starName: star.name, 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); return { name: record.name, type: record.type, star, planets, settlements, belt, hazard, }; } /** * 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, starName, density }) { const out = []; const make = (kind, anchor, rootWord) => { const def = kindDefs[kind] ?? {}; out.push({ kind, name: NameGenerator.station(rng, kind, rootWord), 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 }, p.root); } if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) && roll(rng, spec.miningStation?.chance ?? 0.2, density)) { make('miningStation', { type: 'planet', ordinal: p.ordinal }, p.root); } if (needs(p, spec.cloudBase?.needs, ['gas']) && roll(rng, spec.cloudBase?.chance ?? 0.15, density)) { make('cloudBase', { type: 'planet', ordinal: p.ordinal }, p.root); } } // Free-floating, out in the dark. if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) { make('deepSpaceStation', { type: 'space' }, starName); } if (roll(rng, spec.waypoint?.chance ?? 0.2, density)) { make('waypoint', { type: 'space' }, starName); } 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)))); }