feat: seeded galaxy generation with lanes, hazards, routing

This commit is contained in:
2026-06-12 14:06:10 +00:00
parent 8e38d4c158
commit d712f9bc23
2 changed files with 225 additions and 0 deletions
+158
View File
@@ -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) };
}
+67
View File
@@ -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 });
});
});