Files
SpaceWanderer/lib/game/world.ts
T
kamilkrzywda 90de8818f8 world: #51 — ringEdges small-n fix + generateRing seeded 1–4 legs with attach
- ringEdges: n=0 → [], n=1 → [[0,1]], n=2 → [[0,1],[0,2],[1,2]];
  n≥3 wheel is byte-identical to the legacy output.
- generateRing: new params attachCandidates, existingEdgeKeys, anchorId;
  legCount is seeded-random in [EXPAND_MIN, EXPAND_MAX] (1–4);
  each leg may attach a known system (closing a loop) with prob ATTACH_PROB
  instead of minting; frontier guarantee converts the last-drawn attach
  to a mint when all legs attached.
- RingRegion gains attaches: number[] (candidate indices attached).
- Remove the EXPAND_RING constant (replaced by EXPAND_MIN/EXPAND_MAX).
2026-10-06 12:18:27 +00:00

319 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
// #51 — the spread limit: jumping into an undiscovered system mints a seeded-random number of
// new systems in [EXPAND_MIN, EXPAND_MAX]. Each "leg" can instead attach an already-known
// system (closing a loop) with probability ATTACH_PROB, so the chart grows slower and more
// circular. EXPAND_RING (the old fixed count) is replaced by this range.
export const EXPAND_MIN = 1
export const EXPAND_MAX = 4
export const ATTACH_PROB = 0.35
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].
// #51 — small-n special cases: n=0 is empty, n=1 is the single spoke, n=2 is the complete
// triangle (the old wheel formula produced a self-loop at n=1 and a duplicate edge at n=2).
// n≥3 is the unchanged wheel.
export function ringEdges(n: number): [number, number][] {
if (n === 0) return []
if (n === 1) return [[0, 1]]
if (n === 2) return [[0, 1], [0, 2], [1, 2]]
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
attaches: number[] // indices into the attachCandidates array that were attached (loop-closed) rather than minted
}
// Jumping into an undiscovered system grows the chart: a fresh ring of (undiscovered)
// neighbours around it. #51 — the ring width is a seeded-random 1–4 legs; each leg may
// instead attach an already-known system (closing a loop) with probability ATTACH_PROB.
// `takenNames`/`takenPositions` are every system already in the save. `attachCandidates`
// are the systems eligible for loop-closing (all save systems except the anchor).
// `existingEdgeKeys` is the set of `${min(a,b)}-${max(a,b)}` keys for routes already
// touching the anchor, so an attach never duplicates one. `anchorId` is the DB id of the
// anchor system (used to build edge keys).
export function generateRing(
rng: () => number,
anchorX: number,
anchorY: number,
takenNames: Set<string>,
takenPositions: WorldSystem[],
attachCandidates: (WorldSystem & { id: number })[] = [],
existingEdgeKeys: Set<string> = new Set(),
anchorId: number = 0,
): RingRegion {
const legCount = EXPAND_MIN + Math.floor(rng() * (EXPAND_MAX - EXPAND_MIN + 1))
let mints = 0
const attachSet = new Set<number>()
let lastAttachCi = -1
for (let leg = 0; leg < legCount; leg++) {
let isAttach = false
if (attachCandidates.length > 0 && rng() < ATTACH_PROB) {
const ci = Math.floor(rng() * attachCandidates.length)
const a = Math.min(anchorId, attachCandidates[ci]!.id)
const b = Math.max(anchorId, attachCandidates[ci]!.id)
if (!existingEdgeKeys.has(`${a}-${b}`)) {
attachSet.add(ci)
lastAttachCi = ci
isAttach = true
}
}
if (!isAttach) mints++
}
// Frontier guarantee: at least one new system must be minted so the chart always grows.
if (mints === 0 && lastAttachCi >= 0) {
attachSet.delete(lastAttachCi)
mints = 1
}
const systems = placeRing(rng, anchorX, anchorY, mints, takenNames, takenPositions)
return { systems, edges: ringEdges(mints), attaches: [...attachSet] }
}
// 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
}