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; 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: assign each system a faction id (or null = wild space) * at roster time — the record is the seam (reputation's `owner` * resolution and the galaxy plate's `faction: null` are reserved). * - 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. const name = NameGenerator.star(Rng.derive(this.seed, 'name', id)); const rec = { id, name, type, x: p.x, y: p.y, d, zone }; 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; // 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 PURE SPANNING TREE of the nearest-star graph (no // shortcuts — data/gates.json → shortcuts:false) — the galaxy reads as // a MAZE: exactly one route between any two systems, no closed loops. // 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 tree degree); a BARREN system is // a dead-end LEAF — exactly one gate (in and out the same way). // 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, }); // 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`); } // 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 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; } // ------------------------------------------------------------------ /** 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, }; } }