diff --git a/src/sim/galaxy.js b/src/sim/galaxy.js new file mode 100644 index 0000000..dacce84 --- /dev/null +++ b/src/sim/galaxy.js @@ -0,0 +1,158 @@ +import { makeRng, hash32 } from './rng.js'; + +const SYSTEM_NAMES = [ + 'Arcadia', 'Boreas', 'Cygni', 'Drakon', 'Eridu', 'Fenrir', 'Gorgon', 'Helix', + 'Icarus', 'Juno', 'Kepler', 'Lyra', 'Mira', 'Nyx', 'Orion', 'Pavo', + 'Quasar', 'Rigel', 'Sirius', 'Talos', 'Umbra', 'Vega', 'Wraith', 'Xandar', + 'Yara', 'Zenith', 'Altair', 'Bellatrix', 'Castor', 'Deneb', +]; +const PLANET_SUFFIX = ['Prime', 'II', 'III']; + +const PROFILES = [ + { kind: 'agri', produces: { food: 10, meds: 3 }, consumes: { fuel: 4, tech: 3 } }, + { kind: 'mining', produces: { ore: 12, fuel: 6 }, consumes: { food: 6, meds: 2 } }, + { kind: 'industrial', produces: { tech: 6 }, consumes: { ore: 8, fuel: 5, food: 5 } }, + { kind: 'refinery', produces: { fuel: 10 }, consumes: { ore: 6, food: 4 } }, + { kind: 'resort', produces: { luxury: 5 }, consumes: { food: 7, meds: 4, fuel: 3 } }, +]; +const ALL_GOODS = ['ore', 'food', 'fuel', 'tech', 'meds', 'luxury']; + +export function otherEnd(lane, systemId) { + return lane.a === systemId ? lane.b : lane.a; +} + +export function generateGalaxy(seed, { systemCount = 25 } = {}) { + const rng = makeRng(hash32('galaxy:' + seed)); + + // 1. Place systems with minimum spacing (relaxes if placement gets tight). + const systems = []; + let minDist = 95, attempts = 0; + while (systems.length < systemCount) { + const x = 60 + rng() * 880, y = 50 + rng() * 600; + if (systems.every((s) => Math.hypot(s.x - x, s.y - y) > minDist)) { + systems.push({ id: systems.length, name: SYSTEM_NAMES[systems.length % SYSTEM_NAMES.length], x, y, planets: [], lanes: [] }); + } + if (++attempts > 2000) { minDist *= 0.9; attempts = 0; } + } + + // 2. Lanes: each system to its 2 nearest neighbors, deduplicated. + const laneKeys = new Set(); + const key = (a, b) => (a < b ? `${a}-${b}` : `${b}-${a}`); + for (const s of systems) { + const near = systems + .filter((o) => o.id !== s.id) + .sort((p, q) => Math.hypot(p.x - s.x, p.y - s.y) - Math.hypot(q.x - s.x, q.y - s.y)); + laneKeys.add(key(s.id, near[0].id)); + laneKeys.add(key(s.id, near[1].id)); + } + + // 3. Connectivity repair: link closest pair across components until one component. + const parent = systems.map((_, i) => i); + const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i]))); + const union = (a, b) => { parent[find(a)] = find(b); }; + for (const k of laneKeys) { const [a, b] = k.split('-').map(Number); union(a, b); } + for (;;) { + const roots = new Set(systems.map((s) => find(s.id))); + if (roots.size === 1) break; + let best = null; + for (const s of systems) for (const o of systems) { + if (find(s.id) === find(o.id)) continue; + const d = Math.hypot(s.x - o.x, s.y - o.y); + if (!best || d < best.d) best = { a: s.id, b: o.id, d }; + } + laneKeys.add(key(best.a, best.b)); + union(best.a, best.b); + } + + // 4. Materialize lanes with travel time and hazard. + const lanes = [...laneKeys].sort().map((k, i) => { + const [a, b] = k.split('-').map(Number); + const dist = Math.hypot(systems[a].x - systems[b].x, systems[a].y - systems[b].y); + const days = Math.min(4, Math.max(1, Math.round(dist / 80))); + const hazard = rng() < 0.2 ? +(0.3 + rng() * 0.4).toFixed(3) : +(rng() * 0.15).toFixed(3); + return { id: i, a, b, days, hazard }; + }); + for (const lane of lanes) { + systems[lane.a].lanes.push(lane.id); + systems[lane.b].lanes.push(lane.id); + } + + // 5. Planets with economy profiles and a render orbit. + const planets = []; + for (const s of systems) { + const n = 1 + Math.floor(rng() * 3); + for (let i = 0; i < n; i++) { + const profile = PROFILES[Math.floor(rng() * PROFILES.length)]; + const stock = {}; + for (const g of ALL_GOODS) { + if (profile.produces[g]) stock[g] = Math.floor(150 + rng() * 150); + else if (profile.consumes[g]) stock[g] = Math.floor(40 + rng() * 80); + else stock[g] = Math.floor(80 + rng() * 70); + } + const planet = { + id: planets.length, systemId: s.id, + name: `${s.name} ${PLANET_SUFFIX[i]}`, kind: profile.kind, + produces: { ...profile.produces }, consumes: { ...profile.consumes }, + stock, stockTime: 0, + orbit: { r: 90 + i * 55, a: rng() * Math.PI * 2 }, + }; + planets.push(planet); + s.planets.push(planet.id); + } + } + + return { systems, lanes, planets }; +} + +// All-pairs hop counts (BFS per system). hops[a][b] = lanes to cross. +export function hopMatrix(galaxy) { + const n = galaxy.systems.length; + const m = Array.from({ length: n }, () => Array(n).fill(Infinity)); + for (let src = 0; src < n; src++) { + m[src][src] = 0; + const queue = [src]; + while (queue.length) { + const cur = queue.shift(); + for (const laneId of galaxy.systems[cur].lanes) { + const next = otherEnd(galaxy.lanes[laneId], cur); + if (m[src][next] === Infinity) { + m[src][next] = m[src][cur] + 1; + queue.push(next); + } + } + } + } + return m; +} + +// Dijkstra by travel days. Returns { lanes: [laneId...], days } or null. +export function findRoute(galaxy, fromSys, toSys) { + if (fromSys === toSys) return { lanes: [], days: 0 }; + const dist = new Map([[fromSys, 0]]); + const prevLane = new Map(); + const visited = new Set(); + for (;;) { + let cur = null, best = Infinity; + for (const [s, d] of dist) if (!visited.has(s) && d < best) { best = d; cur = s; } + if (cur === null) return null; + if (cur === toSys) break; + visited.add(cur); + for (const laneId of galaxy.systems[cur].lanes) { + const lane = galaxy.lanes[laneId]; + const next = otherEnd(lane, cur); + const nd = best + lane.days; + if (nd < (dist.get(next) ?? Infinity)) { + dist.set(next, nd); + prevLane.set(next, laneId); + } + } + } + const laneIds = []; + let s = toSys; + while (s !== fromSys) { + const laneId = prevLane.get(s); + laneIds.unshift(laneId); + s = otherEnd(galaxy.lanes[laneId], s); + } + return { lanes: laneIds, days: dist.get(toSys) }; +} diff --git a/tests/galaxy.test.js b/tests/galaxy.test.js new file mode 100644 index 0000000..15b629c --- /dev/null +++ b/tests/galaxy.test.js @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { generateGalaxy, hopMatrix, findRoute, otherEnd } from '../src/sim/galaxy.js'; +import { GOODS } from '../src/sim/constants.js'; + +describe('galaxy', () => { + const g = generateGalaxy('test-seed', { systemCount: 25 }); + + it('is deterministic for the same seed', () => { + const g2 = generateGalaxy('test-seed', { systemCount: 25 }); + expect(JSON.stringify(g2)).toBe(JSON.stringify(g)); + const g3 = generateGalaxy('other-seed', { systemCount: 25 }); + expect(JSON.stringify(g3)).not.toBe(JSON.stringify(g)); + }); + + it('has the requested systems, each with 1-3 planets and at least one lane', () => { + expect(g.systems).toHaveLength(25); + for (const s of g.systems) { + expect(s.planets.length).toBeGreaterThanOrEqual(1); + expect(s.planets.length).toBeLessThanOrEqual(3); + expect(s.lanes.length).toBeGreaterThanOrEqual(1); + } + }); + + it('is fully connected (every system reachable from system 0)', () => { + const hops = hopMatrix(g); + for (let i = 0; i < g.systems.length; i++) { + expect(hops[0][i]).toBeLessThan(Infinity); + } + }); + + it('lanes have sane travel days and hazard, and back-reference systems', () => { + for (const lane of g.lanes) { + expect(lane.days).toBeGreaterThanOrEqual(1); + expect(lane.days).toBeLessThanOrEqual(4); + expect(lane.hazard).toBeGreaterThanOrEqual(0); + expect(lane.hazard).toBeLessThanOrEqual(0.7); + expect(g.systems[lane.a].lanes).toContain(lane.id); + expect(g.systems[lane.b].lanes).toContain(lane.id); + } + }); + + it('planets have profiles, stocks for every good, and an orbit for rendering', () => { + for (const p of g.planets) { + expect(Object.keys(p.produces).length).toBeGreaterThan(0); + expect(Object.keys(p.consumes).length).toBeGreaterThan(0); + for (const good of GOODS) expect(p.stock[good]).toBeGreaterThanOrEqual(0); + expect(p.stockTime).toBe(0); + expect(p.orbit.r).toBeGreaterThan(0); + } + }); + + it('findRoute returns a connected lane path with summed days', () => { + const from = 0, to = g.systems.length - 1; + const route = findRoute(g, from, to); + expect(route).not.toBeNull(); + let sys = from, days = 0; + for (const laneId of route.lanes) { + const lane = g.lanes[laneId]; + expect([lane.a, lane.b]).toContain(sys); + sys = otherEnd(lane, sys); + days += lane.days; + } + expect(sys).toBe(to); + expect(route.days).toBeCloseTo(days, 5); + expect(findRoute(g, 3, 3)).toEqual({ lanes: [], days: 0 }); + }); +});