orbit/js/galaxy/SystemGenerator.js

1138 lines
52 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, 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.<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 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):
* 13 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 `<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 -------------------------------------------------------------
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?.currentSystemId;
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): 1maxGates, 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 (13; [] 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: `<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);
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^(N1). */
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 48 rocks (data → cluster.groupSize), each 64128
* 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^(homeLevel1), 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?.currentSystemId;
// --- How many clusters: the inverse of the planet count ---------------
const target = dist.targetObjects ?? 9;
const jitter = dist.jitter ?? 1;
const minC = Math.max(1, Math.floor(dist.minClusters ?? 1));
const maxC = Math.max(minC, Math.floor(dist.maxClusters ?? 6));
const homeMin = isHome ? Math.max(minC, Math.floor(dist.startingSystemMinClusters ?? 2)) : minC;
const rng = Rng.derive(galaxy.seed, 'system', record.id, 'asteroids');
const count = clamp(
Math.round(target - planets.length + rng.range(-jitter, jitter)),
homeMin, maxC,
);
if (count === 0) return [];
// --- Parameter plumbing (every value steerable from data/asteroids.json)
const frameCount = Math.max(1, Math.floor(cfg.frameCount ?? 10));
const sizeMin = Math.floor(clusterCfg.sizes?.min ?? 64);
const sizeMax = Math.max(sizeMin, Math.floor(clusterCfg.sizes?.max ?? 128));
const groupMin = Math.max(1, Math.floor(clusterCfg.groupSize?.min ?? 4));
const groupMax = Math.max(groupMin, Math.floor(clusterCfg.groupSize?.max ?? 8));
const spread = clusterCfg.spread ?? 140;
const spinMin = Math.max(0, clusterCfg.spin?.minDegPerSec ?? 0.3) * DEG;
const spinMax = Math.max(spinMin, clusterCfg.spin?.maxDegPerSec ?? 1.6) * DEG;
const gspinMin = Math.max(0, clusterCfg.groupSpin?.minDegPerSec ?? 0.12) * DEG;
const gspinMax = Math.max(gspinMin, clusterCfg.groupSpin?.maxDegPerSec ?? 0.4) * DEG;
const tintAnchors =
clusterCfg.tint?.enabled === false ? [] : (clusterCfg.tint?.anchors ?? ['#dfe9ff', '#ffe9d6', '#e8e4f8']);
const tintStrength = clamp(clusterCfg.tint?.strength ?? 0.5, 0, 1);
const debrisCfg = clusterCfg.debris ?? {};
// --- Placement rules ---------------------------------------------------
const rMin = placement.minRadius ?? 2048;
const rMax = Math.max(rMin, placement.maxRadius ?? 18432);
const minSep = placement.minObjectSpacing ?? 1024;
const maxAttempts = Math.max(1, Math.floor(placement.maxPlacementAttempts ?? 400));
const tetherMargin = placement.tetherMargin ?? 96;
const tetherRadius = homeTetherRadius();
const homeSlots = isHome ? Math.min(count, homeMin) : 0;
// Names already taken in this system (planets + stations).
const nameRng = Rng.derive(galaxy.seed, 'system', record.id, 'names', 'asteroids');
const usedNames = new Set();
for (const p of planets) if (p.name) usedNames.add(p.name);
for (const s of freeSpace) if (s.name) usedNames.add(s.name);
// Spacing obstacles: the home world (origin) + every placed object
// (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.20.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 (02, 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` (`<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 (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))));
}