fertig-classic-games/src/games/mastervega/VegaGalaxyGen.js

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;
}