Files
SpaceWanderer/lib/game/world.test.ts
T
kamilkrzywda 06f8a28e96
CI / build + unit + e2e (pull_request) Successful in 2m38s
fix(game): numbered name fallback when pool exhausted — server no longer freezes past 128 systems
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.
2026-09-29 09:18:00 +00:00

148 lines
5.8 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.
import { describe, expect, it } from 'vitest'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, rollExploration } from './world'
describe('generateStartRegion', () => {
const region = generateStartRegion(mulberry32(42))
it('produces a starting system plus six neighbours', () => {
expect(region.neighbors).toHaveLength(6)
expect(region.start.x).toBe(50)
expect(region.start.y).toBe(50)
})
it('gives every system a unique name and in-bounds coordinates', () => {
const all = [region.start, ...region.neighbors]
const names = all.map((s) => s.name)
expect(new Set(names).size).toBe(names.length)
for (const s of all) {
expect(s.x).toBeGreaterThanOrEqual(0)
expect(s.x).toBeLessThanOrEqual(100)
expect(s.y).toBeGreaterThanOrEqual(0)
expect(s.y).toBeLessThanOrEqual(100)
}
})
it('connects the start to every neighbour and closes the ring cycle', () => {
const n = region.neighbors.length
// anchor↔each member (n edges) + ring cycle (n edges)
expect(region.edges).toHaveLength(n * 2)
for (let i = 0; i < n; i++) {
expect(region.edges).toContainEqual([0, i + 1])
expect(region.edges).toContainEqual([i + 1, ((i + 1) % n) + 1])
}
// every edge endpoint is a valid index into [start, ...neighbors]
for (const [a, b] of region.edges) {
expect(a).toBeGreaterThanOrEqual(0)
expect(a).toBeLessThan(n + 1)
expect(b).toBeGreaterThanOrEqual(0)
expect(b).toBeLessThan(n + 1)
}
})
it('is deterministic under a fixed seed and differs across seeds', () => {
const again = generateStartRegion(mulberry32(42))
expect(again).toEqual(region)
const other = generateStartRegion(mulberry32(43))
expect(other.start.name !== region.start.name || other.neighbors[0].name !== region.neighbors[0].name).toBe(true)
})
})
describe('generateRing (expansion)', () => {
it('adds four systems spaced away from the anchor and existing positions', () => {
const takenNames = new Set(['Kepler Drift'])
const takenPositions = [{ name: 'Kepler Drift', x: 50, y: 50 }]
const ring = generateRing(mulberry32(7), 50, 50, takenNames, takenPositions)
expect(ring.systems).toHaveLength(4)
const ringNames = ring.systems.map((s) => s.name)
for (const s of ring.systems) {
expect(Math.hypot(s.x - 50, s.y - 50)).toBeGreaterThanOrEqual(16)
expect(s.name).not.toBe('Kepler Drift') // never reuses a pre-taken name
}
expect(new Set(ringNames).size).toBe(4) // and makeName claimed them into the set
for (const n of ringNames) expect(takenNames.has(n)).toBe(true)
expect(ring.edges).toHaveLength(8) // anchor↔each + cycle
for (const [a, b] of ring.edges) {
expect(a).toBeGreaterThanOrEqual(0)
expect(a).toBeLessThan(5)
expect(b).toBeGreaterThanOrEqual(0)
expect(b).toBeLessThan(5)
}
})
it('never reuses a taken name', () => {
const rng = mulberry32(1)
const taken = new Set<string>()
for (let i = 0; i < 40; i++) makeName(rng, taken)
expect(taken.size).toBe(40)
})
it('terminates with numbered variants once all 128 base names are taken', () => {
const rng = mulberry32(9)
const taken = new Set<string>()
for (let i = 0; i < 128; i++) makeName(rng, taken) // exhaust the whole pool
expect(taken.size).toBe(128)
const a = makeName(rng, taken) // must return instead of spinning forever
const b = makeName(rng, taken)
expect(a).not.toBe(b)
expect(taken.has(a)).toBe(true)
expect(taken.has(b)).toBe(true)
})
})
describe('generatePlanets', () => {
it('gives every system 2–5 planets and keeps body names unique within the system', () => {
for (const seed of [1, 7, 42, 999]) {
const planets = generatePlanets(bodyRng(String(seed), seed))
expect(planets.length).toBeGreaterThanOrEqual(2)
expect(planets.length).toBeLessThanOrEqual(5)
const names: string[] = []
for (const p of planets) {
names.push(p.name, ...p.moons.map((m) => m.name))
expect(['TERRESTRIAL', 'GAS GIANT', 'ROCKY', 'ICE WORLD', 'VOLCANIC', 'OCEAN', 'ASTEROID FIELD']).toContain(p.type)
for (const m of p.moons) {
expect(['MOON', 'ICE MOON', 'VOLCANIC MOON']).toContain(m.type)
}
}
expect(new Set(names).size).toBe(names.length) // planets + moons share one name pool per system
}
})
it('is deterministic per (seed, system id) and differs across systems', () => {
const a = generatePlanets(bodyRng('42', 7))
expect(generatePlanets(bodyRng('42', 7))).toEqual(a) // same save+system → same bodies
const b = generatePlanets(bodyRng('43', 7))
expect(JSON.stringify(b)).not.toBe(JSON.stringify(a)) // different seed → different world
})
})
describe('rollExploration', () => {
it('is deterministic for the same save+planet and yields valid items', () => {
const first = rollExploration(explorationRng('42', 13))
expect(rollExploration(explorationRng('42', 13))).toEqual(first)
for (const item of first) {
expect(['resource', 'part']).toContain(item.kind)
if (item.kind === 'resource') {
expect(item.qty).toBeGreaterThanOrEqual(1)
expect(item.qty).toBeLessThanOrEqual(4)
} else {
expect(item.qty).toBe(1)
}
}
})
it('across many planets produces every outcome shape: resource, part, both, or nothing', () => {
const shapes = new Set<string>()
for (let planetId = 1; planetId <= 200; planetId++) {
const found = rollExploration(explorationRng('42', planetId))
const hasResource = found.some((f) => f.kind === 'resource')
const hasPart = found.some((f) => f.kind === 'part')
shapes.add(`${hasResource ? 'r' : '-'}${hasPart ? 'p' : '-'}`)
}
expect(shapes).toEqual(new Set(['rp', 'r-', '-p', '--']))
})
})