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).
This commit is contained in:
2026-10-06 12:18:27 +00:00
parent a593cd04cd
commit 90de8818f8
2 changed files with 164 additions and 18 deletions
+110 -13
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, reachableWithin, rollExploration } from './world'
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))
@@ -47,37 +48,133 @@ describe('generateStartRegion', () => {
})
})
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)', () => {
it('adds four systems spaced away from the anchor and existing positions', () => {
// 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)
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
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)
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('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('never reuses a taken name', () => {
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('terminates with numbered variants once all 128 base names are taken', () => {
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