344 lines
14 KiB
JavaScript
344 lines
14 KiB
JavaScript
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));
|
||
|
||
/**
|
||
* 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 count spread, planet class weights, moon/belt chances,
|
||
* habitability, hazard, and — the lived-in layer — `settlements` (chance
|
||
* + required planet class per settlement kind). Settlement kinds and their
|
||
* population ranges live in data/settlements.json; the core→rim density
|
||
* gradient in data/galaxy.json (`settlements.gradient`).
|
||
*
|
||
* The galaxy is ALREADY LIVED IN: it was settled long before the player.
|
||
* Colonies on habitable worlds, mining stations over resource worlds,
|
||
* cloud bases riding gas giants, stations adrift in open space — and a
|
||
* fair number of charted-but-unclaimed systems. Nothing here is hostile
|
||
* yet: `owner` on every settlement is a reserved seam for the factions
|
||
* and pirates we'll introduce later.
|
||
*/
|
||
export function generateSystemContent(galaxy, record, typeDefs = null) {
|
||
const defs = typeDefs ?? config.get('systems.types', {});
|
||
const type = defs[record.type] ?? { label: record.type, attributes: {} };
|
||
const attr = type.attributes ?? {};
|
||
const rng = Rng.derive(galaxy.seed, 'system', record.id);
|
||
|
||
// --- Star -------------------------------------------------------------
|
||
const starClasses = attr.star?.classes ?? { G: 30, K: 35, M: 35 };
|
||
const massTable = attr.star?.mass ?? {};
|
||
const starClass = rng.weighted(starClasses, 'M');
|
||
const mass = Array.isArray(massTable[starClass]) ? massTable[starClass] : [0.3, 1.2];
|
||
const star = {
|
||
name: NameGenerator.star(rng),
|
||
class: starClass,
|
||
mass: Number(rng.range(mass[0], mass[1]).toFixed(2)),
|
||
binary: false,
|
||
};
|
||
if (rng.chance(attr.binaryChance ?? 0.05)) {
|
||
star.binary = true;
|
||
star.secondary = {
|
||
name: NameGenerator.star(rng),
|
||
class: rng.weighted(starClasses, starClass),
|
||
};
|
||
}
|
||
|
||
// --- Planets ----------------------------------------------------------
|
||
const pc = attr.planetCount ?? { min: 3, max: 8, mean: 5 };
|
||
const count = Math.round(
|
||
clamp(pc.mean + (rng.next() - 0.5) * (pc.max - pc.min), pc.min, pc.max),
|
||
);
|
||
const classWeights = attr.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 };
|
||
// Planet names come from a curated bank (data/naming.json → banks.planet),
|
||
// dealt out per-system without repeats (see NameGenerator.planetDeck).
|
||
// A dedicated derived stream keeps this order-independent (lazy === eager).
|
||
const planetDeck = NameGenerator.planetDeck(
|
||
Rng.derive(galaxy.seed, 'system', record.id, 'names', 'planets')
|
||
);
|
||
// The player's home world sits at the origin of the STARTING system and is
|
||
// not one of the generated planets. It takes the first name off that
|
||
// system's deck so it can never clash with a planet; the planets then draw
|
||
// from the rest of the deck (all distinct within the system).
|
||
const isHome = record.id === galaxy?.currentSystemId;
|
||
const homeName = isHome ? planetDeck[0] : null;
|
||
const planets = [];
|
||
for (let i = 1; i <= count; i++) {
|
||
const pclass = rng.weighted(classWeights, 'rocky');
|
||
let moons = 0;
|
||
if (rng.chance(attr.moonChance ?? 0.3)) {
|
||
// Jovian/ice worlds drag moon systems; terrestrials mostly don't.
|
||
moons = pclass === 'gas' || pclass === 'ice' ? rng.int(1, 6) : rng.int(0, 2);
|
||
}
|
||
planets.push({
|
||
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,
|
||
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);
|
||
|
||
// --- 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,
|
||
belt,
|
||
hazard,
|
||
};
|
||
if (isHome) content.homeName = homeName; // the player's home world (starting system only)
|
||
return content;
|
||
}
|
||
|
||
/**
|
||
* Top-down layout of a system's objects — its planets and its free-space
|
||
* stations — as one or two ORBITS (rings) around the home world, which
|
||
* always sits at the system origin (the game renders a solid world there;
|
||
* see GameScene).
|
||
*
|
||
* Each object gains x, y (world position). Planets also gain scale (size
|
||
* multiplier, data/planets.json → classScale — the spacing rules below are
|
||
* center-to-center, but the rendered discs still scale by class).
|
||
*
|
||
* The hard spacing rules (data/planets.json → solarSystem):
|
||
* minSpacing — no two objects (planets, space stations, the home world)
|
||
* may be closer than this, center to center;
|
||
* maxNeighbor — whenever a system holds more than one object, every
|
||
* object must be within this of at least one other.
|
||
*
|
||
* A ring of k objects at radius R around the origin satisfies both at once:
|
||
* - object ⇄ home-world distance is R, so R ∈ [minSpacing, maxNeighbor]
|
||
* takes care of the home world's own pair of constraints;
|
||
* - the closest object-object pair on a regular k-gon is an edge,
|
||
* 2·R·sin(π/k) — an edge is the shortest chord (vertices further around
|
||
* are further), so edge ≥ minSpacing covers every pair;
|
||
* - every object's nearest neighbor is then the home world, at
|
||
* R ≤ maxNeighbor.
|
||
* That radius range is non-empty for k ≤ 10 (k = 11 would need
|
||
* R ≥ 10905, which already strands the home world beyond maxNeighbor). So
|
||
* the 11-object maximum (9 planets + 2 stations — the most the current
|
||
* data can produce) gets two orbits: an inner 3-ring and an outer 8-ring.
|
||
* The outer ring's near neighbor is its ring-mate (edge stays in
|
||
* [minSpacing, maxNeighbor]); the inner ring keeps the home world within
|
||
* maxNeighbor; and any inner/outer pair is at least R_outer − R_inner ≥
|
||
* minSpacing apart. Smaller N all share one orbit. (Defensively, beyond
|
||
* 11 objects the orbits chain outward — rings of ≤ 10, and a lone world
|
||
* always fits radially outside the previous orbit — so this terminates
|
||
* for any N.)
|
||
*
|
||
* What counts as an object: planets (all of them) and free-space
|
||
* settlements (anchor.type 'space' — deep-space stations, waypoints).
|
||
* Planet-bound settlements are features OF their planet (a colony sits on
|
||
* it) and so are not layout objects of their own.
|
||
*
|
||
* Determinism: draws come from the dedicated fork (seed, 'system', id,
|
||
* 'layout') — layout never perturbs the star/planet/settlement draws
|
||
* above, and lazy (on-arrival) === eager (generateAll) is preserved.
|
||
*/
|
||
function layoutSystem(seed, systemId, planets, freeSpace) {
|
||
const band = config.get('planets.solarSystem', {});
|
||
if (band.enabled === false) return;
|
||
const MIN_SEP = band.minSpacing ?? 6144;
|
||
const MAX_NBR = band.maxNeighbor ?? 10240;
|
||
|
||
// Rendered size per class (visual only — the spacing rules are
|
||
// center-to-center, not edge-to-edge).
|
||
for (const p of planets) {
|
||
p.scale = config.get(`planets.classScale.${p.class}`, 1) ?? 1;
|
||
}
|
||
|
||
// Slot assignment order: planets in orbital order, then free-space
|
||
// stations in generation order (deep-space station, then waypoint) —
|
||
// stable per system, so lazy === eager.
|
||
const objects = [...planets, ...freeSpace];
|
||
const N = objects.length;
|
||
if (N === 0) return;
|
||
|
||
const TAU = Math.PI * 2;
|
||
const lay = Rng.derive(seed, 'system', systemId, 'layout');
|
||
const edgeFactor = (k) => (k <= 1 ? 1 : 2 * Math.sin(Math.PI / k));
|
||
const slots = [];
|
||
let prevR = 0;
|
||
let prevAngle = 0;
|
||
|
||
const placeRing = (k, lo, hi) => {
|
||
const R = lay.range(lo, hi);
|
||
// A LONE object on an outer orbit sits radially outside an
|
||
// already-placed one: its guaranteed neighbor is then exactly
|
||
// R − prevR away, whereas a random angle could leave it more than
|
||
// maxNeighbor from every inner object.
|
||
const th0 = k === 1 && prevR > 0 ? prevAngle : lay.range(0, TAU);
|
||
for (let i = 0; i < k; i++) {
|
||
const th = th0 + (k === 1 ? 0 : (TAU * i) / k);
|
||
slots.push({ x: R * Math.cos(th), y: R * Math.sin(th) });
|
||
}
|
||
prevR = R;
|
||
prevAngle = th0;
|
||
};
|
||
|
||
if (N <= 10) {
|
||
// One orbit around the home world (k = 1: a single object, whose only
|
||
// neighbor is the home world — fine, it's still within [MIN_SEP, MAX_NBR]).
|
||
const f = edgeFactor(N);
|
||
placeRing(N, Math.max(MIN_SEP, MIN_SEP / f), MAX_NBR);
|
||
} else if (N === 11) {
|
||
// Inner 3-ring: the home world must stay within MAX_NBR of it (R1 ≤
|
||
// MAX_NBR), and the outer 8-ring must sit at least MIN_SEP beyond it
|
||
// at a radius where its edge is still ≤ MAX_NBR:
|
||
// R1 ≤ MAX_NBR / edgeFactor(8) − MIN_SEP.
|
||
const f8 = edgeFactor(8);
|
||
placeRing(
|
||
3,
|
||
Math.max(MIN_SEP, MIN_SEP / edgeFactor(3)),
|
||
Math.min(MAX_NBR, MAX_NBR / f8 - MIN_SEP),
|
||
);
|
||
const R1 = prevR;
|
||
// Outer 8-ring: ring-mates are its near neighbors (edge in
|
||
// [MIN_SEP, MAX_NBR]); every inner object is ≥ R2 − R1 ≥ MIN_SEP away.
|
||
placeRing(
|
||
8,
|
||
Math.max(MIN_SEP / f8, R1 + MIN_SEP),
|
||
Math.min(MAX_NBR / f8, R1 + MAX_NBR),
|
||
);
|
||
} else {
|
||
// Beyond the data maximum: chain orbits outward. A ring of ≤ 10
|
||
// objects always has a valid radius as ring 1, a lone world always
|
||
// fits on any outer orbit — the loop terminates for any N.
|
||
let rem = N;
|
||
while (rem > 0) {
|
||
const k = Math.min(rem, 10);
|
||
const f = edgeFactor(k);
|
||
const lo = prevR === 0 ? Math.max(MIN_SEP, MIN_SEP / f) : Math.max(MIN_SEP / f, prevR + MIN_SEP);
|
||
const hi = prevR === 0 ? MAX_NBR : Math.min(MAX_NBR / f, prevR + MAX_NBR);
|
||
if (lo <= hi) {
|
||
placeRing(k, lo, hi);
|
||
rem -= k;
|
||
} else {
|
||
placeRing(1, prevR + MIN_SEP, prevR + MAX_NBR);
|
||
rem -= 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
for (let i = 0; i < objects.length; i++) {
|
||
objects[i].x = slots[i].x;
|
||
objects[i].y = slots[i].y;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Core→rim density: the settled heart of the galaxy has more activity per
|
||
* system; the rim is thinner, lonelier. `factor` scales every settlement
|
||
* chance (clamped to a floor so the rim isn't dead). 0 = no gradient.
|
||
*/
|
||
function settlementDensity(galaxy, record) {
|
||
const g = galaxy?.params?.settlements?.gradient ?? {};
|
||
const falloff = Math.max(0, g.falloff ?? 0.7);
|
||
const floor = clamp(g.floor ?? 0.22, 0, 1);
|
||
const rNorm = clamp(record?.rNorm ?? 0, 0, 1);
|
||
return clamp(1 - rNorm * falloff, floor, 1);
|
||
}
|
||
|
||
/**
|
||
* Draw settlements for one system. Stable draw order: planet-bound kinds in
|
||
* orbital order (colony, mining, cloud), then free-floating (deep-space
|
||
* station, waypoint). Every roll goes through the system's own stream.
|
||
*/
|
||
function generateSettlements({ rng, 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({
|
||
kind,
|
||
name: stationDeck[nameIndex++ % stationDeck.length],
|
||
anchor,
|
||
population: logPopulation(rng, def.population),
|
||
owner: null, // reserved: factions / pirates claim settlements later
|
||
});
|
||
};
|
||
|
||
// Planet-bound, in orbital order.
|
||
for (const p of planets) {
|
||
if (p.habitable && roll(rng, spec.colony?.chance ?? 0.3, density)) {
|
||
make('colony', { type: 'planet', ordinal: p.ordinal });
|
||
}
|
||
if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) &&
|
||
roll(rng, spec.miningStation?.chance ?? 0.2, density)) {
|
||
make('miningStation', { type: 'planet', ordinal: p.ordinal });
|
||
}
|
||
if (needs(p, spec.cloudBase?.needs, ['gas']) &&
|
||
roll(rng, spec.cloudBase?.chance ?? 0.15, density)) {
|
||
make('cloudBase', { type: 'planet', ordinal: p.ordinal });
|
||
}
|
||
}
|
||
|
||
// Free-floating, out in the dark.
|
||
if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) {
|
||
make('deepSpaceStation', { type: 'space' });
|
||
}
|
||
if (roll(rng, spec.waypoint?.chance ?? 0.2, density)) {
|
||
make('waypoint', { type: 'space' });
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
/** One deterministic roll, scaled by the core→rim density factor. */
|
||
function roll(rng, chance, density) {
|
||
return rng.chance(chance * density);
|
||
}
|
||
|
||
/** Does the planet satisfy the kind's requirements? */
|
||
function needs(planet, needsList, defaults) {
|
||
const list = Array.isArray(needsList) && needsList.length ? needsList : defaults;
|
||
return list.includes(planet.class);
|
||
}
|
||
|
||
/** Log-uniform population in [min, max] (a few towns to a few megacities). */
|
||
function logPopulation(rng, pop) {
|
||
const min = pop?.min ?? 1;
|
||
const max = Math.max(min, pop?.max ?? min);
|
||
if (min <= 0 && rng.chance(0.5)) return 0; // e.g. unmanned waypoints
|
||
return Math.round(Math.exp(rng.range(Math.log(Math.max(1, min)), Math.log(max))));
|
||
}
|