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 class weights, moon/belt chances, habitability, hazard, * and — the lived-in layer — `settlements` (per-type odds for the * free-space kinds). The system's OBJECT COUNT is a global rule * (data/systems.json → `objectCount`): the starting system is exempt * (fixed two planets beside the home world + at most one station); * every other system rolls its TOTAL object count — planets + * free-space stations together — from the table (default: 10% barren — * a jump-gate-only system — then 2/3/4/5 objects at 15/30/30/15%). * Stations roll first (per-type odds × the core→rim gradient), planets * fill the rest of the budget. 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. * 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 * BARREN systems (objectCount → 0) are the deliberate exception: star and * jump gates, nothing else — dead-end stops on the network (strong * connectivity keeps them reachable and escapable), where the player's * room to move is the activated gate's own level-1 tether (data/gates.json * → ACTIVITY: every gate carries `active`, default false; the activation * mechanic is future work). 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. And it is capped at ONE free-space * station: five objects (the home world + four) cannot sit 6400..10240 px * apart — the tightest 5-point spacing needs a max/min ratio ≥ φ ≈ 1.618, * which the home band 10240/6400 = 1.6 cannot give — so the home system * stays a ≤ 4-object configuration (see layoutSystem). * * LAYOUT (data/planets.json → solarSystem) — the SOLAR SYSTEM BAND: every * PAIR of layout objects (planets, free-space stations, and the central * body — the star, or the home world in the starting system — at the * local origin) sits 6400..15360 px apart, center to center; in the home * system the band tightens to 6400..10240 px. Normal systems lay out as a * regular N-gon ring around the star; the home system as a regular * (N+1)-polygon with the home world as one vertex. The rotation is chosen * to serve the jump gates — objects bias toward the directions the system * jumps (see layoutSystem + layoutGates). * * JUMP GATES (data/gates.json; the network in js/galaxy/JumpNetwork.js): * 1–3 gates per system — each placed on the side of the system facing its * destination star on the 2-D map (an upper-right gate jumps to a star in * the upper right). An ANCHORED system (a planet, a free-space station, * or the home world) hosts its gates within level-1 tether (5120 px) of * an anchor; a BARREN system (no anchors) hosts its gate(s) on the ray * toward the destination, `barrenDistance` from the star. Every gate * record carries `active` (default false — the activation mechanic is * future work; an activated gate anchors a level-1 tether). The * galaxy-wide network is strongly connected: no closed systems, no * trapped sets, the whole galaxy is reachable. * * FRAME DIVERSITY (js/galaxy/PlanetFrames.js): each planet's spritesheet * frame is assigned by a galaxy-wide pass — the system's (class, frame) * avoids what the NEAREST stars already wear for that class — so the same * face (e.g. terran frame 0) is spread across the galaxy instead of * clustering in one region. The pass runs once at galaxy build (fixed * roster order ⇒ visit-order independent) and stamps `planet.frame` / * `content.homeFrame` here; the renderer prefers those over a random pick. * * 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), }; } // --- Composition: the system's object budget -------------------------- // data/systems.json → objectCount. The STARTING system is exempt: it // always holds exactly TWO generated planets — a gas giant and a rocky // world — beside the home world (the origin, the player's homestead, // not a generated planet), plus at most one free-space station. // Every other system rolls its TOTAL object count N — planets + // free-space stations together — from the configured table (default: // 10% barren — a jump-gate-only stop — then 2/3/4/5 objects at // 15/30/30/15%). Stations roll next (per-type odds × the core→rim // density gradient, ≤ 2), the planets fill the rest: N − stations. // The roll lives on dedicated forks (rollSystemComposition), so the // galaxy-wide frame pass can reproduce it exactly. const isHome = record.id === galaxy?.homeSystemId; const spec = attr.settlements ?? {}; const composition = rollSystemComposition( galaxy.seed, record, isHome, spec, settlementDensity(galaxy, record), attr, ); const classes = composition.classes; const planetN = classes.length; // --- Planets ---------------------------------------------------------- // 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 homeName = isHome ? planetDeck[0] : null; const planets = []; for (let i = 1; i <= planetN; i++) { const pclass = classes[i - 1]; 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), }); } // FRAME DIVERSITY (js/galaxy/PlanetFrames.js — the galaxy-wide // (class, frame) pass): stamp the assigned sheet frame on each world so // the same class+face is spread across the galaxy (the renderer // prefers planet.frame over a random pick). const frames = galaxy?.planetFrames?.get(record.id); if (Array.isArray(frames)) { for (let i = 0; i < planets.length; i++) planets[i].frame = frames[i]; } // --- Settlements (the lived-in layer) --------------------------------- const settlements = generateSettlements({ rng, systemId: record.id, kindDefs: config.get('settlements.kinds', {}), spec, planets, stationDeck: NameGenerator.stationDeck( Rng.derive(galaxy.seed, 'system', record.id, 'names', 'stations') ), density: settlementDensity(galaxy, record), stations: { deepSpace: composition.deepSpace, waypoint: composition.waypoint }, isHome, }); // --- Jump gate targets (the galaxy's gate network) -------------------- // The other stars this system's gates jump to (Galaxy.jumpNetwork — // data/gates.json, js/galaxy/JumpNetwork.js): 1–maxGates, local (the // system's nearest-star pool), strongly connected. Ordered with the // "road home" (parent) edge first. Empty for a one-system galaxy. const targets = typeof galaxy?.jumpGatesFor === 'function' ? galaxy.jumpGatesFor(record.id) : []; // Bearings in the 2-D map plane — the same frame the system view uses, // so "an upper-right gate" means "a star to the upper right on the map". const targetAngles = targets.length > 0 ? targets.map((t) => Math.atan2(t.y - record.y, t.x - record.x)) : null; // --- Layout: the system's objects + its jump gates -------------------- // Planets and free-space stations are the system's layout objects — each // gets an x/y (see layoutSystem for the band rule), and the gates are // placed toward their target stars (see layoutGates). const freeSpace = settlements.filter((s) => s.anchor?.type === 'space'); layoutSystem(galaxy.seed, record.id, planets, freeSpace, isHome, targetAngles); const jumps = layoutGates(galaxy.seed, record, planets, freeSpace, isHome, targets); // --- Asteroid clusters ------------------------------------------------ // Loose groups of slowly tumbling rocks scattered through the void. // Generated AFTER the layout (so every placed object — worlds, stations, // and the jump gates — 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, jumps); // --- 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, jumps, // the system's jump gates (1–3; [] for a one-system galaxy) }; if (isHome) { content.homeName = homeName; // the player's home world (starting system only) // The home world's sheet frame (the galaxy-wide frame pass stamped it // on the galaxy — js/galaxy/PlanetFrames.js). if (typeof galaxy?.homeWorldFrame === 'number') content.homeFrame = galaxy.homeWorldFrame; } return content; } /** * Top-down layout of a system's objects — its planets and its free-space * stations — under the SOLAR SYSTEM BAND (data/planets.json → * solarSystem): * * Every PAIR of layout objects — the planets, the free-space stations, * and the central body (the star, or the HOME world in the starting * system — which sits at the local origin) — is at least `minSpacing` * and at most the band's maximum apart, center to center: * * normal systems : minSpacing..maxSpacing (6400..15360 px) * home system : minSpacing..homeMaxSpacing (6400..10240 px) * * (The old "every object within maxNeighbor of some object" rule is * implied: ≤ 6 objects inside a 15360 px band keeps every object within * level-2/3 tether of the others.) * * Shapes that satisfy a full pairwise band exactly: * N = 1, non-home — a single point at distance R ∈ [min, max]; its only * pair (with the star) is just R. * N ≥ 2, non-home — a REGULAR N-GON RING around the star: every pair is * a chord, the longest being 2·R·sin(⌊N/2⌋·π/N), so * R ∈ [min, max / (2·sin(⌊N/2⌋·π/N))]. * home system — a REGULAR (N+1)-POLYGON with the home world as ONE * VERTEX (the origin): every pair is a polygon chord, * the longest ratio(N+1)·side, so side ∈ [min, * homeMax / ratio(N+1)]. (The home world + 4 objects — * 5 points — would need a max/min ratio ≥ φ ≈ 1.618, * which the home band 10240/6400 = 1.6 cannot give; * that is why the home system is capped at 3 objects: * its 2 fixed planets + at most one free-space * station — see generateSettlements.) * * 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. The central body is the * origin (the game renders the home world there in the starting system; * elsewhere it is the star — the visual, but part of the spacing rule). * * The ROTATION (ring phase / polygon orientation) is chosen to serve the * jump gates: it minimizes the worst perpendicular distance between a * gate's target ray and the nearest object, so layoutGates can sit each * gate on the true target ray while staying tether-reachable. Without * targets (gates disabled, one-system galaxy) it falls back to a seeded * random rotation. * * Each planet also gains scale (size multiplier, data/planets.json → * classScale — the band is center-to-center, but the rendered discs still * scale by class). * * Determinism: the radius/side draw comes from the dedicated fork * (seed, 'system', id, 'layout'); the rotation is a pure function of * (shape, target angles). Same seed ⇒ same layout, and lazy * (on-arrival) === eager (generateAll). */ function layoutSystem(seed, systemId, planets, freeSpace, isHome, targetAngles) { const band = config.get('planets.solarSystem', {}); if (band.enabled === false) return; const MIN = Math.max(1, band.minSpacing ?? 6400); const MAX = Math.max(MIN, isHome ? (band.homeMaxSpacing ?? 10240) : (band.maxSpacing ?? 15360)); // Rendered size per class (visual only — the band is center-to-center). 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 — stable per system, so lazy === eager. const objects = [...planets, ...freeSpace]; const N = objects.length; if (N === 0) return; const lay = Rng.derive(seed, 'system', systemId, 'layout'); // `place(phi)` → the N object positions [{x, y}] for rotation `phi`. let place; if (isHome) { // Regular (N+1)-gon, the home world (origin) as vertex 0: vertex k is // Rc·(u(φ + 2πk/m) − u(φ)) — a chord of length 2·Rc·sin(πk/m). const m = N + 1; const sideMax = MAX / ratioOf(m); if (sideMax < MIN) { console.warn( `[orbit] ${systemId}: ${N} objects cannot fit the home band [${MIN}, ${MAX}] px — using the minimum spacing`, ); } const side = lay.range(MIN, Math.max(MIN, sideMax)); const Rc = side / (2 * Math.sin(Math.PI / m)); place = (phi) => { const out = []; for (let k = 1; k < m; k++) { out.push({ x: Rc * (Math.cos(phi + (TAU * k) / m) - Math.cos(phi)), y: Rc * (Math.sin(phi + (TAU * k) / m) - Math.sin(phi)), }); } return out; }; } else { // Regular N-gon ring around the star (origin). N = 1: one point at // distance R — its only pair (with the star) is just R. const rMax = N === 1 ? MAX : MAX / (2 * Math.sin((Math.floor(N / 2) * Math.PI) / N)); const R = lay.range(MIN, Math.max(MIN, rMax)); place = (phi) => { const out = []; for (let k = 0; k < N; k++) { const a = phi + (TAU * k) / N; out.push({ x: R * Math.cos(a), y: R * Math.sin(a) }); } return out; }; } const phi = targetAngles ? bestRotation(place, targetAngles) : lay.range(0, TAU); const slots = place(phi); for (let i = 0; i < N; i++) { objects[i].x = slots[i].x; objects[i].y = slots[i].y; } } /** Max-chord / side ratio of a regular `m`-gon (m ≥ 3); 1 for m < 3. */ function ratioOf(m) { if (m < 3) return 1; const half = Math.floor(m / 2); return Math.sin((half * Math.PI) / m) / Math.sin(Math.PI / m); } /** * The rotation that best serves the jump gates: minimize the WORST * perpendicular distance between a gate's target ray and the NEAREST * object (the gate then sits on the ray whenever that distance is within * the anchor tether range — layoutGates). Coarse scan + local refine; the * first minimum on the deterministic grid wins, so the result is exact * for the seed (no Math.random anywhere). */ function bestRotation(place, targetAngles) { const score = (phi) => { const pts = place(phi); let worst = 0; for (const t of targetAngles) { const st = Math.sin(t); const ct = Math.cos(t); let best = Infinity; for (const p of pts) { const h = Math.abs(p.x * st - p.y * ct); // perpendicular distance to the ray line if (h < best) best = h; } if (best > worst) worst = best; } return worst; }; const STEPS = 4096; const span = TAU / STEPS; let bi = 0; let bv = Infinity; for (let i = 0; i < STEPS; i++) { const v = score(i * span); if (v < bv) { bv = v; bi = i; } } let bestPhi = bi * span; for (let j = -16; j <= 16; j++) { const i = bi + j; if (i < 0 || i >= STEPS) continue; const phi = i * span; const v = score(phi); if (v < bv) { bv = v; bestPhi = phi; } } return bestPhi; } /** * JUMP GATES — the physical gates of the system, one per gate-network * target (Galaxy.jumpNetwork; data/gates.json). Each gate: * * - sits on the system's side of its DESTINATION star — the bearing is * computed in the 2-D map plane, so "an upper-right gate" means "a * star to the upper right on the map"; * - ANCHORED systems (a planet, a free-space station, or the home world): * within level-`anchorTetherLevel` tether (5120 px for level 1) of an * anchor — on the anchor's tether circle, chosen in order of facing * quality: (1) the far ray-circle intersection (the gate exactly on * the target ray — system, gate, and star collinear), (2) the point of * the circle aimed exactly at the target star, (3) a forward-hemisphere * scan of the circle (±75°). Every candidate is exactly `range` from * its anchor, so tether-reachability holds by construction and the * facing deviation never exceeds 90° (in practice a few degrees); * - BARREN systems (no anchors — objectCount → 0): on the ray toward the * destination, `barrenDistance` from the star (stepped outward within * the radius band only if the gate gap forces it). The activation * mechanic (future work) then turns the gate itself into the system's * level-1 tether anchor — see data/gates.json → ACTIVITY; * - stays `minRadius..maxRadius` from the star, `size` + `clearance` * clear of every anchor disc, and 2·`size` + `gateGap` from every * other gate. * * The target list arrives ordered (the "road home" parent edge first) and * anchors iterate in content order, so the placement is exact for the * seed — same seed ⇒ same gates (dev/jumps.test.mjs). * * Record shape (one per gate): * { id: `-j`, name: ` Gate`, to, toName, * x, y, size, rotation, active: false } * `active` defaults to false — the gate is inert until the player * activates it (data/gates.json → ACTIVITY); an activated gate anchors a * level-1 tether at its own position. */ function layoutGates(seed, record, planets, freeSpace, isHome, targets) { const g = config.section('gates', {}); if (g.enabled === false) return []; if (!Array.isArray(targets) || targets.length === 0) return []; const size = Math.max(1, Math.floor(g.size ?? 96)); const clearance = Math.max(0, g.clearance ?? 256); const gap = Math.max(0, g.gateGap ?? 192); const minR = Math.max(0, g.minRadius ?? 2048); const maxR = Math.max(minR, g.maxRadius ?? 20480); const range = anchorTetherRange(g.anchorTetherLevel ?? 1); // Anchors: the planets, the free-space stations, and (home only) the // home world at the origin — the bodies a gate's tether may hang from. // A BARREN system holds none — its gates use the on-ray rule below. const anchors = [ ...planets.map((p) => ({ x: p.x, y: p.y, r: planetRenderRadius(p) })), ...freeSpace.map((f) => ({ x: f.x, y: f.y, r: stationKeepout(f.kind) })), ]; if (isHome) anchors.push({ x: 0, y: 0, r: homeWorldRadius() }); const jumps = []; const gateNames = new Map(); // star name → how many gates named after it for (let i = 0; i < targets.length; i++) { const t = targets[i]; const th = Math.atan2(t.y - record.y, t.x - record.x); const ux = Math.cos(th); const uy = Math.sin(th); let chosen = null; if (anchors.length === 0) { // BARREN SYSTEM (objectCount → 0) — no anchor to tether to: the gate // sits ON the ray toward its destination, `barrenDistance` from the // star (data/gates.json), stepped outward in 1024 px steps within the // radius band if the gate gap forces it (two close targets). When the // player activates it (future mechanic), the gate itself becomes the // system's level-1 tether anchor (data/gates.json → ACTIVITY). const base = Math.max(minR, Math.min(maxR, Math.max(1, g.barrenDistance ?? 8192))); const okB = (px, py) => { const d2c = px * px + py * py; if (d2c < minR * minR || d2c > maxR * maxR) return false; for (const j of jumps) { const need = 2 * size + gap; const dx = px - j.x; const dy = py - j.y; if (dx * dx + dy * dy < need * need) return false; } return true; }; for (let D = base; D <= maxR + 1e-6 && !chosen; D += 1024) { if (okB(D * ux, D * uy)) chosen = { px: D * ux, py: D * uy }; } for (const eps of [0.05, -0.05, 0.1, -0.1, 0.2, -0.2]) { // Last resort — a 1024 px step over the band should always clear a // 384 px gap; angle-nudge if not (the facing rule is soft). if (chosen) break; const a = th + eps; if (okB(base * Math.cos(a), base * Math.sin(a))) { chosen = { px: base * Math.cos(a), py: base * Math.sin(a) }; } } if (!chosen) { chosen = { px: base * ux, py: base * uy }; console.warn(`[orbit] ${record.id}: gate ${i + 1} could not clear the gate gap (barren)`); } } else { // Candidate points, best first: // tier 1 — for every anchor, the FAR ray-circle intersection: the // gate exactly ON the target ray (system center, gate, and // star collinear), outside the anchor. Always faces the // target (≤ 90° from the anchor's point of view). // tier 2 — for every anchor, the point on the anchor's tether circle // that faces the target: a + range·u — exactly `range` from // the anchor (tether-reachable) and aimed exactly at the // star (zero deviation from the anchor's point of view). // tier 3 — a forward-hemisphere scan around each anchor's circle // (32 points, ± up to 75° from the target direction) — the // clearance search for tight systems. // Every candidate is exactly `range` from an anchor, so the level-N // tether rule (hard) is met by construction; the direction rule (soft) // is honored by the tier order: on-ray → aimed → near-aimed. const cands = []; anchors.forEach((a, ai) => { const proj = a.x * ux + a.y * uy; // signed distance along the ray const h = Math.abs(a.x * uy - a.y * ux); // perpendicular distance if (h <= range) { const off = Math.sqrt(Math.max(0, range * range - h * h)); cands.push({ tier: 1, order: h * 1e6 + ai * 1000, px: (proj + off) * ux, py: (proj + off) * uy }); } cands.push({ tier: 2, order: h * 1e6 + ai * 1000, px: a.x + range * ux, py: a.y + range * uy }); for (let k = 0; k < 32; k++) { const phi = -1.3089 + (2.6179 * k) / 31; // ±75° around the target direction const dx = ux * Math.cos(phi) - uy * Math.sin(phi); const dy = ux * Math.sin(phi) + uy * Math.cos(phi); cands.push({ tier: 3, order: Math.abs(phi) * 1e6 + h + ai * 1e-3, px: a.x + range * dx, py: a.y + range * dy }); } }); cands.sort((p, q) => p.tier - q.tier || p.order - q.order); const ok = (c) => { const d2c = c.px * c.px + c.py * c.py; if (d2c < minR * minR || d2c > maxR * maxR) return false; for (const b of anchors) { const need = b.r + size + clearance; const dx = c.px - b.x; const dy = c.py - b.y; if (dx * dx + dy * dy < need * need) return false; } for (const j of jumps) { const need = 2 * size + gap; const dx = c.px - j.x; const dy = c.py - j.y; if (dx * dx + dy * dy < need * need) return false; } return true; }; for (const c of cands) { if (ok(c)) { chosen = c; break; } } if (!chosen) { // Every candidate failed clearance (nearly impossible — an anchor's // tether circle is 5120 px across, the discs under a thousand): take // the first anchor's aimed point anyway — the tether and facing rules // outrank cosmetics. chosen = cands.find((c) => c.tier === 2) ?? cands[0]; console.warn( `[orbit] ${record.id}: gate ${i + 1} fell back to its first aimed candidate (clearance)`, ); } } // Unique gate name (two targets can share a star name — star names are // syllable-generated): "Avidy Gate", "Avidy Gate II", "Avidy Gate III". let name = `${t.name} Gate`; const k = (gateNames.get(name) ?? 0) + 1; gateNames.set(name, k); if (k > 1) name = `${name} ${k === 2 ? 'II' : 'III'}`; jumps.push({ id: `${record.id}-j${i + 1}`, name, to: t.id, toName: t.name, x: chosen.px, y: chosen.py, size, // Inert until the player activates it (data/gates.json → ACTIVITY): // an activated gate anchors a level-1 tether at its own position — // the room to move in a barren system. The activation mechanic is // future work; the renderer dims inactive gates. active: false, // The gate's visual bearing — from the gate's own position to the // destination star, so it always points exactly at where it jumps. rotation: Math.atan2(t.y - chosen.py, t.x - chosen.px), }); } return jumps; } /** Level-N tether radius (data/tether.json): level1Radius × growth^(N−1). */ function anchorTetherRange(level) { const lv = Math.max(1, Math.floor(level ?? 1)); const base = Math.max(1, config.get('tether.level1Radius', 5120)); const growth = Math.max(1, config.get('tether.radiusGrowth', 2.0)); return base * Math.pow(growth, lv - 1); } /** A planet's rendered disc radius (the anchor keepout for gate clearance). */ function planetRenderRadius(p) { const frame = Math.max(1, Math.floor(config.get('planets.frameWidth', 1024))); const scale = Math.max(0.01, config.get('planets.scale', 1.0)); const classScale = Math.max(0.01, p.scale ?? config.get(`planets.classScale.${p.class}`, 1)); return (frame * scale * classScale) / 2; } /** A free-space station's keepout radius (data/stations.json). */ function stationKeepout(kind) { return Math.max(1, Math.floor(config.get(`stations.kinds.${kind}.size`, 108))); } /** The home world's disc radius (data/planets.json). */ function homeWorldRadius() { const frame = Math.max(1, Math.floor(config.get('planets.frameWidth', 1024))); const scale = Math.max(0.01, config.get('planets.scale', 1.0)); return (frame * scale) / 2; } /** * 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, jumps = []) { const cfg = config.section('asteroids', {}); if (cfg.enabled === false) return []; // A BARREN system (objectCount → 0) is a jump-gate-only stop — star and // gates, nothing else — so no clusters. if (planets.length === 0 && freeSpace.length === 0) 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?.homeSystemId; // --- 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 // (planets, free-space stations, and the jump gates). 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 }); for (const j of jumps) if (typeof j.x === 'number' && typeof j.y === 'number') placed.push({ x: j.x, y: j.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', 2.0); 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); } /** * The system's OBJECT COMPOSITION (data/systems.json → objectCount + * attributes.settlements + the core→rim density gradient — * data/galaxy.json → settlements.gradient), in one deterministic roll: * * home system → fixed: 2 planets (gas giant + rocky) beside the home * world, + at most one free-space station (deepSpace only — * a waypoint would be a 5th point the home band can't hold); * every other → 1) the FINAL object count N from the configured table * (0 = barren, then 2/3/4/5 at the configured weights — the * distribution is on the FINAL count, by design); * 2) the free-space stations (0–2, per-type odds × the * density gradient; at most 2, which never exceeds the * minimum non-barren budget of 2); * 3) the planets — N − stations weighted class draws * (a 2-object system can be 0 planets + 2 stations). * * Both the content generator and the galaxy-wide frame pass * (js/galaxy/PlanetFrames.js) call this — the forks * (seed, 'system', id, 'planets' / 'settlements') are pure functions of * their inputs, so they always agree. * → { objects, deepSpace, waypoint, classes } */ export function rollSystemComposition(seed, record, isHome, spec, density, attr) { if (isHome) { const rng = Rng.derive(seed, 'system', record.id, 'settlements'); const deepSpace = rng.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density); return { objects: 2, deepSpace, waypoint: false, classes: ['gas', 'rocky'] }; } // 1) The final object count — the configured composition table. const oc = config.get('systems.objectCount', { barren: 0.1, objects: { 2: 0.15, 3: 0.3, 4: 0.3, 5: 0.15 }, }); const table = { 0: Math.max(0, Number(oc.barren) || 0) }; for (const [k, w] of Object.entries(oc.objects ?? {})) { const n = Number(k); if (Number.isInteger(n) && n > 0) table[n] = Math.max(0, Number(w) || 0); } const rngP = Rng.derive(seed, 'system', record.id, 'planets'); const objects = Math.max(0, Number(rngP.weighted(table, 2))); // BARREN (N = 0): a jump-gate-only system — no stations, no planets. if (objects === 0) { return { objects, deepSpace: false, waypoint: false, classes: [] }; } // 2) The free-space stations (≤ 2 — never more than the min budget of 2). const rngS = Rng.derive(seed, 'system', record.id, 'settlements'); const deepSpace = rngS.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density); const waypoint = rngS.chance((spec?.waypoint?.chance ?? 0.2) * density); const stations = Number(deepSpace) + Number(waypoint); // 3) The planets fill the rest of the budget (N − stations ≥ 0). const classWeights = attr?.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 }; const classes = Array.from({ length: objects - stations }, () => rngP.weighted(classWeights, 'rocky')); return { objects, deepSpace, waypoint, classes }; } /** * The system's PLANET CLASSES — a thin wrapper over rollSystemComposition * for callers that only need the worlds (the frame pass uses the full roll). */ export function rollPlanetClasses(seed, record, isHome, attr, spec, density) { return rollSystemComposition(seed, record, isHome, spec, density, attr).classes; } /** Backwards-compatible roll (tests/tools) — the stations of a system. */ export function rollStationCount(seed, record, isHome, spec, density) { const c = rollSystemComposition(seed, record, isHome, spec, density, {}); return { deepSpace: c.deepSpace, waypoint: c.waypoint, count: Number(c.deepSpace) + Number(c.waypoint) }; } /** * 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. * Exported: the frame pass (js/galaxy/PlanetFrames.js) re-derives the same * values from the same inputs. */ export 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 * 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, stations, isHome = false }) { 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. 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 }); } } 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 — the PRE-ROLLED flags (rollStationCount: // per-type odds × the core→rim density gradient; the home system never // rolls a waypoint — its band can't hold 5 points). A BARREN system // (objectCount → 0) rolled no stations — it is a jump-gate-only stop, // deliberately. if (stations?.deepSpace) make('deepSpaceStation', { type: 'space' }); if (stations?.waypoint) make('waypoint', { type: 'space' }); 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); } /** 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)))); }