orbit/js/galaxy/Galaxy.js

831 lines
37 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';
import { buildJumpNetwork } from './JumpNetwork.js';
import { assignPlanetFrames } from './PlanetFrames.js';
import { assignStationFrames } from './StationFrames.js';
import { generateSystemContent, rollSystemComposition, settlementDensity } from './SystemGenerator.js';
const TAU = Math.PI * 2;
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
/**
* The Galaxy — an immense, procedurally generated collection of star
* systems, fully determined by its seed.
*
* Two-level generation (the "grand scale" design):
*
* 1. GALAXY ROSTER — generated once, up front, when New Game is pressed:
* `systemCount` lightweight records ({ id, name, type, x, y, d, zone }).
* This is cheap: the 90-system default is a few ms and a few hundred KB.
* It fixes the shape of the galaxy, where every system sits, and
* what KIND each one is — for the entire galaxy, from the seed alone.
*
* 2. SYSTEM CONTENTS — planets, moons, belts, hazards… generated LAZILY,
* the first time the player arrives (`ensureContent(id)`), then
* cached. Because each system's draw stream is derived from
* (seed, 'system', id), a lazy generation is identical to an eager
* one — so this is a pure performance choice, never a correctness one.
* `generateAll()` exists for exactly that, if it's ever "just as easy".
*
* The SHAPE (data/galaxy.json):
* - a seeded WIDE (2:1) field of stars (layout.field.width × height,
* center at the world origin) placed by Bridson Poisson-disk
* sampling — an even, organic field: every pair of systems stays at
* least minSpacing·√(width·height/N) apart, no clumps, no voids, not
* a grid. The 2:1 aspect matches the map plate (js/ui/MapWindow.js at
* the 1280×720 design size), so the fully-zoomed-out galaxy fills
* the plate instead of letterboxing;
* - the player's HOME system sits in the star nearest the configured
* corner (startingSystem: policy "corner", corner NE/NW/SE/SW,
* screen orientation — SE = lower right); "center" / "random" still
* work; its TYPE is always "main" (Main Sequence —
* startingSystem.type, the player's home is an ordinary star; the
* zone-mixed roll is overridden at roster build); the HOME→FAR
* diagonal is the galaxy's progression axis:
* each record carries `d` (0 at the home corner, 1 at the opposite
* corner) and its `zone` (near/middle/far, distribution.zones);
* - types mix PER ZONE (distribution.zoneMix × the type's global
* distribution.weight — the old per-type radiusBand is gone: type
* flavor is regional now), and free-space settlement density thins
* home→far (settlements.gradient, keyed on `d`).
*
* Determinism contract:
* same seed ⇒ same roster (positions, types, names), same contents,
* in any order of generation. Dev/test tools rely on this.
*
* Extension points for later world rules (see docs/PROJECT_NOTES.md):
* - FACTIONS: ASSIGNED AT ROSTER TIME by the faction pass (_assignFactions,
* data/factions.json) — each system's record carries `faction` (or
* null = neutral wild space) + `capital` (the four designated
* capitals: neutral + apex/mindweave/zenith). The reputation seam
* (js/reputation/Reputation.js → factionStanding) and the galaxy
* plate's faction ring layer (js/ui/GalaxyView.js) read them; reachability
* / combat / trade off the territories is the next layer.
* - COMBAT / TRADE: read difficulty and price levels off `record.d`
* and `record.zone` (the middle zone is where the zone borders cross
* — the planned contested space + transit trade hubs).
* - more layout knobs in data/galaxy.json `layout.field`.
*/
export class Galaxy {
constructor(seed, params, typeDefs) {
this.seed = seed;
this.params = params;
this.typeDefs = typeDefs;
this.records = [];
this.byId = new Map();
this.contentCache = new Map();
this.currentSystemId = null;
// The STARTING system — the player's home port. Set once at roster
// build and never moves: `currentSystemId` tracks where the player IS
// NOW (jumps + saves move it), but the home world always belongs to
// the starting system (SystemGenerator's isHome rule, GameScene's
// home/star split). Without this split, lazily generated content for
// a newly arrived system would compute isHome = true (currentSystemId
// had already moved there) and deal it a home world of its own.
this.homeSystemId = null;
this.planetFrames = new Map(); // id → [frame, ...] — the frame-diversity pass
this.homeWorldFrame = null; // the starting system's home world frame
this.stationFrames = new Map(); // id → frame — the station-variant spread pass
this.name = NameGenerator.galaxy(Rng.derive(seed, 'galaxy', 'name'));
}
/**
* Build a galaxy from a seed (trimmed; ' abc ' and 'abc' are the same
* galaxy). `overrides` shallow-merges over data/galaxy.json — used by
* dev tools (e.g. a 300-system galaxy for brute-force tests).
*/
static create(seed, overrides = {}) {
if (seed === undefined || seed === null || String(seed).trim().length === 0) {
throw new Error('Galaxy.create() needs a non-empty seed');
}
const params = { ...config.section('galaxy', {}), ...overrides };
const typeDefs = config.get('systems.types', {});
if (Object.keys(typeDefs).length === 0) {
throw new Error('No system types found — is data/systems.json listed in data/manifest.json?');
}
const count = Math.floor(params.systemCount ?? 200);
if (!(count >= 1)) {
throw new Error(`galaxy.systemCount must be a whole number >= 1 (got ${params.systemCount})`);
}
const galaxy = new Galaxy(String(seed).trim(), params, typeDefs);
galaxy._generate(count);
return galaxy;
}
// ------------------------------------------------------------------
// Roster generation (level 1)
// ------------------------------------------------------------------
_generate(count) {
const L = this.params.layout ?? {};
const F = L.field ?? {};
const W = Math.max(4, Math.floor(Number(F.width) || 32000));
const H = Math.max(4, Math.floor(Number(F.height) || 16000));
this.fieldW = W; // the field's extent (world px, center at origin)
this.fieldH = H;
const halfW = W / 2;
const halfH = H / 2;
const minSpacing = clamp(Number(F.minSpacing) || 0.8, 0.4, 1.2);
// The HOME CORNER (data/galaxy.json → startingSystem.corner; screen
// orientation — y down, so SE = lower right). Two things key off it:
// the starting-system policy (home = the star NEAREST this corner),
// and the difficulty diagonal (d = 0 here, d = 1 at the opposite
// corner — the galaxy's progression axis, whatever policy picks the
// starting system).
const start = this.params.startingSystem ?? {};
const corner = this._cornerPoint(start.corner ?? 'SE', { halfW, halfH });
const opp = { x: -corner.x, y: -corner.y };
const diag2 = (corner.x - opp.x) ** 2 + (corner.y - opp.y) ** 2; // W² + H²
const dOf = (x, y) => clamp(
((corner.x - x) * (corner.x - opp.x) + (corner.y - y) * (corner.y - opp.y)) / diag2,
0, 1,
);
// ZONES — slices of the home→far diagonal (distribution.zones), and
// the PER-ZONE type mix (distribution.zoneMix × each type's global
// distribution.weight). Regional flavor replaces the old per-type
// radiusBand: which KINDS of stars favor which region of the galaxy.
const zones = this._zones();
const zoneOf = (d) => (zones.find((z) => d >= z.d[0] && d < z.d[1]) ?? zones[zones.length - 1]).name;
const mix = this.params.distribution?.zoneMix ?? {};
const typeIds = Object.keys(this.typeDefs);
const weightsFor = (zone) => {
const m = mix[zone] ?? {};
const w = {};
for (const id of typeIds) {
const mul = m[id] === undefined ? 1 : Math.max(0, Number(m[id]) || 0);
w[id] = Math.max(0, this.typeDefs[id].distribution?.weight ?? 1) * mul;
}
return w;
};
const g = Rng.derive(this.seed, 'layout');
// EVEN PLACEMENT — a Bridson Poisson-disk (blue-noise) field: every
// pair of systems stays at least dmin apart (no clumps, no voids)
// while the field stays organic (not a grid). dmin is a fraction of
// the mean inter-star spacing √(width·height/N)
// (layout.field.minSpacing).
// Even spacing is what keeps the jump network's hop counts
// proportional to map distance — the property the trade economy
// leans on ("a hop is a hop").
const dmin = minSpacing * Math.sqrt((W * H) / Math.max(1, count));
const points = this._poissonDisk(count, { halfW, halfH }, dmin);
const records = this.records;
for (let i = 1; i <= count; i++) {
const id = `S${String(i).padStart(6, '0')}`;
const p = points[i - 1] ?? { x: g.range(-halfW, halfW), y: g.range(-halfH, halfH) };
const d = dOf(p.x, p.y);
const zone = zoneOf(d);
const type = g.weighted(weightsFor(zone), typeIds[0]);
// Name comes from a per-record fork so roster generation order can
// never leak into it. d (0 = home corner, 1 = far corner) and zone
// stay on the record: the settlement generator uses d (home→far
// density), and factions/trade/combat will read difficulty off both.
// faction/capital default to null (neutral wild space, no capital)
// — the faction pass (_assignFactions) stamps the territories +
// capital designations after the jump network exists.
const name = NameGenerator.star(Rng.derive(this.seed, 'name', id));
const rec = { id, name, type, x: p.x, y: p.y, d, zone, faction: null, capital: null };
records.push(rec);
this.byId.set(id, rec);
}
// Starting system (the player's home port).
const policy = start.policy ?? 'corner';
const closestTo = (px, py) =>
records
.slice()
.sort((a, b) => (a.x - px) ** 2 + (a.y - py) ** 2 - ((b.x - px) ** 2 + (b.y - py) ** 2))[0]?.id ?? records[0]?.id;
this.currentSystemId =
policy === 'random'
? `S${String(g.int(1, count)).padStart(6, '0')}`
: policy === 'center'
? closestTo(0, 0)
: closestTo(corner.x, corner.y); // 'corner' (default)
// Frozen copy of the starting system — `currentSystemId` will track
// the player from here on (jumps, saves); the home rules must not.
this.homeSystemId = this.currentSystemId;
// The starting system is ALWAYS a MAIN SEQUENCE system (data/galaxy.json
// → startingSystem.type, default 'main' — data/systems.json → types.main,
// "Main Sequence"): the player's home is an ordinary star. Its type was
// rolled from the zone mix above like every other record; override it
// here — before the frame passes read it — so the dossier ("Main
// Sequence system · star G"), the map plate, and the generated content
// all agree.
const forcedType = String(start.type ?? 'main');
const homeRec = this.byId.get(this.currentSystemId);
if (homeRec && this.typeDefs[forcedType]) {
if (homeRec.type !== forcedType) homeRec.type = forcedType;
} else {
console.warn(
`[orbit] startingSystem.type "${forcedType}" is not a defined system type — the starting system keeps its rolled type`,
);
}
// Spatial hash for fast neighbor queries (jump ranges, proximity rules,
// the eventual star map).
const area = W * H;
this.cellSize = Math.max(8, Math.sqrt(area / count) * 1.4);
this.grid = new Map();
for (const rec of records) {
const key = `${Math.floor(rec.x / this.cellSize)},${Math.floor(rec.y / this.cellSize)}`;
let cell = this.grid.get(key);
if (!cell) {
cell = [];
this.grid.set(key, cell);
}
cell.push(rec);
}
// The BARREN set — the gate-only dead ends (objectCount → 0). The
// SAME roll the content generator uses (dedicated per-system forks —
// rollSystemComposition), so the network and the content always agree:
// JumpNetwork keeps every barren system a LEAF of the tree (one gate —
// in and out the same way), the maze's dead ends.
const typeDefs = this.typeDefs ?? {};
const barren = new Set();
for (const record of records) {
if (record.id === this.currentSystemId) continue; // home is exempt
const type = typeDefs[record.type] ?? {};
const density = settlementDensity({ params: this.params }, record);
const composition = rollSystemComposition(
this.seed, record, false, type.attributes?.settlements ?? {}, density, type.attributes ?? {},
);
if (composition.classes.length === 0) barren.add(record.id);
}
// The JUMP NETWORK (data/gates.json): which star each system's jump
// gates reach. A degree-limited spanning TREE of the nearest-star
// graph — the galaxy reads as a MAZE: (nearly) one route between any
// two systems, dead ends at the leaves — and every barren system is
// a dead-end LEAF (exactly one gate, in and out the same way). The
// deliberate exception: the BRIDGES (data/gates.json → bridges)
// cross-link the branches' middle belts, so one branch doesn't have
// to trek back to the trunk (home) to reach another (the faction
// design — data/factions.json). Tree edges run BOTH ways, so from any
// system the player can reach any other and every jump has a RETURN
// gate (no trapped sets). Each system holds 1–maxGates gates (its
// degree, bridges included). Deterministic: same roster ⇒ same
// network.
const pool = Math.max(1, Math.min(count - 1, (config.get('gates.neighborPool', 8) | 0)));
this.jumpNetwork = buildJumpNetwork({
records,
knn: (id) => this.neighborsOf(id, pool),
minGates: Math.max(0, config.get('gates.minGates', 1) | 0),
maxGates: Math.max(1, config.get('gates.maxGates', 3) | 0),
shortcuts: config.get('gates.shortcuts', true) === true,
barren,
rootId: this.currentSystemId,
bridges: config.get('gates.bridges', null),
});
// Defensive repairs that had to fire (0 on a healthy kNN graph).
this.jumpNetworkRepaired = this.jumpNetwork.repaired;
if (this.jumpNetworkRepaired > 0) {
console.warn(`[orbit] jump network: ${this.jumpNetworkRepaired} system(s) needed a repair attach`);
}
// FACTIONS (data/factions.json — the faction pass): branch
// territories + the four CAPITAL designations (neutral + apex /
// mindweave / zenith), stamped on the roster records
// (record.faction / record.capital). Runs after the jump network
// (it needs the branches the tree defines) and before the frame
// pass (independent of it — the capital planet is not a
// composition planet, so the frame pass never sees it).
this._assignFactions(barren);
// FRAME DIVERSITY (js/galaxy/PlanetFrames.js): the galaxy-wide
// (class, frame) assignment — each planet's sheet frame avoids what
// the NEAREST stars already wear for that class, so the same face is
// spread across the galaxy instead of clustering in one region. Fixed
// roster order ⇒ visit-order independent; read back by the content
// generator (planet.frame / content.homeFrame) — the lazy === eager
// contract is preserved.
const pool8 = Math.max(1, Math.floor(this.params.neighbors ?? 8));
const { frames, homeFrame } = assignPlanetFrames({
seed: this.seed,
records,
homeId: this.currentSystemId,
params: this.params,
neighborsOf: (id) => this.neighborsOf(id, pool8),
});
this.planetFrames = frames; // Map id → [frame, ...] (ordinal order)
this.homeWorldFrame = homeFrame;
// STATION VARIANT SPREAD (js/galaxy/StationFrames.js): each system's
// deep-space station wears one of the spacestations.png variants
// (data/stations.json → variants) — the frame avoids what the
// NEAREST stars already wear, so the same station type is spread
// across the galaxy instead of clustering in one region. Fixed
// roster order ⇒ visit-order independent; read back by the content
// generator (settlement.stationFrame) — lazy === eager preserved.
const { frames: stationFrames } = assignStationFrames({
seed: this.seed,
records,
homeId: this.currentSystemId,
params: this.params,
neighborsOf: (id) => this.neighborsOf(id, pool8),
});
this.stationFrames = stationFrames; // Map id → frame (station-bearing systems)
}
/**
* The FACTION PASS — branch territories + the four capital designations
* (data/factions.json; see docs/PROJECT_NOTES.md "Factions"). Runs at
* roster build, before any content generation, so the stamp is pure
* function of the seed (lazy === eager: content generation only READS
* the record fields it leaves behind):
*
* BRANCHES — the jump tree's top-level subtrees, rooted at the home
* system's gate neighbors (home has ≤ maxGates gates, so ≤ 3).
* NEUTRAL CAPITAL — a seeded pick among home's gate neighbors (the
* "always connected to the home star" rule); its system stays
* faction-NEUTRAL even though it roots one faction's branch
* (a neutral hub at the edge of that corridor).
* FACTION CAPITALS — one per faction (apex / mindweave / zenith): a
* SEEDED pick inside that faction's branch, preferred in the middle
* band (d in 0.37..0.63 — the home→far diagonal's middle slice),
* on a non-barren system (it must be able to host a planet). The
* branch↔faction assignment is a SEEDED SHUFFLE — a new galaxy
* reshuffles who sits where (never a fixed order), same seed ⇒
* identical placement.
* TERRITORY — a branch's systems at depth 2 … (maxDepth−1) (home =
* depth 0, its gate neighbors = depth 1) are ELIGIBLE; the ring
* right outside home and every branch's dead-end tips stay NEUTRAL
* WILD SPACE; anything not in a territory is neutral. A faction
* capital always sits in its faction's territory (fallbacks only
* when the branch is too short to have one).
* BALANCE QUOTA (no dominant faction — branch sizes are inherently
* skewed, and a branch IS its faction's territory): each branch
* keeps only the min(eligible, quota) of its eligible systems,
* closest to the middle band (|d − 0.5|, index tiebreak —
* deterministic); quota = the MEDIAN of the three branches'
* eligible sizes. The big branch's overflow stays neutral wild
* space; a small branch keeps everything it has (a working
* territory beats an empty one).
*
* @param {Set<string>|null} barren the gate-only dead ends (excluded from
* capital candidacy — they can't host a world)
*/
_assignFactions(barren) {
const FACTIONS = ['apex', 'mindweave', 'zenith'];
const dMin = 0.37, dMax = 0.63; // the middle band (data/galaxy.json zones)
const homeId = this.homeSystemId;
// Branch + depth of every system — from the JUMP NETWORK's own
// spanning tree (js/galaxy/JumpNetwork.js exposes branches/branchOf/
// depth): the bridge pass and the faction pass must agree on the SAME
// branch structure. (A BFS over the final gate list — tree + bridges
// — can re-parent systems across branches, since the bridges are
// loops; that is NOT the tree's branch structure.) The fallback below
// is only for a network that lacks the export (defensive).
const netBranchOf = this.jumpNetwork?.branchOf instanceof Map ? this.jumpNetwork.branchOf : null;
const netDepth = this.jumpNetwork?.depth instanceof Map ? this.jumpNetwork.depth : null;
let branchOf, depth;
if (netBranchOf && netDepth) {
branchOf = netBranchOf;
depth = netDepth;
} else {
const gates = this.jumpNetwork?.gates ?? new Map();
depth = new Map([[homeId, 0]]);
branchOf = new Map(); // id → its branch-root id (a home neighbor)
const q = [homeId];
while (q.length) {
const u = q.shift();
const du = depth.get(u);
for (const v of gates.get(u) ?? []) {
if (depth.has(v)) continue;
depth.set(v, du + 1);
branchOf.set(v, du === 0 ? v : branchOf.get(u));
q.push(v);
}
}
}
const branchRoots = (this.jumpNetwork?.branches ?? [...new Set(branchOf.values())]).slice().sort();
if (branchRoots.length === 0) {
// One-system galaxy (nothing connected to home) — no branches,
// no capitals, no territories. The galaxy still generates.
this.factions = null;
return;
}
// Per-branch node lists + the branch's max depth (territory bounds).
const nodesOf = new Map(branchRoots.map((b) => [b, []]));
for (const [id, b] of branchOf) nodesOf.get(b).push(id);
const maxDepthOf = new Map();
for (const b of branchRoots) {
let m = 1;
for (const id of nodesOf.get(b)) m = Math.max(m, depth.get(id) ?? 1);
maxDepthOf.set(b, m);
}
// TERRITORY = the branch's middle systems (depth 2 … maxDepth−1):
// the depth-1 ring at home and the branch tips stay neutral.
const eligibleOf = new Map();
for (const b of branchRoots) {
eligibleOf.set(
b,
(nodesOf.get(b) ?? []).filter((id) => {
const d = depth.get(id) ?? 0;
return d >= 2 && d <= (maxDepthOf.get(b) ?? 1) - 1;
}),
);
}
// BALANCE QUOTA (no dominant faction at the start — branch sizes are
// inherently skewed, and a branch IS its faction's territory, so an
// unbalanced galaxy would crown a dominant faction): quota = the
// MEDIAN of the three branches' eligible sizes; the big branch keeps
// only its systems nearest the middle band (|d − 0.5| — the "middle
// of the galaxy" design, where the bridges sit too; index tiebreak
// for determinism) and its overflow stays NEUTRAL WILD SPACE; a small
// branch keeps everything it has (a working territory beats an empty
// one). A balanced galaxy is untouched (median = each size).
const sizes = branchRoots.map((b) => eligibleOf.get(b).length).sort((x, y) => x - y);
const quota = sizes.length ? sizes[Math.floor((sizes.length - 1) / 2)] : 0;
const index = new Map(this.records.map((r, i) => [r.id, i]));
const midRank = (id) => Math.abs((this.byId.get(id)?.d ?? 0.5) - 0.5);
const territoryOf = new Map();
for (const b of branchRoots) {
const elig = eligibleOf.get(b) ?? [];
territoryOf.set(
b,
new Set(
elig
.slice()
.sort((a, c) => midRank(a) - midRank(c) || index.get(a) - index.get(c))
.slice(0, Math.min(elig.length, quota)),
),
);
}
const inTerritory = (id, b) => territoryOf.get(b)?.has(id) ?? false;
const inBand = (id) => {
const rec = this.byId.get(id);
return !!rec && rec.d >= dMin && rec.d <= dMax;
};
const isBarren = (id) => barren instanceof Set && barren.has(id);
// NEUTRAL CAPITAL — one of home's gate neighbors (seeded pick).
const neutralId = Rng.derive(this.seed, 'factions', 'neutral').pick(branchRoots);
// Branch↔faction: a SEEDED SHUFFLE of the three factions across the
// branches (fewer than three branches ⇒ factions share one — the
// "nice to have" relaxation; their capitals still differ).
const assign = Rng.derive(this.seed, 'factions', 'assign').shuffle(FACTIONS);
const branchFaction = new Map();
branchRoots.forEach((b, i) => branchFaction.set(b, assign[i % assign.length]));
// FACTION CAPITALS — one per faction, inside its branch (seeded).
const taken = new Set([neutralId]);
const capitals = {};
for (const f of FACTIONS) {
const b = branchRoots.find((x) => branchFaction.get(x) === f);
if (!b) continue;
const nodes = nodesOf.get(b) ?? [];
const free = (ids) => ids.filter((id) => !taken.has(id) && !isBarren(id));
// Tiered candidacy: middle-band territory → any territory → any
// free branch system (tiny-branch fallback — a working capital
// beats no capital).
let cands = free(nodes).filter((id) => inTerritory(id, b) && inBand(id));
if (cands.length === 0) cands = free(nodes).filter((id) => inTerritory(id, b));
if (cands.length === 0) cands = free(nodes);
if (cands.length === 0) cands = branchRoots.filter((id) => id !== neutralId);
const capitalId = Rng.derive(this.seed, 'factions', 'capital', f).pick(cands);
if (!capitalId) continue;
const rec = this.byId.get(capitalId);
if (rec) {
rec.capital = f;
rec.faction = f; // its seat — even on a fallback outside the territory
}
taken.add(capitalId);
capitals[f] = capitalId;
}
// The neutral capital's stamp (its system stays faction-neutral).
{
const rec = this.byId.get(neutralId);
if (rec) {
rec.capital = 'neutral';
rec.faction = null;
}
}
// TERRITORIES — the branch's middle systems take its faction;
// the neutral hub is explicitly exempt, everything else untouched
// (neutral wild space).
for (const b of branchRoots) {
const f = branchFaction.get(b);
for (const id of nodesOf.get(b) ?? []) {
if (id === neutralId) continue;
if (inTerritory(id, b)) {
const rec = this.byId.get(id);
if (rec && !rec.capital) rec.faction = f; // a capital keeps its own
}
}
}
// The pass's output (UI/tests read this; the records carry the rest).
this.factions = {
neutralCapital: neutralId,
capitals,
branches: branchRoots,
branchFaction: Object.fromEntries(branchFaction),
// The BALANCE QUOTA (the median of the branches' eligible territory
// sizes) + each branch's final territory (its kept systems, sorted
// — the map's territory-region fill will read these).
quota,
territories: Object.fromEntries(branchRoots.map((b) => [b, [...(territoryOf.get(b) ?? [])].sort()])),
};
const missing = FACTIONS.filter((f) => !capitals[f]);
if (missing.length > 0) {
console.warn(`[orbit] factions: no capital placed for ${missing.join(', ')} (degenerate branch layout)`);
}
}
/**
* The home→far zones (data/galaxy.json → distribution.zones),
* validated and sorted by `d`. A single fallback zone when the config
* is missing/malformed — the galaxy still generates.
*/
_zones() {
const raw = this.params.distribution?.zones;
const out = (Array.isArray(raw) ? raw : [])
.filter(
(z) =>
z &&
typeof z.name === 'string' &&
Array.isArray(z.d) &&
z.d.length === 2 &&
Number.isFinite(z.d[0]) &&
Number.isFinite(z.d[1]) &&
z.d[1] > z.d[0],
)
.map((z) => ({ name: z.name, d: [clamp(z.d[0], 0, 1), clamp(z.d[1], 0, 1)] }))
.sort((a, b) => a.d[0] - b.d[0]);
return out.length ? out : [{ name: 'all', d: [0, 1] }];
}
/** A corner of the field (screen orientation — y DOWN). */
_cornerPoint(name, { halfW, halfH }) {
const CORNERS = { NE: [1, -1], NW: [-1, -1], SE: [1, 1], SW: [-1, 1] };
const key = String(name ?? 'SE').toUpperCase();
const [sx, sy] = CORNERS[key] ?? CORNERS.SE;
return { x: sx * halfW, y: sy * halfH };
}
/**
* Even star placement: exactly `n` points in the field
* [−halfW, halfW] × [−halfH, halfH], every pair at least `d0` apart
* (Bridson / Poisson disk). If the field can't hold `n` points at
* `d0` (dense config), retry a few times with a relaxed spacing; as a
* last resort pad with random points — a working galaxy beats a
* perfect one. Deterministic: each attempt draws from its own seeded
* fork (seed, 'layout', 'poisson', attempt).
*/
_poissonDisk(n, { halfW, halfH }, d0) {
if (n <= 0) return [];
let d = Math.max(1, d0);
let pts = null;
for (let attempt = 0; attempt < 8; attempt++) {
const rng = Rng.derive(this.seed, 'layout', 'poisson', attempt);
pts = this._bridson(n, { halfW, halfH }, d, rng);
if (pts.length >= n) break;
d *= 0.88; // not enough room — loosen the spacing and retry
}
const rng = Rng.derive(this.seed, 'layout', 'poisson', 'pad');
while (pts.length < n) {
pts.push({ x: rng.range(-halfW, halfW), y: rng.range(-halfH, halfH) });
}
return pts.slice(0, n);
}
/**
* Bridson's algorithm: grow a Poisson-disk of points in the field,
* stopping once `n` points are placed (or the frontier is exhausted).
* Pure — deterministic for a given (n, halfW, halfH, d, rng stream).
*/
_bridson(n, { halfW, halfH }, d, rng) {
if (n <= 0) return [];
const cell = d / Math.SQRT2;
const grid = new Map(); // "cx,cy" → [point, …]
const pts = [];
const active = []; // indices into pts (Bridson's active list)
const inside = (p) =>
p.x >= -halfW && p.x <= halfW && p.y >= -halfH && p.y <= halfH;
const keyOf = (p) =>
`${Math.floor((p.x + halfW) / cell)},${Math.floor((p.y + halfH) / cell)}`;
const free = (p) => {
const cx = Math.floor((p.x + halfW) / cell);
const cy = Math.floor((p.y + halfH) / cell);
for (let ax = -2; ax <= 2; ax++) {
for (let ay = -2; ay <= 2; ay++) {
const bucket = grid.get(`${cx + ax},${cy + ay}`);
if (!bucket) continue;
for (const q of bucket) {
const dx = q.x - p.x;
const dy = q.y - p.y;
if (dx * dx + dy * dy < d * d) return false;
}
}
}
return true;
};
const place = (p) => {
pts.push(p);
active.push(pts.length - 1);
const k = keyOf(p);
const bucket = grid.get(k);
if (bucket) bucket.push(p);
else grid.set(k, [p]);
};
// Seed the frontier with one random interior point.
place({ x: rng.range(-halfW, halfW), y: rng.range(-halfH, halfH) });
while (active.length > 0 && pts.length < n) {
const i = rng.int(0, active.length - 1);
const p = pts[active[i]];
let placed = false;
for (let t = 0; t < 30 && !placed; t++) {
// Sample a candidate in the annulus [d, 2d) around p.
const r = d * (1 + rng.next());
const a = rng.next() * TAU;
const q = { x: p.x + r * Math.cos(a), y: p.y + r * Math.sin(a) };
if (!inside(q) || !free(q)) continue;
place(q);
placed = true;
}
if (!placed) active.splice(i, 1); // p can never yield a neighbor
}
return pts;
}
// ------------------------------------------------------------------
// Queries
// ------------------------------------------------------------------
/**
* The k nearest systems to a world-space point (k=1 by default).
* Ring-expands the spatial hash; exact as long as k systems exist.
*/
nearest(x, y, k = 1) {
const c = this.cellSize;
const cx = Math.floor(x / c);
const cy = Math.floor(y / c);
const best = [];
const consider = (rec) => {
const d2 = (rec.x - x) ** 2 + (rec.y - y) ** 2;
best.push({ d2, record: rec });
best.sort((a, b) => a.d2 - b.d2);
if (best.length > k) best.length = k;
};
const maxRing = Math.min(1024, Math.ceil(Math.hypot(this.fieldW ?? 32000, this.fieldH ?? 16000) / c) + 1);
for (let ring = 0; ring <= maxRing; ring++) {
for (let dx = -ring; dx <= ring; dx++) {
for (let dy = -ring; dy <= ring; dy++) {
if (Math.max(Math.abs(dx), Math.abs(dy)) !== ring) continue;
const cell = this.grid.get(`${cx + dx},${cy + dy}`);
if (cell) for (const rec of cell) consider(rec);
}
}
// Everything left unscanned is at least ring·c away; if the kth
// best is already closer, the answer is final.
if (best.length >= k) {
const bound = ring * c;
if (best[k - 1].d2 <= bound * bound) break;
}
}
return best.slice(0, k).map((e) => e.record);
}
/** The k nearest OTHER systems to a system (jump-range candidate list). */
neighborsOf(id, k = null) {
const rec = this.byId.get(id);
if (!rec) throw new Error(`Unknown system "${id}"`);
const need = k ?? Math.floor(this.params.neighbors ?? 8);
const out = [];
for (const cand of this.nearest(rec.x, rec.y, need + 1)) {
if (cand.id !== id) out.push(cand);
if (out.length >= need) break;
}
return out;
}
/**
* The other systems this system's jump gates jump to — the star
* RECORDS ({ id, name, x, y }), ordered with the "road home" (parent)
* edge first, then the local shortcuts. minGates–maxGates entries for
* n > 1 (data/gates.json); empty for a one-system galaxy.
*/
jumpGatesFor(id) {
const rec = this.byId.get(id);
if (!rec) throw new Error(`Unknown system "${id}"`);
return (this.jumpNetwork.gates.get(id) ?? []).map((tid) => this.byId.get(tid)).filter(Boolean);
}
/** @returns {object} the player's current (starting) system record */
currentSystem() {
return this.byId.get(this.currentSystemId) ?? this.records[0];
}
/** True when the system is the player's STARTING one — the only system
* whose central body is the home world (a star, in every other). Stable
* across jumps, unlike a `currentSystemId` comparison. */
isHomeSystem(id) {
return id === this.homeSystemId;
}
// ------------------------------------------------------------------
// Contents (level 2, lazy)
// ------------------------------------------------------------------
/**
* Generate (once) and return a system's full contents. Safe to call from
* "the player arrived here" — the result is identical to what an
* up-front generateAll() would have produced.
*/
ensureContent(id) {
const cached = this.contentCache.get(id);
if (cached) return cached;
const record = this.byId.get(id);
if (!record) throw new Error(`Unknown system "${id}"`);
const content = generateSystemContent(this, record);
this.contentCache.set(id, content);
return content;
}
/** Alias of ensureContent() — reads nicer at call sites. */
contentOf(id) {
return this.ensureContent(id);
}
/**
* Eager mode: generate every system now. Deterministically identical to
* lazy generation (per-system seeded streams) — use it if profiling ever
* shows "just do it all at once" is fine.
*/
generateAll() {
for (const rec of this.records) this.ensureContent(rec.id);
return this;
}
get generatedCount() {
return this.contentCache.size;
}
/**
* The NEUTRAL CAPITAL as a destination target (data/factions.json —
* the faction pass's designation): the system id + the capital world's
* object id. The OBJECT vocabulary is the map's discoveryId (js/scenes/
* GameScene.js: planets carry `discoveryId = rec.name`) — the same id
* MapWindow's SET DESTINATION passes and the arrival/compass/plate
* lookups read, so the capital world is the final stop the same way a
* clicked planet would be. The capital world is the system's OUTERMOST
* planet (js/galaxy/SystemGenerator.js converts it: `p.capital`). The
* 'Leaving the Nest' quest's last brief (data/comms.json:
* trestle-brief-3 — Trestle "sends the coordinates") hands this to the
* scene's setDestination on acknowledgment (js/scenes/GameScene.js →
* _commEnded).
* @returns {{systemId: string, objectId: (string|null)}|null} null when
* the faction pass never designated one (degenerate one-system galaxy)
*/
neutralCapitalTarget() {
const sysId = this.factions?.neutralCapital;
if (!sysId || !this.byId.has(sysId)) return null;
const content = this.ensureContent(sysId);
const cap = (content.planets ?? []).find((p) => p.capital) ?? null;
return { systemId: sysId, objectId: cap ? cap.name : null };
}
// ------------------------------------------------------------------
/** Debug/console summary. */
summary() {
const byType = {};
for (const r of this.records) byType[r.type] = (byType[r.type] ?? 0) + 1;
const byZone = {};
for (const r of this.records) byZone[r.zone] = (byZone[r.zone] ?? 0) + 1;
return {
name: this.name,
seed: this.seed,
systems: this.records.length,
byType,
byZone,
generated: this.generatedCount,
currentSystemId: this.currentSystemId,
// FACTIONS (the faction pass — data/factions.json): branch map +
// the four capital system ids (a null capital = placement failed
// for that faction, logged at roster build).
factions: this.factions ? {
neutralCapital: this.factions.neutralCapital,
capitals: this.factions.capitals,
branches: this.factions.branches,
branchFaction: this.factions.branchFaction,
quota: this.factions.quota,
territories: this.factions.territories,
} : null,
jumpNetworkEdges: this.jumpNetwork ? [...this.jumpNetwork.gates.values()].reduce((n, t) => n + t.length, 0) : null,
jumpNetworkBridges: this.jumpNetwork?.bridges ?? [],
};
}
}