270 lines
9.7 KiB
TypeScript
270 lines
9.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; seeds the force-directed WORLD MAP layout
|
||
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) }
|
||
}
|
||
|
||
// BFS over jump routes: every system within maxHops of fromId, mapped to its shortest hop count.
|
||
// `edges` may be undirected pairs (WorldState.edges) or double-directed route rows — duplicates are
|
||
// harmless because a visited system is never re-queued. fromId itself is not in the result.
|
||
export function reachableWithin(edges: [number, number][], fromId: number, maxHops: number): Map<number, number> {
|
||
const adj = new Map<number, number[]>()
|
||
for (const [a, b] of edges) {
|
||
if (!adj.has(a)) adj.set(a, [])
|
||
if (!adj.has(b)) adj.set(b, [])
|
||
adj.get(a)!.push(b)
|
||
adj.get(b)!.push(a)
|
||
}
|
||
const dist = new Map<number, number>()
|
||
const seen = new Set([fromId]) // the origin is never re-queued or reported
|
||
let frontier = [fromId]
|
||
for (let hop = 1; hop <= maxHops; hop++) {
|
||
const next: number[] = []
|
||
for (const id of frontier) {
|
||
for (const nb of adj.get(id) ?? []) {
|
||
if (!seen.has(nb)) {
|
||
seen.add(nb)
|
||
dist.set(nb, hop)
|
||
next.push(nb)
|
||
}
|
||
}
|
||
}
|
||
frontier = next
|
||
}
|
||
return dist
|
||
}
|
||
|
||
// --- 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
|
||
}
|