- 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).
279 lines
12 KiB
TypeScript
279 lines
12 KiB
TypeScript
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)
|
||
})
|
||
})
|