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() 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() 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() 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() 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) }) })