449 lines
16 KiB
JavaScript
449 lines
16 KiB
JavaScript
// Master of Vega — galaxy generation. Headless and fully deterministic: the
|
|
// same (size, shape, seed, species list) always produces a byte-identical
|
|
// galaxy, which is what lets the verifier soak self-play games reproducibly.
|
|
//
|
|
// Distances are in PARSECS. Pixels are only ever a rendering concern, so the
|
|
// engine's fuel-range maths never has to know how big the star map is drawn.
|
|
|
|
export const PARSEC_PX = 90;
|
|
|
|
// Standalone mulberry32 — generation happens once, before there is a game
|
|
// state to carry an RNG cursor in, so this one is closure-based on purpose.
|
|
// VegaLogic has its own explicit-state variant for in-game randomness.
|
|
export function mulberry32(seed) {
|
|
let a = seed >>> 0;
|
|
return function next() {
|
|
a = (a + 0x6d2b79f5) | 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
|
|
function weightedPick(rnd, list, weightOf) {
|
|
let total = 0;
|
|
for (const item of list) total += Math.max(0, weightOf(item));
|
|
if (total <= 0) return list[0];
|
|
let r = rnd() * total;
|
|
for (const item of list) {
|
|
r -= Math.max(0, weightOf(item));
|
|
if (r <= 0) return item;
|
|
}
|
|
return list[list.length - 1];
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Star placement
|
|
|
|
// Each shape returns a candidate point in [0,w]x[0,h]. Rejection against a
|
|
// minimum separation happens in the caller, so a shape only has to describe
|
|
// where stars *want* to be.
|
|
function samplePoint(rnd, shape, w, h, clusterCenters) {
|
|
const cx = w / 2;
|
|
const cy = h / 2;
|
|
const rx = w / 2;
|
|
const ry = h / 2;
|
|
|
|
if (shape.id === 'spiral') {
|
|
const arms = Math.max(1, shape.arms ?? 2);
|
|
const arm = Math.floor(rnd() * arms);
|
|
// t biased outward so the core does not swallow every star.
|
|
const t = Math.sqrt(rnd());
|
|
const spin = 2.4;
|
|
const ang = t * spin * Math.PI + (arm * 2 * Math.PI) / arms;
|
|
// Jitter widens with radius, giving the arms a realistic feathered edge.
|
|
const spread = 0.10 + 0.13 * t;
|
|
const jr = (rnd() + rnd() + rnd() - 1.5) * spread;
|
|
const ja = (rnd() + rnd() + rnd() - 1.5) * spread * 1.6;
|
|
const r = Math.min(1, t + jr);
|
|
return { x: cx + Math.cos(ang + ja) * r * rx * 0.94, y: cy + Math.sin(ang + ja) * r * ry * 0.94 };
|
|
}
|
|
|
|
if (shape.id === 'ring') {
|
|
const r = 0.56 + rnd() * 0.42;
|
|
const ang = rnd() * Math.PI * 2;
|
|
const jitter = (rnd() + rnd() - 1) * 0.05;
|
|
return { x: cx + Math.cos(ang) * (r + jitter) * rx * 0.94, y: cy + Math.sin(ang) * (r + jitter) * ry * 0.94 };
|
|
}
|
|
|
|
if (shape.id === 'cluster') {
|
|
const c = clusterCenters[Math.floor(rnd() * clusterCenters.length)];
|
|
// Box-Muller would be cleaner but three uniforms is plenty and keeps the
|
|
// RNG cursor advancing a fixed number of steps per attempt.
|
|
const gx = (rnd() + rnd() + rnd() - 1.5) * 0.9;
|
|
const gy = (rnd() + rnd() + rnd() - 1.5) * 0.9;
|
|
return { x: cx + (c.x + gx * c.r) * rx * 0.94, y: cy + (c.y + gy * c.r) * ry * 0.94 };
|
|
}
|
|
|
|
// elliptical — uniform over the disc, slightly centre-weighted
|
|
const r = Math.sqrt(rnd()) * 0.94;
|
|
const ang = rnd() * Math.PI * 2;
|
|
return { x: cx + Math.cos(ang) * r * rx, y: cy + Math.sin(ang) * r * ry };
|
|
}
|
|
|
|
function placeStars(rnd, shape, count, w, h) {
|
|
const clusterCenters = [];
|
|
if (shape.id === 'cluster') {
|
|
const n = shape.clusters ?? 5;
|
|
for (let i = 0; i < n; i += 1) {
|
|
const ang = (i / n) * Math.PI * 2 + rnd() * 0.5;
|
|
const rad = 0.30 + rnd() * 0.45;
|
|
clusterCenters.push({ x: Math.cos(ang) * rad, y: Math.sin(ang) * rad, r: 0.16 + rnd() * 0.10 });
|
|
}
|
|
}
|
|
|
|
// Target separation from the area each star "owns", relaxed on repeated
|
|
// failure so a tight shape can never hang the generator.
|
|
const area = w * h;
|
|
let minSep = Math.sqrt(area / count) * 0.62;
|
|
const pts = [];
|
|
let attempts = 0;
|
|
const maxAttempts = count * 400;
|
|
while (pts.length < count && attempts < maxAttempts) {
|
|
attempts += 1;
|
|
const p = samplePoint(rnd, shape, w, h, clusterCenters);
|
|
if (p.x < 40 || p.y < 40 || p.x > w - 40 || p.y > h - 40) continue;
|
|
let ok = true;
|
|
for (const q of pts) {
|
|
if (Math.hypot(p.x - q.x, p.y - q.y) < minSep) { ok = false; break; }
|
|
}
|
|
if (ok) pts.push(p);
|
|
else if (attempts % (count * 8) === 0) minSep *= 0.93;
|
|
}
|
|
// Last-resort top-up: relax entirely rather than return a short galaxy.
|
|
while (pts.length < count) {
|
|
const p = samplePoint(rnd, shape, w, h, clusterCenters);
|
|
pts.push({ x: Math.max(40, Math.min(w - 40, p.x)), y: Math.max(40, Math.min(h - 40, p.y)) });
|
|
}
|
|
// Deterministic ordering regardless of acceptance order.
|
|
pts.sort((a, b) => (a.y - b.y) || (a.x - b.x));
|
|
return pts;
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Planets
|
|
|
|
function rollPlanets(rnd, rules, starClass) {
|
|
if (starClass.special === 'blackhole') return [];
|
|
|
|
let maxPlanets = 5;
|
|
if (starClass.special === 'pulsar') maxPlanets = 2;
|
|
else if (starClass.id === 'brown') maxPlanets = 2;
|
|
else if (starClass.id === 'red') maxPlanets = 3;
|
|
const count = Math.floor(rnd() * (maxPlanets + 1));
|
|
|
|
const bias = starClass.planetBias ?? 0;
|
|
const planets = [];
|
|
for (let orbit = 0; orbit < count; orbit += 1) {
|
|
// Roughly 40% of slots are uninhabitable scenery — gas giants and belts
|
|
// are common in real systems and give the orrery something to draw.
|
|
const uninhabitable = rules.planetTypeList.filter((p) => !p.colonizable);
|
|
const habitable = rules.colonizableTypes;
|
|
let type;
|
|
if (rnd() < 0.38 && uninhabitable.length) {
|
|
type = uninhabitable[Math.floor(rnd() * uninhabitable.length)];
|
|
} else {
|
|
// Good worlds are rarer than bad ones; planetBias tilts the whole ladder.
|
|
type = weightedPick(rnd, habitable, (p) => {
|
|
const base = 14 - 8 * p.habitability;
|
|
return base * Math.max(0.02, 1 + 0.5 * bias * (p.habitability - 0.55));
|
|
});
|
|
}
|
|
const size = weightedPick(rnd, rules.planetSizeList, (s) => s.weight);
|
|
const rich = weightedPick(rnd, rules.richnessList, (m) => m.weight * (1 + 0.25 * (starClass.richBias ?? 0)));
|
|
const grav = weightedPick(rnd, rules.gravityList, (g) => g.weight);
|
|
|
|
planets.push({
|
|
orbit,
|
|
typeId: type.id,
|
|
sizeId: size.id,
|
|
richId: rich.id,
|
|
gravId: grav.id,
|
|
basePop: Math.round(size.basePop * type.habitability),
|
|
// Orrery presentation, generated here so it is stable across reloads.
|
|
orbitRadius: 46 + orbit * 30 + Math.floor(rnd() * 12),
|
|
orbitAngle: rnd() * Math.PI * 2,
|
|
orbitSpeed: (0.30 - orbit * 0.045) * (0.85 + rnd() * 0.3),
|
|
});
|
|
}
|
|
return planets;
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Starlanes — Gabriel graph
|
|
|
|
// An edge (p,q) survives if no third star sits inside the circle whose
|
|
// diameter is pq. The Gabriel graph contains the Euclidean minimum spanning
|
|
// tree, so the lane network is ALWAYS connected — no repair pass needed, and
|
|
// the verifier asserts it.
|
|
function buildLanes(stars) {
|
|
const lanes = [];
|
|
const adj = stars.map(() => []);
|
|
for (let i = 0; i < stars.length; i += 1) {
|
|
for (let j = i + 1; j < stars.length; j += 1) {
|
|
const a = stars[i];
|
|
const b = stars[j];
|
|
const mx = (a.x + b.x) / 2;
|
|
const my = (a.y + b.y) / 2;
|
|
const r2 = ((a.x - b.x) ** 2 + (a.y - b.y) ** 2) / 4;
|
|
let blocked = false;
|
|
for (let k = 0; k < stars.length; k += 1) {
|
|
if (k === i || k === j) continue;
|
|
const c = stars[k];
|
|
if ((c.x - mx) ** 2 + (c.y - my) ** 2 < r2 - 1e-9) { blocked = true; break; }
|
|
}
|
|
if (blocked) continue;
|
|
const d = dist(a, b);
|
|
lanes.push({ a: i, b: j, dist: d, parsecs: d / PARSEC_PX });
|
|
adj[i].push(j);
|
|
adj[j].push(i);
|
|
}
|
|
}
|
|
return { lanes, adj };
|
|
}
|
|
|
|
export function isConnected(stars, adj) {
|
|
if (!stars.length) return true;
|
|
const seen = new Uint8Array(stars.length);
|
|
const stack = [0];
|
|
seen[0] = 1;
|
|
let n = 1;
|
|
while (stack.length) {
|
|
const cur = stack.pop();
|
|
for (const nb of adj[cur]) {
|
|
if (!seen[nb]) { seen[nb] = 1; n += 1; stack.push(nb); }
|
|
}
|
|
}
|
|
return n === stars.length;
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Homeworlds
|
|
|
|
// Farthest-point sampling: seed with the star nearest the galaxy centroid,
|
|
// then repeatedly take whichever star is furthest from everything chosen so
|
|
// far. Deterministic, and it spreads empires as evenly as the shape allows.
|
|
function pickHomeStars(stars, numEmpires) {
|
|
const eligible = stars
|
|
.map((s, i) => ({ s, i }))
|
|
.filter(({ s }) => s.special !== 'blackhole' && s.special !== 'pulsar' && s.planets.length > 0);
|
|
const pool = eligible.length >= numEmpires ? eligible : stars.map((s, i) => ({ s, i }));
|
|
|
|
const cx = stars.reduce((t, s) => t + s.x, 0) / stars.length;
|
|
const cy = stars.reduce((t, s) => t + s.y, 0) / stars.length;
|
|
let first = pool[0];
|
|
let bestD = Infinity;
|
|
for (const cand of pool) {
|
|
const d = Math.hypot(cand.s.x - cx, cand.s.y - cy);
|
|
if (d < bestD) { bestD = d; first = cand; }
|
|
}
|
|
|
|
const chosen = [first];
|
|
while (chosen.length < numEmpires) {
|
|
let best = null;
|
|
let bestScore = -Infinity;
|
|
for (const cand of pool) {
|
|
if (chosen.some((c) => c.i === cand.i)) continue;
|
|
let nearest = Infinity;
|
|
for (const c of chosen) nearest = Math.min(nearest, dist(cand.s, c.s));
|
|
if (nearest > bestScore) { bestScore = nearest; best = cand; }
|
|
}
|
|
if (!best) break;
|
|
chosen.push(best);
|
|
}
|
|
return chosen.map((c) => c.i);
|
|
}
|
|
|
|
// Every empire must start on its species' native world, at a size and richness
|
|
// that does not decide the game on turn one.
|
|
function installHomeworld(rules, star, spec) {
|
|
const type = rules.planetTypes[spec.homeworld];
|
|
const size = rules.planetSizes.large ?? rules.planetSizeList[rules.planetSizeList.length - 1];
|
|
const home = {
|
|
orbit: 0,
|
|
typeId: type.id,
|
|
sizeId: size.id,
|
|
richId: 'normal',
|
|
gravId: 'normal',
|
|
basePop: Math.round(size.basePop * type.habitability),
|
|
orbitRadius: 70,
|
|
orbitAngle: 0,
|
|
orbitSpeed: 0.28,
|
|
homeworld: true,
|
|
};
|
|
// Keep any other planets in the system but push them outward one orbit, so
|
|
// the capital always sits in the innermost slot the orrery draws first.
|
|
const rest = star.planets.filter((p) => p.orbit !== 0).map((p) => ({ ...p }));
|
|
star.planets = [home, ...rest];
|
|
for (let i = 0; i < star.planets.length; i += 1) {
|
|
star.planets[i].orbit = i;
|
|
if (i > 0) star.planets[i].orbitRadius = 70 + i * 30;
|
|
}
|
|
}
|
|
|
|
// Fairness pass: guarantee every empire can see at least `want` freely
|
|
// settleable worlds inside its opening fuel range. Without this, a start in a
|
|
// hostile pocket is simply dead, and the soak test would blame the AI for it.
|
|
function guaranteeNearbyWorlds(rules, stars, homeIdx, rangeParsecs, want) {
|
|
const upgraded = [];
|
|
for (const hi of homeIdx) {
|
|
const home = stars[hi];
|
|
let open = 0;
|
|
const candidates = [];
|
|
for (let i = 0; i < stars.length; i += 1) {
|
|
if (i === hi) continue;
|
|
if (dist(home, stars[i]) / PARSEC_PX > rangeParsecs) continue;
|
|
for (const p of stars[i].planets) {
|
|
const t = rules.planetTypes[p.typeId];
|
|
if (t.colonizable && t.hostility === 0) open += 1;
|
|
else candidates.push({ starIdx: i, planet: p, type: t });
|
|
}
|
|
}
|
|
const tundra = rules.planetTypes.tundra ?? rules.colonizableTypes[rules.colonizableTypes.length - 1];
|
|
const makeOpen = (planet) => {
|
|
planet.typeId = tundra.id;
|
|
planet.basePop = Math.round((rules.planetSizes[planet.sizeId]?.basePop ?? 40) * tundra.habitability);
|
|
};
|
|
|
|
let guard = 0;
|
|
while (open < want && candidates.length && guard < 40) {
|
|
guard += 1;
|
|
// Upgrade the least-bad candidate: a hostile world becomes tundra rather
|
|
// than a gas giant becoming terran, so the map still reads honestly.
|
|
candidates.sort((a, b) => (b.type.habitability - a.type.habitability));
|
|
const c = candidates.shift();
|
|
makeOpen(c.planet);
|
|
upgraded.push({ starIdx: c.starIdx, orbit: c.planet.orbit });
|
|
open += 1;
|
|
}
|
|
|
|
// On a large, sparse galaxy a homeworld can have NO star at all inside its
|
|
// opening fuel range, and then there is nothing to upgrade — that empire
|
|
// simply cannot expand until it researches propulsion, while its rivals
|
|
// are already colonising. Seed new worlds instead: first at in-range stars,
|
|
// and failing that in the home system itself, so every start is playable.
|
|
guard = 0;
|
|
while (open < want && guard < 20) {
|
|
guard += 1;
|
|
const inRange = [];
|
|
for (let i = 0; i < stars.length; i += 1) {
|
|
if (i === hi) continue;
|
|
if (dist(home, stars[i]) / PARSEC_PX <= rangeParsecs) inRange.push(stars[i]);
|
|
}
|
|
const host = inRange.length
|
|
? inRange.reduce((best, s) => (s.planets.length < best.planets.length ? s : best), inRange[0])
|
|
: home;
|
|
const orbit = host.planets.length;
|
|
host.planets.push({
|
|
orbit,
|
|
typeId: tundra.id,
|
|
sizeId: 'medium',
|
|
richId: 'normal',
|
|
gravId: 'normal',
|
|
basePop: Math.round((rules.planetSizes.medium?.basePop ?? 60) * tundra.habitability),
|
|
orbitRadius: 70 + orbit * 30,
|
|
orbitAngle: 0,
|
|
orbitSpeed: 0.2,
|
|
seeded: true,
|
|
});
|
|
upgraded.push({ starIdx: host.idx, orbit, seeded: true });
|
|
open += 1;
|
|
}
|
|
}
|
|
return upgraded;
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
|
|
export function generateGalaxy(rules, opts) {
|
|
const {
|
|
sizeId = 'medium',
|
|
shapeId = 'spiral',
|
|
seed = 1,
|
|
speciesIds = ['human'],
|
|
} = opts;
|
|
|
|
const size = rules.galaxySizes[sizeId];
|
|
if (!size) throw new Error(`unknown galaxy size ${sizeId}`);
|
|
const shape = rules.galaxyShapes[shapeId];
|
|
if (!shape) throw new Error(`unknown galaxy shape ${shapeId}`);
|
|
if (speciesIds.length > size.maxEmpires) {
|
|
throw new Error(`${speciesIds.length} empires exceeds ${sizeId} galaxy max of ${size.maxEmpires}`);
|
|
}
|
|
|
|
const rnd = mulberry32(seed * 2654435761);
|
|
const pts = placeStars(rnd, shape, size.stars, size.width, size.height);
|
|
|
|
// Names are dealt without replacement so no galaxy has two Vegas.
|
|
const namePool = rules.starNames.slice();
|
|
for (let i = namePool.length - 1; i > 0; i -= 1) {
|
|
const j = Math.floor(rnd() * (i + 1));
|
|
[namePool[i], namePool[j]] = [namePool[j], namePool[i]];
|
|
}
|
|
|
|
const stars = pts.map((p, i) => {
|
|
const cls = weightedPick(rnd, rules.starClassList, (c) => c.weight);
|
|
return {
|
|
idx: i,
|
|
name: namePool[i] ?? `Star ${i + 1}`,
|
|
x: Math.round(p.x),
|
|
y: Math.round(p.y),
|
|
classId: cls.id,
|
|
special: cls.special ?? null,
|
|
planets: rollPlanets(rnd, rules, cls),
|
|
// Binary companions are pure presentation, but they must be stable.
|
|
companionAngle: cls.special === 'binary' ? rnd() * Math.PI * 2 : 0,
|
|
beamAngle: cls.special === 'pulsar' ? rnd() * Math.PI * 2 : 0,
|
|
};
|
|
});
|
|
|
|
const homeIdx = pickHomeStars(stars, speciesIds.length);
|
|
// A home system with no planets at all can come out of pickHomeStars' fallback
|
|
// path; installHomeworld always seeds orbit 0, so this is safe either way.
|
|
speciesIds.forEach((sid, e) => {
|
|
installHomeworld(rules, stars[homeIdx[e]], rules.species[sid]);
|
|
});
|
|
|
|
const { lanes, adj } = buildLanes(stars);
|
|
|
|
const upgraded = guaranteeNearbyWorlds(
|
|
rules, stars, homeIdx,
|
|
(rules.economy.baseFuelRange ?? 4) + 1.5,
|
|
2,
|
|
);
|
|
|
|
return {
|
|
sizeId,
|
|
shapeId,
|
|
seed,
|
|
width: size.width,
|
|
height: size.height,
|
|
stars,
|
|
lanes,
|
|
adj,
|
|
homeIdx,
|
|
upgraded,
|
|
};
|
|
}
|
|
|
|
// Straight-line distance in parsecs between two systems. Fleets fly direct —
|
|
// the lane graph is a readability aid and a range guide, not a rail network.
|
|
export function parsecs(galaxy, i, j) {
|
|
return dist(galaxy.stars[i], galaxy.stars[j]) / PARSEC_PX;
|
|
}
|
|
|
|
// Rough measure of how good a system is to settle, used by worldgen fairness
|
|
// checks and by the AI's expansion scoring.
|
|
export function systemQuality(rules, star) {
|
|
let q = 0;
|
|
for (const p of star.planets) {
|
|
const t = rules.planetTypes[p.typeId];
|
|
if (!t.colonizable) continue;
|
|
const rich = rules.richness[p.richId]?.industryMult ?? 1;
|
|
q += p.basePop * rich * (1 - 0.08 * t.hostility);
|
|
}
|
|
return q;
|
|
}
|