CI / build + unit + e2e (pull_request) Successful in 2m38s
Production freeze reported after the v0.9.3 (#21) deploy: every API route hung and all panels stayed at LOADING. Root cause was not the #21 layout (verified healthy across all 7 tabs/viewports) but makeName's unbounded rejection loop over a finite 16x8=128 system-name pool — once a save grew past ~128 systems, any jump into an undiscovered system spun forever on Node's single thread (sql.js is synchronous), blocking the whole process. makeName and makeCrewName now fall back to numbered variants ('Kepler Drift 2') once their pools are exhausted, so they always terminate.
240 lines
8.7 KiB
TypeScript
240 lines
8.7 KiB
TypeScript
// Pure, seeded world generation — no DB, no Date, no Math.random. The same seed always
|
||
// yields the same world, which is what makes unit tests (and save regeneration) deterministic.
|
||
|
||
export interface WorldSystem {
|
||
name: string
|
||
x: number // 0–100 coordinate space, rendered directly by NodeGraph
|
||
y: number
|
||
}
|
||
|
||
export const START_X = 50
|
||
export const START_Y = 50
|
||
|
||
const START_RING = 6 // neighbours around the starting system on a new game
|
||
const EXPAND_RING = 4 // new neighbours generated when jumping into an undiscovered system
|
||
const MIN_SPACING = 16 // minimum distance between any two systems in the 0–100 space
|
||
|
||
// mulberry32 — small fast seeded PRNG; plenty for name/position picking.
|
||
export function mulberry32(seed: number): () => number {
|
||
let a = seed >>> 0
|
||
return () => {
|
||
a |= 0
|
||
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 FIRST = [
|
||
'KEPLER', 'VESPER', 'HOLLOW', 'CINDER', 'LANTERN', 'SABLE', 'ORPHEUS', 'MIRE',
|
||
'THALASSA', 'KUIPER', 'AURELLE', 'BRIMSTONE', 'EMBER', 'FROST', 'GALE', 'IRON',
|
||
]
|
||
const SECOND = ['DRIFT', 'REACH', 'GATE', 'FIELD', 'VOID', 'DEEP', 'BEND', 'SHARD']
|
||
|
||
function title(word: string): string {
|
||
return word.charAt(0) + word.slice(1).toLowerCase()
|
||
}
|
||
|
||
// Picks a name not in `taken` and adds it, so callers can't accidentally reuse one. The base
|
||
// pool is finite (16×8=128), so once every base is taken the loop would spin forever — which
|
||
// froze the whole server on a save that had grown past 128 systems (synchronous sql.js runs on
|
||
// Node's single thread). Numbered variants keep names unique and terminate.
|
||
export function makeName(rng: () => number, taken: Set<string>): string {
|
||
for (;;) {
|
||
const base = `${title(FIRST[Math.floor(rng() * FIRST.length)])} ${title(SECOND[Math.floor(rng() * SECOND.length)])}`
|
||
if (!taken.has(base)) {
|
||
taken.add(base)
|
||
return base
|
||
}
|
||
if (taken.size >= FIRST.length * SECOND.length) {
|
||
for (let n = 2; ; n++) {
|
||
const name = `${base} ${n}`
|
||
if (!taken.has(name)) {
|
||
taken.add(name)
|
||
return name
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function clamp(v: number): number {
|
||
return Math.min(96, Math.max(4, v))
|
||
}
|
||
|
||
// Places `count` systems on a ring around (cx,cy), nudging each candidate until it is at
|
||
// least MIN_SPACING away from every known position. After 12 tries it accepts the last
|
||
// candidate anyway — ponytail: a slightly crowded node beats an infinite loop; with ≤6
|
||
// rings per region and spacing 16 in a 0–100 box this never triggers in practice.
|
||
function placeRing(
|
||
rng: () => number,
|
||
cx: number,
|
||
cy: number,
|
||
count: number,
|
||
takenNames: Set<string>,
|
||
takenPositions: WorldSystem[],
|
||
): WorldSystem[] {
|
||
const placed: WorldSystem[] = []
|
||
for (let i = 0; i < count; i++) {
|
||
const baseAngle = (i / count) * Math.PI * 2 + (rng() - 0.5) * 0.6
|
||
let x = cx
|
||
let y = cy
|
||
for (let attempt = 0; attempt < 12; attempt++) {
|
||
const angle = baseAngle + attempt * 0.45
|
||
const radius = 26 + rng() * 8
|
||
x = clamp(cx + Math.cos(angle) * radius)
|
||
y = clamp(cy + Math.sin(angle) * radius)
|
||
if ([...takenPositions, ...placed].every((p) => Math.hypot(p.x - x, p.y - y) >= MIN_SPACING)) break
|
||
}
|
||
placed.push({ name: makeName(rng, takenNames), x, y })
|
||
}
|
||
return placed
|
||
}
|
||
|
||
// Edges for a ring of n members around an anchor at index 0: anchor↔each member plus the
|
||
// cycle connecting consecutive members (so every system has ≥2 routes and you can always
|
||
// get back). Pairs are indices into [anchor, ...members].
|
||
function ringEdges(n: number): [number, number][] {
|
||
const edges: [number, number][] = []
|
||
for (let i = 0; i < n; i++) {
|
||
edges.push([0, i + 1])
|
||
edges.push([i + 1, ((i + 1) % n) + 1])
|
||
}
|
||
return edges
|
||
}
|
||
|
||
export interface StartRegion {
|
||
start: WorldSystem
|
||
neighbors: WorldSystem[]
|
||
edges: [number, number][] // indices into [start, ...neighbors]
|
||
}
|
||
|
||
// New game: the starting system at chart centre plus a ring of undiscovered neighbours.
|
||
export function generateStartRegion(rng: () => number): StartRegion {
|
||
const taken = new Set<string>()
|
||
const start: WorldSystem = { name: makeName(rng, taken), x: START_X, y: START_Y }
|
||
const neighbors = placeRing(rng, START_X, START_Y, START_RING, taken, [start])
|
||
return { start, neighbors, edges: ringEdges(neighbors.length) }
|
||
}
|
||
|
||
export interface RingRegion {
|
||
systems: WorldSystem[]
|
||
edges: [number, number][] // indices into [anchor, ...systems] — index 0 is the anchor itself
|
||
}
|
||
|
||
// Jumping into an undiscovered system grows the chart: a fresh ring of (undiscovered)
|
||
// neighbours around it. `takenNames`/`takenPositions` are every system already in the save.
|
||
export function generateRing(
|
||
rng: () => number,
|
||
anchorX: number,
|
||
anchorY: number,
|
||
takenNames: Set<string>,
|
||
takenPositions: WorldSystem[],
|
||
): RingRegion {
|
||
const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions)
|
||
return { systems, edges: ringEdges(systems.length) }
|
||
}
|
||
|
||
// --- Bodies (planets + moons) and exploration -------------------------------
|
||
|
||
export interface WorldMoon {
|
||
name: string
|
||
type: string
|
||
hazard: boolean
|
||
}
|
||
|
||
export interface WorldPlanet {
|
||
name: string
|
||
type: string
|
||
hazard: boolean
|
||
moons: WorldMoon[]
|
||
}
|
||
|
||
// Derive an independent PRNG stream for one entity from the save seed, so a system's bodies
|
||
// and a planet's exploration haul are reproducible offline (tests recompute them directly).
|
||
export function bodyRng(seed: string, systemId: number): () => number {
|
||
return mulberry32((Number(seed) + Math.imul(systemId, 0x9e3779b1)) >>> 0)
|
||
}
|
||
|
||
export function explorationRng(seed: string, planetId: number): () => number {
|
||
return mulberry32((Number(seed) + Math.imul(planetId, 0x85ebca6b)) >>> 0)
|
||
}
|
||
|
||
const BODY_NAMES = [
|
||
'AURELLE', 'BRIMSTONE', 'CINDERROCK', 'DRIFTBAND', 'PALEMOON', 'ASHFALL', 'VESPERINE', 'KORR',
|
||
'MIRELLA', 'SABLET', 'EMBERLING', 'FROSTHOLD', 'GALEN', 'IRONVEIN', 'JUNO', 'KESTREL',
|
||
'LUMEN', 'NADIR', 'OPALINE', 'PYRE', 'QUILL', 'RUSTBARK', 'SIROCCO', 'TARN',
|
||
'UMBRA', 'VELVETINE', 'WYRM', 'ZEPHYR', 'HALCYON', 'MORROW', 'ONYX', 'THALASSA',
|
||
]
|
||
|
||
const PLANET_TYPES = ['TERRESTRIAL', 'GAS GIANT', 'ROCKY', 'ICE WORLD', 'VOLCANIC', 'OCEAN', 'ASTEROID FIELD']
|
||
const MOON_TYPES = ['MOON', 'ICE MOON', 'VOLCANIC MOON']
|
||
|
||
const HAZARD_CHANCE = 0.25
|
||
const MOON_CHANCE = 0.6 // a planet has any moons at all
|
||
const PLANET_COUNT_MIN = 2
|
||
const PLANET_COUNT_SPREAD = 4 // 2..5 planets per system
|
||
|
||
// A body name unique within its system (planets and moons share one pool). Max bodies per
|
||
// system is 5 + 5*3 = 20 < BODY_NAMES.length, so the loop always terminates.
|
||
function makeBodyName(rng: () => number, taken: Set<string>): string {
|
||
for (;;) {
|
||
const name = title(BODY_NAMES[Math.floor(rng() * BODY_NAMES.length)])
|
||
if (!taken.has(name)) {
|
||
taken.add(name)
|
||
return name
|
||
}
|
||
}
|
||
}
|
||
|
||
function pick<T>(rng: () => number, arr: T[]): T {
|
||
return arr[Math.floor(rng() * arr.length)]
|
||
}
|
||
|
||
// The bodies of one system: 2–5 planets, some with 1–3 moons. Pure — the caller derives the
|
||
// rng from (save seed, system id) so a given system always gets the same bodies.
|
||
export function generatePlanets(rng: () => number): WorldPlanet[] {
|
||
const taken = new Set<string>()
|
||
const planets: WorldPlanet[] = []
|
||
for (let i = 0; i < PLANET_COUNT_MIN + Math.floor(rng() * PLANET_COUNT_SPREAD); i++) {
|
||
const name = makeBodyName(rng, taken)
|
||
const type = pick(rng, PLANET_TYPES)
|
||
const hazard = rng() < HAZARD_CHANCE
|
||
const moons: WorldMoon[] = []
|
||
if (rng() < MOON_CHANCE) {
|
||
for (let m = 0; m < 1 + Math.floor(rng() * 3); m++) {
|
||
moons.push({ name: makeBodyName(rng, taken), type: pick(rng, MOON_TYPES), hazard: rng() < HAZARD_CHANCE })
|
||
}
|
||
}
|
||
planets.push({ name, type, hazard, moons })
|
||
}
|
||
return planets
|
||
}
|
||
|
||
export interface FoundItem {
|
||
kind: 'resource' | 'part'
|
||
name: string
|
||
qty: number
|
||
}
|
||
|
||
const RESOURCE_NAMES = ['IRON ORE', 'CRYSTAL SHARDS', 'GAS CELLS', 'WATER ICE', 'RARE EARTH']
|
||
const PART_NAMES = ['NAV MODULE', 'HULL PLATE', 'REACTOR CORE', 'COMMS ARRAY', 'ENGINE TUNING']
|
||
|
||
const RESOURCE_CHANCE = 0.7
|
||
const PART_CHANCE = 0.3 // parts are rarer than resources
|
||
|
||
// The haul from scanning one body: a resource and/or a part, or nothing (~21% of scans).
|
||
// Pure — the caller derives the rng from (save seed, planet id), so the same save+planet
|
||
// always yields the same outcome.
|
||
export function rollExploration(rng: () => number): FoundItem[] {
|
||
const found: FoundItem[] = []
|
||
if (rng() < RESOURCE_CHANCE) {
|
||
found.push({ kind: 'resource', name: pick(rng, RESOURCE_NAMES), qty: 1 + Math.floor(rng() * 4) }) // 1..4
|
||
}
|
||
if (rng() < PART_CHANCE) {
|
||
found.push({ kind: 'part', name: pick(rng, PART_NAMES), qty: 1 })
|
||
}
|
||
return found
|
||
}
|