orbit/js/galaxy/SystemGenerator.js

709 lines
31 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). Planet COUNT is a global rule (data/systems.json →
* `planetCount`): noneChance of systems are barren, the rest hold 24
* worlds. 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
* charted-but-unclaimed systems are the barren ones that also roll no
* free-space station. 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.
*
* 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),
};
}
// --- Planets ----------------------------------------------------------
// How many worlds the system holds (data/systems.json → planetCount):
// noneChance of systems are barren (ZERO planets); the rest get a
// uniform whole number in [min, max]. The STARTING system is the one
// exception: it always holds exactly TWO generated planets — a gas
// giant and a rocky world — which with the home world (the origin, the
// player's homestead, not a generated planet) makes its three planets.
const isHome = record.id === galaxy?.currentSystemId;
const pc = attr.planetCount ?? config.get('systems.planetCount', { noneChance: 0.2, min: 2, max: 4 });
const count = isHome
? 2
: rng.chance(pc.noneChance ?? 0.2) ? 0 : rng.int(pc.min ?? 2, pc.max ?? 4);
const classWeights = attr.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 };
// Planet names come from a curated bank (data/naming.json → banks.planet),
// dealt out per-system without repeats (see NameGenerator.planetDeck).
// A dedicated derived stream keeps this order-independent (lazy === eager).
const planetDeck = NameGenerator.planetDeck(
Rng.derive(galaxy.seed, 'system', record.id, 'names', 'planets')
);
// The player's home world sits at the origin of the STARTING system and is
// not one of the generated planets. It takes the first name off that
// system's deck so it can never clash with a planet; the planets then draw
// from the rest of the deck (all distinct within the system).
const homeName = isHome ? planetDeck[0] : null;
const planets = [];
for (let i = 1; i <= count; i++) {
// The starting system's two worlds are fixed (gas giant, then rocky);
// everywhere else the class rolls from the type's weights.
const pclass = isHome ? (i === 1 ? 'gas' : 'rocky') : rng.weighted(classWeights, 'rocky');
let moons = 0;
if (rng.chance(attr.moonChance ?? 0.3)) {
// Jovian/ice worlds drag moon systems; terrestrials mostly don't.
moons = pclass === 'gas' || pclass === 'ice' ? rng.int(1, 6) : rng.int(0, 2);
}
planets.push({
id: `${record.id}-p${i}`, // reputation/trading key (stable per seed)
name: planetDeck[(isHome ? i : i - 1) % planetDeck.length],
ordinal: i,
class: pclass,
moons,
habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1),
});
}
// --- Settlements (the lived-in layer) ---------------------------------
const settlements = generateSettlements({
rng,
systemId: record.id,
kindDefs: config.get('settlements.kinds', {}),
spec: attr.settlements ?? {},
planets,
stationDeck: NameGenerator.stationDeck(
Rng.derive(galaxy.seed, 'system', record.id, 'names', 'stations')
),
density: settlementDensity(galaxy, record),
});
// --- Layout: orbits around the home world -----------------------------
// Planets and free-space stations are the system's layout objects — each
// gets an x/y (see layoutSystem for the spacing rules and the N=11 case).
const freeSpace = settlements.filter((s) => s.anchor?.type === 'space');
layoutSystem(galaxy.seed, record.id, planets, freeSpace);
// --- Asteroid clusters ------------------------------------------------
// Loose groups of slowly tumbling rocks scattered through the void.
// Generated AFTER the layout (so every placed object is a spacing
// obstacle) from the dedicated stream (seed, 'system', id, 'asteroids')
// — independent of the star/planet/settlement/layout draws above, so
// lazy (on-arrival) === eager (generateAll) is preserved.
const asteroids = generateAsteroidClusters(galaxy, record, planets, freeSpace);
// --- Debris belt & system-level hazard --------------------------------
const belt = {
present: rng.chance(attr.beltChance ?? 0.35),
kind: rng.pick(['asteroid', 'debris']) ?? 'asteroid',
};
const hazard = rng.chance(attr.hazard ?? 0.1);
const content = {
name: record.name,
type: record.type,
star,
planets,
settlements,
asteroids,
belt,
hazard,
};
if (isHome) content.homeName = homeName; // the player's home world (starting system only)
return content;
}
/**
* Top-down layout of a system's objects — its planets and its free-space
* stations — as one or two ORBITS (rings) around the home world, which
* always sits at the system origin (the game renders a solid world there;
* see GameScene).
*
* Each object gains x, y (world position). Planets also gain scale (size
* multiplier, data/planets.json → classScale — the spacing rules below are
* center-to-center, but the rendered discs still scale by class).
*
* The hard spacing rules (data/planets.json → solarSystem):
* minSpacing — no two objects (planets, space stations, the home world)
* may be closer than this, center to center;
* maxNeighbor — whenever a system holds more than one object, every
* object must be within this of at least one other.
*
* A ring of k objects at radius R around the origin satisfies both at once:
* - object ⇄ home-world distance is R, so R ∈ [minSpacing, maxNeighbor]
* takes care of the home world's own pair of constraints;
* - the closest object-object pair on a regular k-gon is an edge,
* 2·R·sin(π/k) — an edge is the shortest chord (vertices further around
* are further), so edge ≥ minSpacing covers every pair;
* - every object's nearest neighbor is then the home world, at
* R ≤ maxNeighbor.
* That radius range is non-empty for k ≤ 10 (k = 11 would need
* R ≥ 10905, which already strands the home world beyond maxNeighbor).
* The current data's maximum is 6 objects (4 planets + 2 free-space
* stations), which always fits a single orbit; the 11-object case is kept
* as a defensive fallback (an inner 3-ring and an outer 8-ring) for any
* future data that could produce it.
* The outer ring's near neighbor is its ring-mate (edge stays in
* [minSpacing, maxNeighbor]); the inner ring keeps the home world within
* maxNeighbor; and any inner/outer pair is at least R_outer R_inner ≥
* minSpacing apart. Smaller N all share one orbit. (Defensively, beyond
* 11 objects the orbits chain outward — rings of ≤ 10, and a lone world
* always fits radially outside the previous orbit — so this terminates
* for any N.)
*
* What counts as an object: planets (all of them) and free-space
* settlements (anchor.type 'space' — deep-space stations, waypoints).
* Planet-bound settlements are features OF their planet (a colony sits on
* it) and so are not layout objects of their own.
*
* Determinism: draws come from the dedicated fork (seed, 'system', id,
* 'layout') — layout never perturbs the star/planet/settlement draws
* above, and lazy (on-arrival) === eager (generateAll) is preserved.
*/
function layoutSystem(seed, systemId, planets, freeSpace) {
const band = config.get('planets.solarSystem', {});
if (band.enabled === false) return;
const MIN_SEP = band.minSpacing ?? 6144;
const MAX_NBR = band.maxNeighbor ?? 10240;
// Rendered size per class (visual only — the spacing rules are
// center-to-center, not edge-to-edge).
for (const p of planets) {
p.scale = config.get(`planets.classScale.${p.class}`, 1) ?? 1;
}
// Slot assignment order: planets in orbital order, then free-space
// stations in generation order (deep-space station, then waypoint) —
// stable per system, so lazy === eager.
const objects = [...planets, ...freeSpace];
const N = objects.length;
if (N === 0) return;
const TAU = Math.PI * 2;
const lay = Rng.derive(seed, 'system', systemId, 'layout');
const edgeFactor = (k) => (k <= 1 ? 1 : 2 * Math.sin(Math.PI / k));
const slots = [];
let prevR = 0;
let prevAngle = 0;
const placeRing = (k, lo, hi) => {
const R = lay.range(lo, hi);
// A LONE object on an outer orbit sits radially outside an
// already-placed one: its guaranteed neighbor is then exactly
// R prevR away, whereas a random angle could leave it more than
// maxNeighbor from every inner object.
const th0 = k === 1 && prevR > 0 ? prevAngle : lay.range(0, TAU);
for (let i = 0; i < k; i++) {
const th = th0 + (k === 1 ? 0 : (TAU * i) / k);
slots.push({ x: R * Math.cos(th), y: R * Math.sin(th) });
}
prevR = R;
prevAngle = th0;
};
if (N <= 10) {
// One orbit around the home world (k = 1: a single object, whose only
// neighbor is the home world — fine, it's still within [MIN_SEP, MAX_NBR]).
const f = edgeFactor(N);
placeRing(N, Math.max(MIN_SEP, MIN_SEP / f), MAX_NBR);
} else if (N === 11) {
// Inner 3-ring: the home world must stay within MAX_NBR of it (R1 ≤
// MAX_NBR), and the outer 8-ring must sit at least MIN_SEP beyond it
// at a radius where its edge is still ≤ MAX_NBR:
// R1 ≤ MAX_NBR / edgeFactor(8) MIN_SEP.
const f8 = edgeFactor(8);
placeRing(
3,
Math.max(MIN_SEP, MIN_SEP / edgeFactor(3)),
Math.min(MAX_NBR, MAX_NBR / f8 - MIN_SEP),
);
const R1 = prevR;
// Outer 8-ring: ring-mates are its near neighbors (edge in
// [MIN_SEP, MAX_NBR]); every inner object is ≥ R2 R1 ≥ MIN_SEP away.
placeRing(
8,
Math.max(MIN_SEP / f8, R1 + MIN_SEP),
Math.min(MAX_NBR / f8, R1 + MAX_NBR),
);
} else {
// Beyond the data maximum: chain orbits outward. A ring of ≤ 10
// objects always has a valid radius as ring 1, a lone world always
// fits on any outer orbit — the loop terminates for any N.
let rem = N;
while (rem > 0) {
const k = Math.min(rem, 10);
const f = edgeFactor(k);
const lo = prevR === 0 ? Math.max(MIN_SEP, MIN_SEP / f) : Math.max(MIN_SEP / f, prevR + MIN_SEP);
const hi = prevR === 0 ? MAX_NBR : Math.min(MAX_NBR / f, prevR + MAX_NBR);
if (lo <= hi) {
placeRing(k, lo, hi);
rem -= k;
} else {
placeRing(1, prevR + MIN_SEP, prevR + MAX_NBR);
rem -= 1;
}
}
}
for (let i = 0; i < objects.length; i++) {
objects[i].x = slots[i].x;
objects[i].y = slots[i].y;
}
}
/**
* Asteroid clusters — the system's loose rock fields (data/asteroids.json).
*
* A cluster is a GROUP of 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) {
const cfg = config.section('asteroids', {});
if (cfg.enabled === false) return [];
// Without the solar-system layout there are no placed objects to space
// against — no clusters either (the scene renders nothing else anyway).
if (config.get('planets.solarSystem.enabled', true) === false) return [];
const clusterCfg = cfg.cluster ?? {};
const dist = cfg.distribution ?? {};
const placement = cfg.placement ?? {};
const isHome = record.id === galaxy?.currentSystemId;
// --- How many clusters: the inverse of the planet count ---------------
const target = dist.targetObjects ?? 9;
const jitter = dist.jitter ?? 1;
const minC = Math.max(1, Math.floor(dist.minClusters ?? 1));
const maxC = Math.max(minC, Math.floor(dist.maxClusters ?? 6));
const homeMin = isHome ? Math.max(minC, Math.floor(dist.startingSystemMinClusters ?? 2)) : minC;
const rng = Rng.derive(galaxy.seed, 'system', record.id, 'asteroids');
const count = clamp(
Math.round(target - planets.length + rng.range(-jitter, jitter)),
homeMin, maxC,
);
if (count === 0) return [];
// --- Parameter plumbing (every value steerable from data/asteroids.json)
const frameCount = Math.max(1, Math.floor(cfg.frameCount ?? 10));
const sizeMin = Math.floor(clusterCfg.sizes?.min ?? 64);
const sizeMax = Math.max(sizeMin, Math.floor(clusterCfg.sizes?.max ?? 128));
const groupMin = Math.max(1, Math.floor(clusterCfg.groupSize?.min ?? 4));
const groupMax = Math.max(groupMin, Math.floor(clusterCfg.groupSize?.max ?? 8));
const spread = clusterCfg.spread ?? 140;
const spinMin = Math.max(0, clusterCfg.spin?.minDegPerSec ?? 0.3) * DEG;
const spinMax = Math.max(spinMin, clusterCfg.spin?.maxDegPerSec ?? 1.6) * DEG;
const gspinMin = Math.max(0, clusterCfg.groupSpin?.minDegPerSec ?? 0.12) * DEG;
const gspinMax = Math.max(gspinMin, clusterCfg.groupSpin?.maxDegPerSec ?? 0.4) * DEG;
const tintAnchors =
clusterCfg.tint?.enabled === false ? [] : (clusterCfg.tint?.anchors ?? ['#dfe9ff', '#ffe9d6', '#e8e4f8']);
const tintStrength = clamp(clusterCfg.tint?.strength ?? 0.5, 0, 1);
const debrisCfg = clusterCfg.debris ?? {};
// --- Placement rules ---------------------------------------------------
const rMin = placement.minRadius ?? 2048;
const rMax = Math.max(rMin, placement.maxRadius ?? 18432);
const minSep = placement.minObjectSpacing ?? 1024;
const maxAttempts = Math.max(1, Math.floor(placement.maxPlacementAttempts ?? 400));
const tetherMargin = placement.tetherMargin ?? 96;
const tetherRadius = homeTetherRadius();
const homeSlots = isHome ? Math.min(count, homeMin) : 0;
// Names already taken in this system (planets + stations).
const nameRng = Rng.derive(galaxy.seed, 'system', record.id, 'names', 'asteroids');
const usedNames = new Set();
for (const p of planets) if (p.name) usedNames.add(p.name);
for (const s of freeSpace) if (s.name) usedNames.add(s.name);
// Spacing obstacles: the home world (origin) + every placed object.
const placed = [{ x: 0, y: 0 }];
for (const p of planets) if (typeof p.x === 'number' && typeof p.y === 'number') placed.push({ x: p.x, y: p.y });
for (const s of freeSpace) if (typeof s.x === 'number' && typeof s.y === 'number') placed.push({ x: s.x, y: s.y });
const clusters = [];
for (let i = 0; i < count; i++) {
// --- The group's rocks ---------------------------------------------
const memberCount = rng.int(groupMin, groupMax);
// Frames: a random subset of the sheet — NO frame twice in this cluster.
const frames = rng.shuffle(Array.from({ length: frameCount }, (_, k) => k)).slice(0, memberCount);
// Sizes: random in [sizeMin, sizeMax]; at least minFullSize full-size.
const sizes = Array.from({ length: memberCount }, () => rng.int(sizeMin, sizeMax));
const fullRocks = Math.min(Math.max(0, Math.floor(clusterCfg.minFullSize ?? 1)), memberCount);
for (let k = 0; k < fullRocks; k++) sizes[rng.int(0, memberCount - 1)] = sizeMax;
// Offsets: a dense random blob around the center (uniform in area),
// then a relaxation pass enforces a small gap between EVERY pair of
// rocks (cluster.gapFactor — 1.12 ≈ a subtle gap): only pairs that
// would overlap move, each by half the shortfall, so the group keeps
// its random look and stays compact while no rocks interpenetrate.
const gapFactor = Math.max(1, clusterCfg.gapFactor ?? 1.12);
const members = [];
for (let k = 0; k < memberCount; k++) {
const a = rng.range(0, TAU);
const r = spread * Math.sqrt(rng.next()); // uniform in area
members.push({ x: Math.cos(a) * r, y: Math.sin(a) * r, size: sizes[k] });
}
relaxRockGaps(members, gapFactor);
const bound = Math.max(...members.map((m) => Math.hypot(m.x, m.y) + m.size / 2));
// Spins: each rock tumbles on its own — its own slow speed, its own
// direction, its own starting phase.
const spins = members.map(() => (rng.chance(0.5) ? 1 : -1) * rng.range(spinMin, spinMax));
const phases = members.map(() => rng.range(0, TAU));
// Dust: a fine halo of motes orbiting just outside the rocks.
const debris = [];
if (debrisCfg.enabled !== false) {
const n = rng.int(debrisCfg.count?.[0] ?? 14, debrisCfg.count?.[1] ?? 30);
const rIn = bound * (debrisCfg.inner ?? 1.0);
const rOut = Math.max(rIn, bound * (debrisCfg.outer ?? 2.1));
for (let k = 0; k < n; k++) {
const a = rng.range(0, TAU);
const r = Math.sqrt(rng.range(rIn * rIn, rOut * rOut));
debris.push({
x: Math.cos(a) * r,
y: Math.sin(a) * r,
size: rng.range(debrisCfg.size?.[0] ?? 0.8, debrisCfg.size?.[1] ?? 2.4),
alpha: rng.range(debrisCfg.alpha?.[0] ?? 0.12, debrisCfg.alpha?.[1] ?? 0.4),
});
}
}
// Starlight: a subtle warm/cool shift, per cluster.
const tint = tintAnchors.length
? mixTint(tintAnchors[rng.int(0, tintAnchors.length - 1)] ?? tintAnchors[0], tintStrength)
: null;
// Name: synthesised, unique within the system.
let name = NameGenerator.asteroid(nameRng);
for (let tries = 0; tries < 10 && usedNames.has(name); tries++) name = NameGenerator.asteroid(nameRng);
usedNames.add(name);
// --- Where: sprinkle it in the void --------------------------------
// The starting system's first clusters live INSIDE the initial tether
// (whole group: center + bound + margin ≤ tether radius); the rest —
// and every cluster elsewhere — go anywhere in the scatter annulus.
const zoneMax =
i < homeSlots ? Math.max(rMin, Math.min(rMax, tetherRadius - bound - tetherMargin)) : rMax;
const pos = placeInAnnulus(rng, rMin, zoneMax, placed, minSep, maxAttempts);
placed.push(pos);
clusters.push({
id: `asteroid-${i + 1}`,
name,
x: pos.x,
y: pos.y,
bound,
tint,
groupSpin: (rng.chance(0.5) ? 1 : -1) * rng.range(gspinMin, gspinMax),
groupPhase: rng.range(0, TAU),
debrisPhase: rng.range(0, TAU),
// The dust glides on its OWN slow orbit (0.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);
}
/**
* Core→rim density: the settled heart of the galaxy has more activity per
* system; the rim is thinner, lonelier. `factor` scales every settlement
* chance (clamped to a floor so the rim isn't dead). 0 = no gradient.
*/
function settlementDensity(galaxy, record) {
const g = galaxy?.params?.settlements?.gradient ?? {};
const falloff = Math.max(0, g.falloff ?? 0.7);
const floor = clamp(g.floor ?? 0.22, 0, 1);
const rNorm = clamp(record?.rNorm ?? 0, 0, 1);
return clamp(1 - rNorm * falloff, floor, 1);
}
/**
* Draw settlements for one system. Stable draw order: planet-bound
* 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 }) {
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 (per-type odds, core→rim scaled).
if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) {
make('deepSpaceStation', { type: 'space' });
}
if (roll(rng, spec.waypoint?.chance ?? 0.2, density)) {
make('waypoint', { type: 'space' });
}
return out;
}
/** 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))));
}