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:
+110
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user