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|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 ?? [], }; } }