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.
148 lines
5.8 KiB
TypeScript
148 lines
5.8 KiB
TypeScript
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', '--']))
|
||
})
|
||
})
|