import { config } from '../config/Config.js'; import { Rng } from '../utils/Rng.js'; import { NameGenerator } from '../utils/NameGenerator.js'; import { generateSystemContent } from './SystemGenerator.js'; const TAU = Math.PI * 2; const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); const wrapPI = (a) => { const t = (a + Math.PI) % TAU; return (t < 0 ? t + TAU : t) - Math.PI; }; /** * 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 }). * This is cheap: ~40,000 systems is a fraction of a second and a few * MB. 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". * * 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): * - data/galaxy.json `distribution.rules[]` — proximity/clustering rules * (e.g. "void systems cluster in the outer rim", faction borders). * Read in _generate(); today only per-type weight + radiusBand * (from data/systems.json) apply. * - more layout knobs in data/galaxy.json `layout`. */ 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; 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 ?? 40000); 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 g = Rng.derive(this.seed, 'layout'); const L = this.params.layout ?? {}; const R = Math.max(1, this.params.radius ?? 20000); const flatten = clamp(L.flatten ?? 0.62, 0.05, 1); // Spiral arms (optional; `enabled: false` or strength 0 = off). const spiral = L.spiral ?? {}; const arms = spiral.enabled === true ? Math.max(0, Math.floor(spiral.arms ?? 0)) : 0; const twist = spiral.twist ?? 2.5; const strength = clamp(spiral.strength ?? 0.5, 0, 1); const armPhase = g.next() * TAU; // one phase for the whole galaxy // Type selection: weights from data/systems.json (distribution.weight). // FUTURE: galaxy.distribution.rules[] proximity/clustering rules hook in // here, before position sampling. const typeIds = Object.keys(this.typeDefs); const typeWeights = {}; for (const id of typeIds) { typeWeights[id] = Math.max(0, this.typeDefs[id].distribution?.weight ?? 1); } let centerId = null; let centerD2 = Infinity; const records = this.records; for (let i = 1; i <= count; i++) { const id = `S${String(i).padStart(6, '0')}`; const type = g.weighted(typeWeights, typeIds[0]); // Radius: a center-weighted shape sample, re-anchored into the // type's radial band (the first "proximity" rule: e.g. void systems // live out in the rim, habitable ones in the mid-galaxy). const shape = this._sampleShape(g, L); // [0,1], dense toward center const band = this.typeDefs[type]?.distribution?.radiusBand; const rNorm = Array.isArray(band) && band.length === 2 ? clamp(band[0] + (band[1] - band[0]) * shape, 0, 1) : shape; let theta = g.next() * TAU; if (arms >= 2) { theta = this._snapToArm(theta, rNorm, armPhase, arms, twist, strength); } const r = R * rNorm; const x = r * Math.cos(theta); const y = r * Math.sin(theta) * flatten; const d2 = x * x + y * y; if (d2 < centerD2) { centerD2 = d2; centerId = id; } // Name comes from a per-record fork so roster generation order can // never leak into it. rNorm (0 = galactic center, 1 = rim) is kept on // the record: the settlement generator uses it (core→rim density), // and it's handy for any future "where am I in the galaxy" rules. const name = NameGenerator.star(Rng.derive(this.seed, 'name', id)); const rec = { id, name, type, x, y, rNorm }; records.push(rec); this.byId.set(id, rec); } // Starting system (the player's home port). const policy = this.params.startingSystem?.policy ?? 'center'; this.currentSystemId = policy === 'random' ? `S${String(g.int(1, count)).padStart(6, '0')}` : (centerId ?? records[0]?.id); // Spatial hash for fast neighbor queries (jump ranges, proximity rules, // the eventual star map). const area = Math.PI * R * R * flatten; 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); } } /** Center-weighted radius sample in [0,1]: core bulge + disk. */ _sampleShape(g, L) { if (g.chance(L.coreFraction ?? 0.25)) { const sigma = Math.max(0.01, L.bulgeSigma ?? 0.09); return Math.min(1, Math.abs(g.normal(0, sigma))); } return Math.min(1, Math.pow(g.next(), Math.max(0.1, L.diskSkew ?? 1.7))); } /** Ease `theta` toward the nearest spiral arm (by `strength`). */ _snapToArm(theta, rNorm, armPhase, arms, twist, strength) { if (strength <= 0) return theta; const step = TAU / arms; const base = armPhase + twist * rNorm; const k = Math.floor((((theta - base) % TAU) + TAU) % TAU / step); let bestD = Infinity; let bestA = base + k * step; for (const cand of [k - 1, k, k + 1]) { const a = base + cand * step; const d = Math.abs(wrapPI(a - theta)); if (d < bestD) { bestD = d; bestA = a; } } return theta + wrapPI(bestA - theta) * strength; } // ------------------------------------------------------------------ // 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((4 * this.params.radius) / 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; } /** @returns {object} the player's current (starting) system record */ currentSystem() { return this.byId.get(this.currentSystemId) ?? this.records[0]; } // ------------------------------------------------------------------ // 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; return { name: this.name, seed: this.seed, systems: this.records.length, byType, generated: this.generatedCount, currentSystemId: this.currentSystemId, }; } }