Files
SpaceWanderer/lib/game/world.test.ts
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

279 lines
12 KiB
TypeScript
Raw Permalink 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 { ATTACH_PROB, EXPAND_MAX, EXPAND_MIN, bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, reachableWithin, ringEdges, rollExploration } from './world'
import type { WorldSystem } 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('ringEdges (#51 dedup)', () => {
it('n=0 is empty and n=1 is the single anchor↔member edge (the old wheel emitted a [1,1] self-loop here)', () => {
expect(ringEdges(0)).toEqual([])
expect(ringEdges(1)).toEqual([[0, 1]])
})
it('n=2 is the complete triangle (the old wheel emitted the [1,2] edge twice, as [1,2] and [2,1])', () => {
expect(ringEdges(2)).toEqual([[0, 1], [0, 2], [1, 2]])
})
it('n>=3 stays the simple wheel, byte-identical to the legacy output', () => {
expect(ringEdges(3)).toEqual([[0, 1], [1, 2], [0, 2], [2, 3], [0, 3], [3, 1]])
expect(ringEdges(4)).toEqual([[0, 1], [1, 2], [0, 2], [2, 3], [0, 3], [3, 4], [0, 4], [4, 1]])
expect(ringEdges(6)).toEqual([[0, 1], [1, 2], [0, 2], [2, 3], [0, 3], [3, 4], [0, 4], [4, 5], [0, 5], [5, 6], [0, 6], [6, 1]])
})
})
describe('generateRing (expansion)', () => {
// A scripted rng: returns the given values in order, then a constant fallback for the rest
// (placeRing's position jitter/radius draws) so a test can pin the leg-drawing phase exactly.
function scriptedRng(script: number[], fallback = 0.5): () => number {
let i = 0
return () => (i < script.length ? script[i++] : fallback)
}
it('#51: mints a seeded-random 1–4 new systems, and all four widths occur across seeds', () => {
const counts = new Set<number>()
for (let seed = 1; seed <= 200; seed++) {
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [])
expect(ring.systems.length).toBeGreaterThanOrEqual(EXPAND_MIN)
expect(ring.systems.length).toBeLessThanOrEqual(EXPAND_MAX)
counts.add(ring.systems.length)
}
expect(counts).toEqual(new Set([EXPAND_MIN, EXPAND_MIN + 1, EXPAND_MIN + 2, EXPAND_MAX]))
})
it('with no attach candidates every leg mints, so systems.length is the leg count and attaches is empty', () => {
for (let seed = 1; seed <= 50; seed++) {
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [])
expect(ring.attaches).toEqual([])
}
})
it('mints are spaced away from the anchor and existing positions and never reuse a taken name', () => {
const takenNames = new Set(['Kepler Drift'])
const takenPositions = [{ name: 'Kepler Drift', x: 50, y: 50 }]
const ring = generateRing(mulberry32(7), 50, 50, takenNames, takenPositions)
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(ringNames.length) // and makeName claimed them into the set
for (const n of ringNames) expect(takenNames.has(n)).toBe(true)
})
it('edges are exactly the wheel for the mint count, indexing into [anchor, ...systems]', () => {
for (let seed = 1; seed <= 50; seed++) {
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [])
const n = ring.systems.length
expect(ring.edges).toEqual(ringEdges(n)) // anchor↔each member + the member cycle
for (const [a, b] of ring.edges) {
expect(a).toBeGreaterThanOrEqual(0)
expect(a).toBeLessThan(n + 1)
expect(b).toBeGreaterThanOrEqual(0)
expect(b).toBeLessThan(n + 1)
}
}
})
it('attaches existing systems to close loops, but never one already routed to the anchor', () => {
const candidates: (WorldSystem & { id: number })[] = [
{ id: 100, name: 'A', x: 10, y: 10 },
{ id: 200, name: 'B', x: 20, y: 20 },
{ id: 300, name: 'C', x: 30, y: 30 },
]
// The anchor (id 7) is already routed to candidate 200 — that one can never be attached.
const existing = new Set(['7-200'])
for (let seed = 1; seed <= 200; seed++) {
const ring = generateRing(mulberry32(seed), 50, 50, new Set(), [], candidates, existing, 7)
for (const ci of ring.attaches) {
expect(ci).toBeGreaterThanOrEqual(0)
expect(ci).toBeLessThan(candidates.length)
expect(candidates[ci].id).not.toBe(200) // the already-routed candidate is never re-routed
}
expect(new Set(ring.attaches).size).toBe(ring.attaches.length) // attaches are unique
expect(ring.systems.length).toBeGreaterThanOrEqual(EXPAND_MIN) // the frontier still grows
}
})
it('frontier guarantee: a scripted all-attach run still mints exactly one system', () => {
// Force legCount=4 (first draw 0.8) and all four legs to attach to four DISTINCT candidates
// (attach-check 0.1 < ATTACH_PROB, ci draws 0.1/0.3/0.6/0.9 → candidates 0/1/2/3). Without the
// guarantee this would mint zero systems and strand the chart; with it, one attach becomes a mint.
const candidates: (WorldSystem & { id: number })[] = [
{ id: 1, name: 'A', x: 10, y: 10 },
{ id: 2, name: 'B', x: 20, y: 20 },
{ id: 3, name: 'C', x: 30, y: 30 },
{ id: 4, name: 'D', x: 40, y: 40 },
]
const ring = generateRing(scriptedRng([0.8, 0.1, 0.1, 0.1, 0.3, 0.1, 0.6, 0.1, 0.9]), 50, 50, new Set(), [], candidates, new Set(), 99)
expect(ring.systems.length).toBe(1) // one attach converted into a mint
expect(ring.attaches).toEqual([0, 1, 2]) // the last-drawn attach (index 3) became the mint
})
it('is deterministic per seed and differs across seeds (candidates + existing edges included)', () => {
const candidates: (WorldSystem & { id: number })[] = [
{ id: 100, name: 'A', x: 10, y: 10 },
{ id: 200, name: 'B', x: 20, y: 20 },
]
const existing = new Set(['7-100'])
const positions = [{ name: 'Kepler Drift', x: 50, y: 50 }]
const a = generateRing(mulberry32(11), 50, 50, new Set(['Kepler Drift']), positions, candidates, existing, 7)
const b = generateRing(mulberry32(11), 50, 50, new Set(['Kepler Drift']), positions, candidates, existing, 7)
expect(b).toEqual(a) // same seed + inputs → identical region
const c = generateRing(mulberry32(12), 50, 50, new Set(['Kepler Drift']), positions, candidates, existing, 7)
expect(JSON.stringify(c)).not.toBe(JSON.stringify(a))
})
it('makeName 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('makeName 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', '--']))
})
})
describe('reachableWithin (#35 jump range)', () => {
// Chain 1—2—3—4 with a branch off 2: 2—5. Distances from 1: 2→1, 3/5→2, 4→3.
const edges: [number, number][] = [[1, 2], [2, 3], [3, 4], [2, 5]]
it('maxHops=1 returns only direct neighbours at distance 1 (the pre-NAV-AI range)', () => {
expect(reachableWithin(edges, 1, 1)).toEqual(new Map([[2, 1]]))
})
it('maxHops=2 adds the two-hop systems and stops before three', () => {
expect(reachableWithin(edges, 1, 2)).toEqual(new Map([ [2, 1], [3, 2], [5, 2] ]))
})
it('larger maxHops keeps walking until the graph is exhausted', () => {
expect(reachableWithin(edges, 1, 5)).toEqual(new Map([ [2, 1], [3, 2], [5, 2], [4, 3] ]))
})
it('never includes the origin and handles empty/isolated input', () => {
expect(reachableWithin(edges, 1, 3).has(1)).toBe(false)
expect(reachableWithin([], 1, 2)).toEqual(new Map())
expect(reachableWithin([[9, 10]], 1, 2)).toEqual(new Map())
})
it('accepts double-directed route rows (the save.ts input shape) identically', () => {
const directed: [number, number][] = [[1, 2], [2, 1], [2, 3], [3, 2]]
expect(reachableWithin(directed, 1, 2)).toEqual(new Map([ [2, 1], [3, 2] ]))
})
it('on a fresh start region every system is one hop away — two-hop targets only exist after an expansion', () => {
const region = generateStartRegion(mulberry32(42)) // edges index into [start, ...neighbors], start = 0
expect(reachableWithin(region.edges, 0, 1).size).toBe(region.neighbors.length)
expect(reachableWithin(region.edges, 0, 2).size).toBe(region.neighbors.length)
})
})