orbit/js/galaxy/SystemGenerator.js

1458 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, d, zone) 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.<id>.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 home→far gradient), planets
* fill the rest of the budget. Settlement kinds and their population
* ranges live in data/settlements.json; the home→far 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: jump
* gates — and, in most, an asteroid cluster drifting inside the gate's
* tether to mine at the stop — and nothing else. They are the DEAD-END
* LEAVES of the jump network (a pure spanning tree, no loops — the maze's
* dead ends: one gate, in and out the same way), 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. It is capped at ONE free-space
* station by design — the home system stays a small start the player
* expands out of (the arc layout could seat more; the cap keeps the
* starting system readable — see layoutSystem + generateSettlements).
* Its TYPE is also
* fixed: always 'main' (Main Sequence — the player's home is an ordinary
* star; Galaxy.js forces it at roster build, data/galaxy.json →
* startingSystem.type), so its star class rolls from the main-sequence
* table (G/K/M).
*
* LAYOUT (data/planets.json → solarSystem) — built around TETHER
* DISCOVERY (data/tether.json): the layout objects (planets + free-space
* stations — the central body, the home world, sits at the local origin
* of the STARTING system only; every other system has an EMPTY center —
* the star is invisible flavor, generated as `star` but never rendered)
* sit on a COMPACT ARC at one orbital radius: consecutive objects a
* level-1 tether apart (≤ 5120 − edgeMargin — a level-1 tether from any
* one of them ALWAYS discovers a neighbor), every other pair clearly
* inside or outside level 2 — and NO object sits in the rim band around
* any tether boundary (± edgeMargin — the scan edge case). A chain of
* level-1 tethers discovers the whole system, so level 3 is never
* required (the old regular N-gon ring is gone — its chords put 2- and
* 3-object systems PAST level 2 and spread 4- and 5-object systems too
* thin). 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, or the home world in
* the starting system) hosts its gates 3500–4608 px from an anchor
* (data/gates.json → anchorMinDistance/anchorMaxDistance — the distance
* VARIES per gate, always CLEARLY inside the level-1 tether (≤ 5120 −
* edgeMargin — never at its rim) and strictly < 5000 px) — planets ONLY (free-space stations are
* deliberately not anchors: the build console lives on a world, so every
* gate must be tether-reachable from a planet the player can build out
* from). A BARREN
* system (no planets — a dead-end leaf of the tree) hosts its single gate
* on the ray toward the destination, `barrenDistance` from the center.
* 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 a pure spanning tree (no loops — the maze):
* from any system the player can reach any other, and every jump has a
* return gate.
*
* 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.
*
* STATION VARIANTS (js/galaxy/StationFrames.js): the same spreading for
* the deep-space stations — each system's free-space station wears one of
* the spacestations.png variants (data/stations.json → variants), chosen
* to avoid what the NEAREST stars already wear. The pass stamps
* `settlement.stationFrame` here (the landing handoff reads the same
* frame — data/landing.json → stationVideos); waypoints keep their beacon
* look and take no frame.
*
* 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 `<systemId>-p<ordinal>` (p1…pn, orbital order)
* settlements `<systemId>-s<n>` (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 (invisible flavor) ------------------------------------------
// The system's star — generated (name/class/mass/binary) as the
// dossier's identity ("Kepler Reach · star G") and the gate names, but
// never rendered: the central body exists only in the starting system
// (the home world). See data/galaxy.json.
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 home→far
// 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,
});
// STATION VARIANTS (js/galaxy/StationFrames.js — the galaxy-wide
// spread pass): stamp the assigned spacestations.png frame on this
// system's deep-space station, so the same station type is spread
// across the galaxy (the renderer prefers settlement.stationFrame over
// a fallback pick; the landing handoff reads the same frame).
const stationFrame = galaxy?.stationFrames?.get(record.id);
if (typeof stationFrame === 'number') {
for (const s of settlements) {
if (s.kind === 'deepSpaceStation') s.stationFrame = stationFrame;
}
}
// --- 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);
// --- Nebula atmosphere color ------------------------------------------
// Every system gets a stable atmosphere color — a random pick from the
// 8-color palette (data/game.json → nebula.palette). A dedicated fork
// (seed, 'system', id, 'atmosphere') keeps it independent of the
// star/planet/settlement/layout draws above, so lazy (on-arrival) ===
// eager (generateAll) is preserved. Only NEBULA systems RENDER it
// (GameScene + js/visuals/NebulaAtmosphere.js), but the color is stamped
// on every record — the palette is future-proof for other archetypes.
const atmosphereRng = Rng.derive(galaxy.seed, 'system', record.id, 'atmosphere');
const nebulaPalette = config.get('game.nebula.palette', []);
const atmosphereColor =
Array.isArray(nebulaPalette) && nebulaPalette.length > 0
? nebulaPalette[atmosphereRng.int(0, nebulaPalette.length - 1)]
: null;
// --- Per-system FX variation (js/visuals/SystemEffects.js) -------------
// A few stable 0..1 numbers the live effects (redDwarf flare, binary
// two-star orbit / split axis / companion star) use for their per-system
// variation: phase (flare rhythm / orbit start), angle (split axis /
// wanderer heading), drift (wanderer speed). A dedicated fork
// (seed, 'system', id, 'fx') keeps it independent of the draws above,
// so lazy === eager. Stamped on every record (cheap) — only the live
// effect families read it.
const fxRng = Rng.derive(galaxy.seed, 'system', record.id, 'fx');
const fx = {
phase: fxRng.next(),
angle: fxRng.next(),
drift: fxRng.next(),
};
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)
atmosphere: { color: atmosphereColor }, // nebula gas color (rendered on nebula systems)
fx, // per-system effect variation (flare phase / orbit / split / wanderer)
};
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 (the "layout objects"; planet-bound settlements are features of
* their planet and are NOT layout objects). The central body — the home
* world at the local origin in the starting system, an invisible star
* (never rendered) elsewhere — is part of the rule but never rendered as
* a planet.
*
* DISCOVERY IS TETHER-BASED (data/tether.json): level 1 = 5120 px, level
* 2 = 10240 px, level 3 = 20480 px from an anchor. The layout is built
* around the design rules:
*
* 1. A LEVEL-1 tether from any planet or station discovers a neighbor —
* in fact ALWAYS: CONSECUTIVE layout objects sit a level-1 distance
* apart (≤ 5120 − edgeMargin), so a chain of level-1 tethers walks
* the whole arc. (The player can always build on a discovered world
* or station — every one is an anchor in its own right.)
* 2. A LEVEL-2 tether DEFINITELY discovers an object while undiscovered
* objects remain: the level-2 adjacency graph over the objects (and
* the home world, starting system) is CONNECTED — it contains the
* level-1 chain, so it holds by construction.
* 3. No LEVEL-3 tether is ever REQUIRED to find everything — the
* level-1 chain above makes level 3 a reachability luxury, not a
* discovery need. Far pairs MAY sit beyond level 2 — that is the
* point of "not everything within two tether regions".
*
* EDGE MARGIN (data/tether.json → edgeMargin, 512 px): NO anchorable
* object pair (planets, stations, home world) sits in the no-man's-land
* band around a tether rim — (L1 − margin, L1 + margin) or (L2 − margin,
* L2 + margin). Objects are clearly inside or clearly outside each tether
* region, never at the edge where the scan misses them. The invisible
* star is NOT a tether anchor, so a normal system's star–planet distance
* only needs the plain orbital band.
*
* THE SHAPE: the N objects sit on ONE orbit (radius r from the center) as
* a compact arc — consecutive objects a level-1 gap apart (chord
* 2·r·sin α, α the half-step) — replacing the old regular N-gon ring,
* which put 2- and 3-object systems past level 2 (chords
* 2·r·sin(π(N−1)/N)) and spread 4- and 5-object systems too thin. The
* step angle α is chosen deterministically (a seeded scan over the SAFE α
* — those where EVERY pair k apart, chord 2·r·|sin kα|, is level-1 for
* k = 1 and clearly outside/inside level 2 for k ≥ 2) to land the
* consecutive gap near a seeded target in [neighborMin, L1 − margin] —
* variety across systems, every rule hard by construction.
*
* Bands (data/planets.json → solarSystem):
* orbital radius r (center-to-object):
* normal systems : minSpacing..maxSpacing (6400..15360 px)
* home system : minSpacing..homeMaxSpacing, additionally ≤
* level-2 − edgeMargin (9728 px — the home world is
* an anchor, so its pairs respect the bands)
* consecutive gap : neighborMin..(level-1 − edgeMargin) (2048..4608 px)
*
* 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).
*
* The ROTATION (arc phase) 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 distances are center-to-center, but the rendered discs
* still scale by class).
*
* Determinism: the radius/gap draws come 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 distances are 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');
const B = tetherBands();
// Orbital radius (every object shares the one orbit). The home world is
// a TETHER ANCHOR, so its distances to the objects respect the bands —
// the ceiling falls at level-2 − edgeMargin (9728 px, clearly inside
// level 2, off the rim); the star of a normal system is not an anchor,
// so its distances only need the plain band.
const Rmax = isHome ? Math.min(MAX, B.L2in) : MAX;
if (Rmax < MIN) {
console.warn(
`[orbit] ${systemId}: the orbital radius band [${MIN}, ${Math.round(Rmax)}] px is empty — using the minimum spacing`,
);
}
const r = lay.range(MIN, Math.max(MIN, Rmax));
// N = 1: a single point at distance r — its only anchorable pair is
// with the central body (home world: r ≤ L2in, and 6400 > L1out —
// clearly inside level 2 and clearly outside level 1 ✓).
if (N === 1) {
const single = (phi) => [{ x: r * Math.cos(phi), y: r * Math.sin(phi) }];
const phi = targetAngles ? bestRotation(single, targetAngles) : lay.range(0, TAU);
objects[0].x = single(phi)[0].x;
objects[0].y = single(phi)[0].y;
return;
}
// N ≥ 2: a COMPACT ARC of consecutive objects. The consecutive gap is
// level-1 (clearly inside — discoverable) and disc-clear; every other
// pair sits off both tether rims. A chain of level-1 tethers discovers
// the whole system; level 2 is the guaranteed fallback, level 3 never
// required (see the docstring).
const nMin = Math.max(2, band.neighborMin ?? 2048);
const nMax = Math.max(nMin, B.L1in);
const dTarget = lay.range(nMin, nMax); // desired consecutive gap (seeded variety)
const alpha = chooseHalfStep(N, r, nMin, dTarget, B);
const step = 2 * alpha; // step angle between consecutive objects
// `place(phi)` → the N object positions [{x, y}] for rotation `phi`.
const place = (phi) => {
const out = [];
for (let k = 0; k < N; k++) {
const a = phi + k * step;
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;
}
}
/**
* The tether discovery bands (data/tether.json): the level radii the
* player actually uses + the EDGE MARGIN around each rim — the
* no-man's-land bands where no object sits. L1in/L1out = the level-1
* boundary ± edgeMargin (clearly inside / clearly outside level 1);
* likewise L2in/L2out for level 2.
*/
function tetherBands() {
const L1 = Math.max(1, config.get('tether.level1Radius', 5120));
const growth = Math.max(1, config.get('tether.radiusGrowth', 2.0));
const M = Math.max(0, config.get('tether.edgeMargin', 512));
const L2 = L1 * growth;
return {
L1, L2, M,
L1in: L1 - M,
L1out: L1 + M,
L2in: L2 - M,
L2out: L2 + M,
};
}
/**
* The safe half-step angle α for the N-object arc at orbit radius r: with
* step angle 2α, objects k apart sit 2·r·|sin kα| apart (|sin| also covers
* the wrap past a half-circle). α is SAFE when:
* - k = 1: nMin ≤ d ≤ L1in — the consecutive gap is clearly inside
* level 1 (discoverable) and clear of the rendered discs;
* - k ≥ 2: d NOT in (L1in, L1out) nor in (L2in, L2out) — every other
* pair sits clearly inside or clearly outside each tether region,
* never at a rim (the scan edge case).
* A deterministic scan (coarse + local refine, like bestRotation) picks
* the safe α whose consecutive gap is nearest the seeded target — variety
* across systems, every rule hard by construction. If no α passes
* (degenerate config / N too large for this radius), fall back to the
* level-1 cap and warn.
*/
function chooseHalfStep(N, r, nMin, dTarget, B) {
const aMax = Math.asin(Math.min(1, B.L1in / (2 * r)));
const aMin = Math.asin(Math.min(1, Math.min(nMin, B.L1in) / (2 * r)));
const safe = (a) => {
for (let k = 1; k < N; k++) {
const d = 2 * r * Math.abs(Math.sin(k * a));
if (k === 1) {
if (d < nMin - 1e-9 || d > B.L1in + 1e-9) return false;
} else if (
(d > B.L1in + 1e-9 && d < B.L1out - 1e-9) ||
(d > B.L2in + 1e-9 && d < B.L2out - 1e-9)
) {
return false;
}
}
return true;
};
const gap = (a) => 2 * r * Math.abs(Math.sin(a));
const score = (a) => (safe(a) ? Math.abs(gap(a) - dTarget) : Infinity);
let best = null; // [α, score]
const COARSE = 2048;
for (let s = 0; s < COARSE; s++) {
const a = aMin + ((aMax - aMin) * s) / (COARSE - 1);
const sc = score(a);
if (best === null || sc < best[1] - 1e-12) best = [a, sc];
}
if (best !== null && best[1] !== Infinity) {
// Local refine around the coarse winner (the safe set is a union of
// intervals — this pins the gap to well under a pixel).
const w = (2 * (aMax - aMin)) / (COARSE - 1);
for (let s = 0; s < 256; s++) {
const a = best[0] - w + ((2 * w) * s) / 255;
if (a < aMin - 1e-12 || a > aMax + 1e-12) continue;
const sc = score(a);
if (sc < best[1] - 1e-12) best = [a, sc];
}
return best[0];
}
// Degenerate (N too large for this orbit radius, or a config conflict):
// the level-1 cap keeps the consecutive gap safe; a far pair may end up
// in a rim band — flag it for inspection rather than fail.
console.warn(
`[orbit] arc layout: no safe half-step for ${N} objects at r=${r} px — ` +
'using the level-1 cap (a far pair may sit in a tether band)',
);
return aMax;
}
/**
* 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 — or the home world in the starting
* system): the gate sits BETWEEN `anchorMinDistance` and
* `anchorMaxDistance` from an anchor (default 3500–4608 px — the
* distance VARIES per gate, always CLEARLY inside the level-1 tether
* (≤ 5120 − edgeMargin — never at its rim) and strictly < 5000 px) —
* on one of the radius STEPS spanning that
* band around the anchor, chosen in order of facing quality: (1) the
* far ray-circle intersection (the gate exactly on the target ray —
* system center, gate, and destination 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 sits
* EXACTLY on one of the step circles, so BOTH the tether rule and the
* < 5000 px rule hold by construction, and the facing deviation never
* exceeds 90° (in practice a few degrees). The per-gate PREFERRED
* step (a dedicated seeded fork) orders the steps — clearance/gap
* pressure only ever pushes the gate outward from its preference. Candidates on the
* DESTINATION SIDE of the system center sort first — before every
* tier — so the gate faces its destination FROM THE CENTER (the rule
* the map reads) and is never placed on the far side; an on-ray
* intersection behind the center loses to any destination-side
* aimed/scan point. After the side, RIM-BAND VIOLATIONS order the
* candidates: a gate never sits in the no-man's-land band around
* another object's tether rim (± edgeMargin — the scan edge case) when
* a clean point exists.
* Anchors are PLANETS
* only (plus the home world at home) — free-space stations are
* deliberately excluded: the build console lives on a world, so every
* gate must be tether-reachable from a planet the player can build
* out from (reach the anchor → build the next tether level → expand).
* (Every non-barren system holds at least one planet — the
* composition roll demotes a station if the budget ran out — so an
* anchor always exists.)
* - BARREN systems (no planets — objectCount → 0, the network's
* dead-end leaves): on the ray toward the destination,
* `barrenDistance` from the center (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 center, `size` + `clearance`
* clear of every anchor disc (planets + free-space stations), 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: `<systemId>-j<n>`, name: `<Star> 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);
// The anchor circles: the gate's distance from its anchor VARIES
// across `anchorMinDistance`..`anchorMaxDistance` (data/gates.json —
// default 3500–4608 px: the design rule "strictly < 5000 px from a
// planet or space station" (Rule 1), with the EDGE MARGIN — the gate
// never sits at the rim of the anchor's level-1 tether
// (data/tether.json → edgeMargin)), CAPPED by the anchor's
// level-`anchorTetherLevel` tether range (the gate must stay inside
// it). Every candidate below sits exactly on one of these circles, so
// both rules hold by construction.
const tetherRange = anchorTetherRange(g.anchorTetherLevel ?? 1);
const rangeMax = Math.min(tetherRange, Math.max(1, g.anchorMaxDistance ?? 4608));
const rangeMin = Math.min(rangeMax, Math.max(1, g.anchorMinDistance ?? 3500));
// Anchors: the PLANETS and (home only) the home world at the origin —
// the bodies a gate's tether may hang from. Free-space stations are
// deliberately NOT anchors (the build console lives on a world — the
// player must always be able to reach a planet and build out from the
// anchor); the composition roll guarantees a non-barren system holds
// at least one. A BARREN system holds none — its gate uses the on-ray
// rule below.
const anchors = planets.map((p) => ({ x: p.x, y: p.y, r: planetRenderRadius(p) }));
if (isHome) anchors.push({ x: 0, y: 0, r: homeWorldRadius() });
// Clearance discs: every solid body the gate must keep clear of — the
// anchors plus the free-space stations.
const discs = [
...anchors,
...freeSpace.map((f) => ({ x: f.x, y: f.y, r: stationKeepout(f.kind) })),
];
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
// center (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); its 1–2
// asteroid clusters drift inside that tether (data/asteroids.json →
// barren).
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 circle that
// faces the target: a + r·u — exactly `r` 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 sits EXACTLY `r` from its anchor, `r` one of the
// RADIUS STEPS spanning [rangeMin, rangeMax] (default 3500–4608 px —
// the gate's distance from its planet VARIES, always < 5000 and
// clearly inside the level-1 tether — off its rim). The per-gate
// PREFERRED step (a dedicated seeded fork — deterministic, lazy ===
// eager) orders the steps, and clearance/gap pressure pushes the
// gate outward from it (the radius is the LAST tie-break — facing
// quality wins over it). The facing rule (soft — the gate faces its
// destination FROM THE SYSTEM CENTER) is honored by the sort:
// destination-side candidates (side 0) come FIRST — before every
// tier — so a gate is never placed on the far side of the center
// (an on-ray intersection behind the center loses to any
// destination-side aimed/scan point); then the RIM-BAND count
// (a gate never sits in the no-man's-land band around another
// object's tether rim — the scan edge case — when a clean point
// exists); then on-ray → aimed → near-aimed → preferred radius.
const STEPS = 8;
const radii = Array.from(
{ length: STEPS },
(_, s) => rangeMin + ((rangeMax - rangeMin) * s) / (STEPS - 1),
);
const radPref = Rng.derive(seed, 'system', record.id, 'gates', i).int(0, STEPS - 1);
// RIM BANDS (data/tether.json → edgeMargin): the no-man's-land
// around each tether rim where no object sits. A gate's distance to
// its OWN anchor is on the step band (≤ L1in — clearly inside, by
// construction), so only the OTHER solids count.
const B = tetherBands();
const inRim = (d) =>
(d > B.L1in + 1e-9 && d < B.L1out - 1e-9) ||
(d > B.L2in + 1e-9 && d < B.L2out - 1e-9);
const bandCount = (px, py, ai) => {
let n = 0;
for (let j = 0; j < discs.length; j++) {
if (j === ai) continue;
const dx = px - discs[j].x;
const dy = py - discs[j].y;
if (inRim(Math.hypot(dx, dy))) n++;
}
return n;
};
const cands = [];
const add = (tier, order, px, py, ai) =>
cands.push({ tier, order, side: px * ux + py * uy > 0 ? 0 : 1, band: bandCount(px, py, ai), px, py });
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
radii.forEach((r, s) => {
const rad = (s - radPref + STEPS) % STEPS; // 0 = preferred step
if (h <= r) {
const off = Math.sqrt(Math.max(0, r * r - h * h));
add(1, h * 1e6 + ai * 1000 + rad * 10, (proj + off) * ux, (proj + off) * uy, ai);
}
add(2, h * 1e6 + ai * 1000 + rad * 10, a.x + r * ux, a.y + r * uy, ai);
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);
add(3, Math.abs(phi) * 1e6 + h + ai * 1e-3 + rad * 1e-4, a.x + r * dx, a.y + r * dy, ai);
}
});
});
cands.sort(
(p, q) => p.side - q.side || p.band - q.band || 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 discs) {
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
// circles span 3500–4608 px, the discs under a thousand): take
// the first DESTINATION-SIDE aimed point anyway — the tether and
// facing rules outrank cosmetics.
chosen =
cands.find((c) => c.tier === 2 && c.side === 0) ??
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 [];
// 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 isBarren = planets.length === 0 && freeSpace.length === 0;
if (isBarren && !jumps.some((j) => typeof j.x === 'number' && typeof j.y === 'number')) {
return []; // no gate to hang the cluster on (a one-system galaxy)
}
const clusterCfg = cfg.cluster ?? {};
const dist = cfg.distribution ?? {};
const placement = cfg.placement ?? {};
const isHome = record.id === galaxy?.homeSystemId;
// --- How many clusters -------------------------------------------------
// BARREN (dead-end) systems: the stop's payload — a little rock to mine
// inside the gate's tether (data/asteroids.json → barren.clusters,
// default 1–2). Everyone else: the inverse of the planet count — 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.
const rng = Rng.derive(galaxy.seed, 'system', record.id, 'asteroids');
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;
let count;
if (isBarren) {
const bc = cfg.barren ?? {};
const lo = Math.max(0, Math.floor(bc.clusters?.[0] ?? 1));
const hi = Math.max(lo, Math.floor(bc.clusters?.[1] ?? 2));
count = rng.int(lo, hi);
} else {
const target = dist.targetObjects ?? 9;
const jitter = dist.jitter ?? 1;
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;
// The dead end's anchor — its gate (barren systems are leaves of the
// tree, so exactly one; the cluster drifts inside its level-1 tether
// radius — an activated gate anchors a level-1 tether).
const gateAnchor = isBarren
? (jumps.find((j) => typeof j.x === 'number' && typeof j.y === 'number') ?? null)
: null;
const gateTetherRadius = anchorTetherRange(1);
// 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 — a real body in the
// starting system; every other system's center is empty) + every placed
// object (planets, free-space stations, and the jump gates).
const placed = [];
if (isHome) placed.push({ 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.
// A BARREN (dead-end) system's clusters drift INSIDE the gate's
// level-1 tether (the stop's only payload — mine it, then head back):
// an annulus around the gate, from barren.minRadius out to the tether
// rim minus the cluster's extent and the margin.
let pos;
if (isBarren && gateAnchor) {
const bc = cfg.barren ?? {};
const bMin = Math.max(0, Math.floor(bc.minRadius ?? 1024));
const bMax = Math.max(bMin, gateTetherRadius - bound - tetherMargin);
pos = placeInAnnulus(rng, bMin, bMax, placed, minSep, maxAttempts, gateAnchor);
} else {
const zoneMax =
i < homeSlots ? Math.max(rMin, Math.min(rMax, tetherRadius - bound - tetherMargin)) : rMax;
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 `center` (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, center = { x: 0, y: 0 }) {
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 = center.x + Math.cos(a) * r;
const y = center.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 home→far 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 —
* by design: the home system stays a small start the player
* expands out of; the arc layout could seat more)
* 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);
// 3) The planets fill the rest of the budget (N − stations ≥ 0).
// Every non-barren system keeps AT LEAST ONE PLANET: a gate anchors
// its tether to a world (layoutGates), and the player must be able to
// reach a planet and build out from it — a station-only budget
// (N = 2 stations) demotes one station to a planet.
let stations = Number(deepSpace) + Number(waypoint);
const classWeights = attr?.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 };
let wp = waypoint;
let ds = deepSpace;
while (stations >= objects && stations > 0) {
if (wp) wp = false;
else ds = false;
stations = Number(ds) + Number(wp);
}
const classes = Array.from({ length: objects - stations }, () => rngP.weighted(classWeights, 'rocky'));
return { objects, deepSpace: ds, waypoint: wp, 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) };
}
/**
* Home-corner → far-corner density: the settled heart (the home corner,
* d = 0) has more free-space activity per system; the deep corner
* (d = 1) is thinner, lonelier. `factor` scales every settlement chance
* (clamped to a floor so the deep corner 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 d = clamp(record?.d ?? 0, 0, 1);
return clamp(1 - d * 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` (`<systemId>-s<n>`, 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 (home→far 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 home→far 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 home→far 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))));
}