orbit/js/galaxy/Galaxy.js

964 lines
43 KiB
JavaScript
Raw Permalink 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 { assignPlanetClasses } from './PlanetClasses.js';
import { assignStationFrames } from './StationFrames.js';
import { generateSystemContent, rollSystemComposition, settlementDensity } from './SystemGenerator.js';
import { buildEconomy } from '../economy/Economy.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.planetClasses = null; // id → [class, ...] — the even-mix deal (or null, balance off)
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);
// PLANET CLASS BALANCE (js/galaxy/PlanetClasses.js): the galaxy-wide
// even-mix pass — the non-home planets are dealt from ONE shared deck
// (even per-class quotas ± the star-mix tilt; each system draws its
// worlds weighted by its star type's table), so every class lands at
// ~1/N of the galaxy while the star type still flavors local systems.
// Stamped before the frame pass (the frames index the classes) and
// read back by the content generator + the economy pass — lazy ===
// eager, every subsystem sees the same worlds. NULL when
// planetClassBalance.enabled === false (callers keep the old
// per-system weighted draws).
this.planetClasses = assignPlanetClasses({
seed: this.seed,
records,
homeId: this.currentSystemId,
params: this.params,
});
// 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),
planetClasses: this.planetClasses,
});
this.planetFrames = frames; // Map id → [frame, ...] (ordinal order)
this.homeWorldFrame = homeFrame;
// STATION VARIANT SPREAD (js/galaxy/StationFrames.js): each system's
// deep-space stations (up to two) wear spacestations.png variants
// (data/stations.json → variants) — a 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)
// TRADE ECONOMY (js/economy/Economy.js): the galaxy-level trade map —
// per-system economic classes + each good's hop-distance to its nearest
// producer. A pure function of the seed (it re-derives the same
// composition roll the content generator uses), so it's lazy-safe and
// save-free; the price engine (Economy.buyPrice/sellPrice/shopFor) reads
// it to fix each shop's prices. Distance is enforced by regional
// availability — a good is cheap only in its home region — not by a live
// per-trade multiplier (docs/ECONOMY.md §5).
buildEconomy(this);
}
/**
* 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 (data/factions.json → balance) — fair, not equal. Two rules:
* CAP — 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 branches'
* eligible sizes. The big branch's overflow stays neutral wild
* space (no dominant faction).
* FLOOR — a faction whose own branch is too small to reach
* balance.minTerritory absorbs systems that would otherwise stay
* neutral (first the branches' eligible overflow, then the richest
* faction's least-core systems — the nearest ones, so the territory
* stays contiguous). A tiny galaxy with too few eligible systems is
* best-effort (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 (data/factions.json → balance) — fair, not equal:
// CAP — quota = the MEDIAN of the 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 (no dominant faction);
// FLOOR — minTerritory: a faction whose own branch is too small is
// lifted up out of the neutral wild space (the floor pass below —
// a working territory beats a one-system sliver).
const balanceCfg = config.get('factions.balance', {});
const minTerritory = Math.max(1, Math.floor(Number(balanceCfg.minTerritory) || 5));
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 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);
// Branch↔faction: a SEEDED SHUFFLE of the three factions across the
// branches — a faction's branch is its HOME territory (the floor pass
// below can lend it neighboring space). Fewer than three branches ⇒ a
// faction's home branch is null and its territory is built entirely
// from borrowed space (the "nice to have" relaxation; its capital
// still lands in its own territory).
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]));
const factionBranch = new Map(
FACTIONS.map((f) => [f, branchRoots.find((b) => branchFaction.get(b) === f) ?? null]),
);
// BASE TERRITORY (per FACTION): the faction's home branch keeps its
// min(eligible, quota) systems closest to the middle band.
const territoryOf = new Map(FACTIONS.map((f) => [f, new Set()]));
for (const f of FACTIONS) {
const b = factionBranch.get(f);
if (!b) continue;
const elig = eligibleOf.get(b) ?? [];
territoryOf.set(
f,
new Set(
elig
.slice()
.sort((a, c) => midRank(a) - midRank(c) || index.get(a) - index.get(c))
.slice(0, Math.min(elig.length, quota)),
),
);
}
// FLOOR PASS (balance.minTerritory — no faction left with a sliver):
// a faction below the floor absorbs systems that would otherwise stay
// NEUTRAL, in this order —
// 1. eligible OVERFLOW of any branch (eligible but unowned — the
// branch with the most of it, then the system nearest the
// recipient, so the region stays contiguous);
// 2. the richest faction still above the floor (its system farthest
// from the middle band — the least core — nearest the recipient).
// Deterministic (branch/faction order + index tiebreaks); a tiny
// galaxy with too few eligible systems is best-effort (the loop stops
// when there is nothing left to give).
const owned = new Set();
for (const f of FACTIONS) for (const id of territoryOf.get(f)) owned.add(id);
const centroid = (ids) => {
let x = 0, y = 0, n = 0;
for (const id of ids) {
const rec = this.byId.get(id);
if (rec) { x += rec.x; y += rec.y; n += 1; }
}
return n > 0 ? { x: x / n, y: y / n } : null;
};
const dist2 = (id, c) => {
const rec = this.byId.get(id);
if (!rec || !c) return 0;
const dx = rec.x - c.x;
const dy = rec.y - c.y;
return dx * dx + dy * dy;
};
const distPts = (p, q) => {
if (!p || !q) return Infinity;
const dx = p.x - q.x;
const dy = p.y - q.y;
return dx * dx + dy * dy;
};
const allEligible = branchRoots.flatMap((b) => eligibleOf.get(b) ?? []);
for (const f of FACTIONS) {
const terr = territoryOf.get(f);
while (terr.size < minTerritory) {
const anchor = terr.size > 0
? centroid([...terr])
: centroid(factionBranch.get(f) ? (nodesOf.get(factionBranch.get(f)) ?? []) : allEligible);
// 1) The biggest unowned eligible overflow, nearest the recipient.
let best = null; // { count, dist, free }
for (const b of branchRoots) {
const free = (eligibleOf.get(b) ?? []).filter((id) => !owned.has(id));
if (free.length === 0) continue;
const dist = distPts(centroid(free), anchor);
if (!best || best.count < free.length || (best.count === free.length && best.dist > dist)) {
best = { count: free.length, dist, free };
}
}
if (best) {
const pickId = best.free
.slice()
.sort((a, c) => dist2(a, anchor) - dist2(c, anchor) || index.get(a) - index.get(c))[0];
terr.add(pickId);
owned.add(pickId);
continue;
}
// 2) Nothing unowned left — the richest faction still above the
// floor lends its least-core system, nearest the recipient.
let donor = null; // { faction, size, dist }
for (const d of FACTIONS) {
if (d === f) continue;
const t = territoryOf.get(d);
if (t.size <= minTerritory) continue;
const dist = distPts(centroid([...t]), anchor);
if (!donor || donor.size < t.size || (donor.size === t.size && donor.dist > dist)) {
donor = { faction: d, size: t.size, dist };
}
}
if (!donor) break; // nothing left to give — best effort
const donorTerr = territoryOf.get(donor.faction);
const pickId = [...donorTerr]
.sort((a, c) => midRank(c) - midRank(a) || dist2(a, anchor) - dist2(c, anchor) || index.get(a) - index.get(c))[0];
donorTerr.delete(pickId);
terr.add(pickId);
// `owned` is unchanged — the system stays owned, just re-owned.
}
}
// NEUTRAL CAPITAL — one of home's gate neighbors (seeded pick).
const neutralId = Rng.derive(this.seed, 'factions', 'neutral').pick(branchRoots);
// FACTION CAPITALS — one per faction, inside its (post-floor) territory
// (seeded): a faction built from borrowed space still gets its seat in
// its own territory.
const taken = new Set([neutralId]);
const capitals = {};
for (const f of FACTIONS) {
const b = factionBranch.get(f);
const nodes = b ? (nodesOf.get(b) ?? []) : [];
const free = (ids) => ids.filter((id) => !taken.has(id) && !isBarren(id));
const terr = [...territoryOf.get(f)];
// Tiered candidacy: middle-band territory → any territory → any free
// home-branch system (tiny-branch fallback — a working capital beats
// no capital).
let cands = free(terr).filter((id) => inBand(id));
if (cands.length === 0) cands = free(terr);
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 — every system in a faction's final territory takes its
// faction (its capital already carries the stamp); the neutral hub is
// explicitly exempt; everything else stays NEUTRAL WILD SPACE.
for (const f of FACTIONS) {
for (const id of territoryOf.get(f)) {
if (id === neutralId) continue;
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),
// BALANCE — the CAP (median quota) + the FLOOR (min territory):
quota,
minTerritory,
// PER-FACTION final territories (the floor pass can cross branches —
// this is the truth; `territories` below is the branch view).
factionTerritories: Object.fromEntries(FACTIONS.map((f) => [f, [...territoryOf.get(f)].sort()])),
// Each branch's systems that ended up in a territory (its home
// faction's kept systems, minus any it lent out — sorted).
territories: Object.fromEntries(
branchRoots.map((b) => [b, (nodesOf.get(b) ?? []).filter((id) => owned.has(id)).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 ?? [],
};
}
}