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)); const TAU = Math.PI * 2; const DEG = Math.PI / 180; /** * 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. * * Place identity (the reputation/trading/faction keys): every planet and * settlement carries a stable `id`, seed-deterministic because it is * built from the system id + the object's position in its generated list: * planets `-p` (p1…pn, orbital order) * settlements `-s` (s1…, draw order: planet-bound * kinds in orbital order, then * free-space) * Same seed ⇒ same ids ⇒ a reputation saved against them lines up with * the regenerated galaxy (see js/reputation/Reputation.js). The player's * home world is not one of the generated planets — it has the fixed key * 'home'. */ 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({ id: `${record.id}-p${i}`, // reputation/trading key (stable per seed) 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, systemId: record.id, 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); // --- Asteroid clusters ------------------------------------------------ // Loose groups of slowly tumbling rocks scattered through the void. // Generated AFTER the layout (so every placed object is a spacing // obstacle) from the dedicated stream (seed, 'system', id, 'asteroids') // — independent of the star/planet/settlement/layout draws above, so // lazy (on-arrival) === eager (generateAll) is preserved. const asteroids = generateAsteroidClusters(galaxy, record, 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, asteroids, 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; } } /** * Asteroid clusters — the system's loose rock fields (data/asteroids.json). * * A cluster is a GROUP of 4–8 rocks (data → cluster.groupSize), each 64–128 * px (cluster.sizes) with at least one full-size rock, sitting within * `cluster.spread` of the cluster center, and kept a small gap apart from * each other (cluster.gapFactor — the generator relaxes any overlap so no * two rocks interpenetrate). Every rock tumbles on its own * slow spin (its own speed and direction — cluster.spin), and the whole * group drifts slowly around its center (cluster.groupSpin) — the render * (js/entities/AsteroidCluster.js) reads these straight off the record. * * The rules (all from data/asteroids.json): * - COUNT vs PLANETS — clusterCount ≈ targetObjects − planetCount (± * jitter, clamped to [minClusters, maxClusters]): the more planets a * system has, the fewer asteroid clusters, and vice versa. The * STARTING system always gets at least startingSystemMinClusters, and * those first ones are placed inside the player's initial tether * (tether.level1Radius × radiusGrowth^(homeLevel−1), whole cluster, * minus placement.tetherMargin) so the player can reach them. * - SPACING — no cluster center may sit closer than * placement.minObjectSpacing (1024 px, center-to-center) to ANY other * object: the home world (origin), every planet, every free-space * station, and every other cluster. * - SCATTER — other clusters land anywhere in the annulus * [placement.minRadius, placement.maxRadius] around the origin, picked * by seeded rejection sampling (uniform in area) — sprinkled through * the void, inside and outside the planet orbits. * - FRAMES — each rock is a random sheet frame, with NO frame repeated * twice in the same cluster (a seeded shuffle of the frame pool). * - NAMES — synthesised (NameGenerator.asteroid) and never repeating a * name already used in the system (planets, stations, other clusters). * * Determinism: every draw comes from the dedicated forks * Rng.derive(seed, 'system', id, 'asteroids') — members, spins, placement * Rng.derive(seed, 'system', id, 'names', 'asteroids') — names * — so clusters are seed-deterministic, independent of the other streams, * and lazy === eager (see generateSystemContent). * * Record shape (one per cluster): * { * id, name, x, y, // cluster center + identity * bound, // max extent from center = discovery radius * tint, // per-cluster starlight tint (int, or null) * groupSpin, groupPhase, // the loose group's slow drift (rad/s, rad) * debrisPhase, debris: [], // dust motes (local px, size, alpha) * asteroids: [{ frame, x, y, size, spin, phase }] // rocks, local px * } */ function generateAsteroidClusters(galaxy, record, planets, freeSpace) { const cfg = config.section('asteroids', {}); if (cfg.enabled === false) return []; // Without the solar-system layout there are no placed objects to space // against — no clusters either (the scene renders nothing else anyway). if (config.get('planets.solarSystem.enabled', true) === false) return []; const clusterCfg = cfg.cluster ?? {}; const dist = cfg.distribution ?? {}; const placement = cfg.placement ?? {}; const isHome = record.id === galaxy?.currentSystemId; // --- How many clusters: the inverse of the planet count --------------- const target = dist.targetObjects ?? 9; const jitter = dist.jitter ?? 1; const minC = Math.max(1, Math.floor(dist.minClusters ?? 1)); const maxC = Math.max(minC, Math.floor(dist.maxClusters ?? 6)); const homeMin = isHome ? Math.max(minC, Math.floor(dist.startingSystemMinClusters ?? 2)) : minC; const rng = Rng.derive(galaxy.seed, 'system', record.id, 'asteroids'); const count = clamp( Math.round(target - planets.length + rng.range(-jitter, jitter)), homeMin, maxC, ); if (count === 0) return []; // --- Parameter plumbing (every value steerable from data/asteroids.json) const frameCount = Math.max(1, Math.floor(cfg.frameCount ?? 10)); const sizeMin = Math.floor(clusterCfg.sizes?.min ?? 64); const sizeMax = Math.max(sizeMin, Math.floor(clusterCfg.sizes?.max ?? 128)); const groupMin = Math.max(1, Math.floor(clusterCfg.groupSize?.min ?? 4)); const groupMax = Math.max(groupMin, Math.floor(clusterCfg.groupSize?.max ?? 8)); const spread = clusterCfg.spread ?? 140; const spinMin = Math.max(0, clusterCfg.spin?.minDegPerSec ?? 0.3) * DEG; const spinMax = Math.max(spinMin, clusterCfg.spin?.maxDegPerSec ?? 1.6) * DEG; const gspinMin = Math.max(0, clusterCfg.groupSpin?.minDegPerSec ?? 0.12) * DEG; const gspinMax = Math.max(gspinMin, clusterCfg.groupSpin?.maxDegPerSec ?? 0.4) * DEG; const tintAnchors = clusterCfg.tint?.enabled === false ? [] : (clusterCfg.tint?.anchors ?? ['#dfe9ff', '#ffe9d6', '#e8e4f8']); const tintStrength = clamp(clusterCfg.tint?.strength ?? 0.5, 0, 1); const debrisCfg = clusterCfg.debris ?? {}; // --- Placement rules --------------------------------------------------- const rMin = placement.minRadius ?? 2048; const rMax = Math.max(rMin, placement.maxRadius ?? 18432); const minSep = placement.minObjectSpacing ?? 1024; const maxAttempts = Math.max(1, Math.floor(placement.maxPlacementAttempts ?? 400)); const tetherMargin = placement.tetherMargin ?? 96; const tetherRadius = homeTetherRadius(); const homeSlots = isHome ? Math.min(count, homeMin) : 0; // Names already taken in this system (planets + stations). const nameRng = Rng.derive(galaxy.seed, 'system', record.id, 'names', 'asteroids'); const usedNames = new Set(); for (const p of planets) if (p.name) usedNames.add(p.name); for (const s of freeSpace) if (s.name) usedNames.add(s.name); // Spacing obstacles: the home world (origin) + every placed object. const placed = [{ x: 0, y: 0 }]; for (const p of planets) if (typeof p.x === 'number' && typeof p.y === 'number') placed.push({ x: p.x, y: p.y }); for (const s of freeSpace) if (typeof s.x === 'number' && typeof s.y === 'number') placed.push({ x: s.x, y: s.y }); const clusters = []; for (let i = 0; i < count; i++) { // --- The group's rocks --------------------------------------------- const memberCount = rng.int(groupMin, groupMax); // Frames: a random subset of the sheet — NO frame twice in this cluster. const frames = rng.shuffle(Array.from({ length: frameCount }, (_, k) => k)).slice(0, memberCount); // Sizes: random in [sizeMin, sizeMax]; at least minFullSize full-size. const sizes = Array.from({ length: memberCount }, () => rng.int(sizeMin, sizeMax)); const fullRocks = Math.min(Math.max(0, Math.floor(clusterCfg.minFullSize ?? 1)), memberCount); for (let k = 0; k < fullRocks; k++) sizes[rng.int(0, memberCount - 1)] = sizeMax; // Offsets: a dense random blob around the center (uniform in area), // then a relaxation pass enforces a small gap between EVERY pair of // rocks (cluster.gapFactor — 1.12 ≈ a subtle gap): only pairs that // would overlap move, each by half the shortfall, so the group keeps // its random look and stays compact while no rocks interpenetrate. const gapFactor = Math.max(1, clusterCfg.gapFactor ?? 1.12); const members = []; for (let k = 0; k < memberCount; k++) { const a = rng.range(0, TAU); const r = spread * Math.sqrt(rng.next()); // uniform in area members.push({ x: Math.cos(a) * r, y: Math.sin(a) * r, size: sizes[k] }); } relaxRockGaps(members, gapFactor); const bound = Math.max(...members.map((m) => Math.hypot(m.x, m.y) + m.size / 2)); // Spins: each rock tumbles on its own — its own slow speed, its own // direction, its own starting phase. const spins = members.map(() => (rng.chance(0.5) ? 1 : -1) * rng.range(spinMin, spinMax)); const phases = members.map(() => rng.range(0, TAU)); // Dust: a fine halo of motes orbiting just outside the rocks. const debris = []; if (debrisCfg.enabled !== false) { const n = rng.int(debrisCfg.count?.[0] ?? 14, debrisCfg.count?.[1] ?? 30); const rIn = bound * (debrisCfg.inner ?? 1.0); const rOut = Math.max(rIn, bound * (debrisCfg.outer ?? 2.1)); for (let k = 0; k < n; k++) { const a = rng.range(0, TAU); const r = Math.sqrt(rng.range(rIn * rIn, rOut * rOut)); debris.push({ x: Math.cos(a) * r, y: Math.sin(a) * r, size: rng.range(debrisCfg.size?.[0] ?? 0.8, debrisCfg.size?.[1] ?? 2.4), alpha: rng.range(debrisCfg.alpha?.[0] ?? 0.12, debrisCfg.alpha?.[1] ?? 0.4), }); } } // Starlight: a subtle warm/cool shift, per cluster. const tint = tintAnchors.length ? mixTint(tintAnchors[rng.int(0, tintAnchors.length - 1)] ?? tintAnchors[0], tintStrength) : null; // Name: synthesised, unique within the system. let name = NameGenerator.asteroid(nameRng); for (let tries = 0; tries < 10 && usedNames.has(name); tries++) name = NameGenerator.asteroid(nameRng); usedNames.add(name); // --- Where: sprinkle it in the void -------------------------------- // The starting system's first clusters live INSIDE the initial tether // (whole group: center + bound + margin ≤ tether radius); the rest — // and every cluster elsewhere — go anywhere in the scatter annulus. const zoneMax = i < homeSlots ? Math.max(rMin, Math.min(rMax, tetherRadius - bound - tetherMargin)) : rMax; const pos = placeInAnnulus(rng, rMin, zoneMax, placed, minSep, maxAttempts); placed.push(pos); clusters.push({ id: `asteroid-${i + 1}`, name, x: pos.x, y: pos.y, bound, tint, groupSpin: (rng.chance(0.5) ? 1 : -1) * rng.range(gspinMin, gspinMax), groupPhase: rng.range(0, TAU), debrisPhase: rng.range(0, TAU), // The dust glides on its OWN slow orbit (0.2–0.5 deg/s, random way) // — fine particles drifting around the rocks, not locked to them. debrisSpin: (rng.next() < 0.5 ? 1 : -1) * rng.range(0.2, 0.5) * DEG, debris, asteroids: members.map((m, k) => ({ frame: frames[k], x: m.x, y: m.y, size: m.size, spin: spins[k], phase: phases[k], })), }); } return clusters; } /** * A random point in the annulus [rMin, rMax] around the origin (uniform in * AREA) that is ≥ minSep from every placed object — seeded rejection * sampling. If the annulus is hopelessly crowded (it isn't, at these * numbers) it returns the best candidate rather than failing. */ function placeInAnnulus(rng, rMin, rMax, placed, minSep, maxAttempts) { let best = null; for (let attempt = 0; attempt < maxAttempts; attempt++) { const a = rng.range(0, TAU); const r = Math.sqrt(rng.range(rMin * rMin, rMax * rMax)); const x = Math.cos(a) * r; const y = Math.sin(a) * r; let ok = true; let worst = Infinity; for (const p of placed) { const d = Math.hypot(x - p.x, y - p.y); if (d < minSep) { ok = false; if (d < worst) worst = d; } } if (ok) return { x, y }; if (best === null || worst > best.worst) best = { x, y, worst }; // best = furthest from the closest object } return { x: best.x, y: best.y }; } /** * Enforce the generator's gap rule: after the pass, every pair of rocks is * ≥ gapFactor × (r1 + r2) apart (centers) — a small visible edge-gap * (1.0 = touching). Iterative pairwise separation: each violator moves half * the shortfall, only violating pairs move (minimal displacement), and a * few dozen sweeps settle any rock count. Fully deterministic (fixed pair * order, fixed iteration cap). */ function relaxRockGaps(members, gapFactor, maxIter = 96) { for (let it = 0; it < maxIter; it++) { let worst = 0; for (let i = 0; i < members.length; i++) { for (let j = i + 1; j < members.length; j++) { const a = members[i]; const b = members[j]; let dx = b.x - a.x; let dy = b.y - a.y; let d = Math.hypot(dx, dy); const need = gapFactor * (a.size / 2 + b.size / 2); if (d >= need) continue; if (d < 1e-9) { dx = 1; dy = 0; d = 1; } // coincident: deterministic axis const push = (need - d) / 2; a.x -= (dx / d) * push; a.y -= (dy / d) * push; b.x += (dx / d) * push; b.y += (dy / d) * push; worst = Math.max(worst, need - d); } } if (worst <= 1e-6) break; } } /** The starting system's initial tether radius (home world's level). */ function homeTetherRadius() { const level = Math.max(1, Math.floor(config.get('tether.homeLevel', 1))); const base = config.get('tether.level1Radius', 5120); const growth = config.get('tether.radiusGrowth', 1.25); return base * Math.pow(growth, level - 1); } /** * Blend a hex tint anchor toward white by `strength` (0 = white, 1 = the * anchor) → a 24-bit canvas tint. Pure (no Phaser — this runs in Node). */ function mixTint(hex, strength) { let h = String(hex ?? '').trim().replace(/^#/, ''); if (h.length === 3) h = h.split('').map((c) => c + c).join(''); const n = parseInt(h, 16); if (!Number.isFinite(n)) return null; const mix = (c) => Math.round(255 + (c - 255) * strength); return (mix((n >> 16) & 255) << 16) | (mix((n >> 8) & 255) << 8) | mix(n & 255); } /** * 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. * Each settlement gets a stable `id` (`-s`, n = its position * in this order) — the reputation/factions key. */ function generateSettlements({ rng, systemId, 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({ id: `${systemId}-s${out.length + 1}`, // reputation/factions key (stable per seed) 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)))); }