831 lines
37 KiB
JavaScript
831 lines
37 KiB
JavaScript
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 ?? [],
|
||
};
|
||
}
|
||
}
|