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
+54 -5
View File
@@ -11,7 +11,13 @@ export const START_X = 50
export const START_Y = 50
const START_RING = 6 // neighbours around the starting system on a new game
const EXPAND_RING = 4 // new neighbours generated when jumping into an undiscovered system
// #51 — the spread limit: jumping into an undiscovered system mints a seeded-random number of
// new systems in [EXPAND_MIN, EXPAND_MAX]. Each "leg" can instead attach an already-known
// system (closing a loop) with probability ATTACH_PROB, so the chart grows slower and more
// circular. EXPAND_RING (the old fixed count) is replaced by this range.
export const EXPAND_MIN = 1
export const EXPAND_MAX = 4
export const ATTACH_PROB = 0.35
const MIN_SPACING = 16 // minimum distance between any two systems in the 0–100 space
// mulberry32 — small fast seeded PRNG; plenty for name/position picking.
@@ -95,7 +101,13 @@ function placeRing(
// Edges for a ring of n members around an anchor at index 0: anchor↔each member plus the
// cycle connecting consecutive members (so every system has ≥2 routes and you can always
// get back). Pairs are indices into [anchor, ...members].
function ringEdges(n: number): [number, number][] {
// #51 — small-n special cases: n=0 is empty, n=1 is the single spoke, n=2 is the complete
// triangle (the old wheel formula produced a self-loop at n=1 and a duplicate edge at n=2).
// n≥3 is the unchanged wheel.
export function ringEdges(n: number): [number, number][] {
if (n === 0) return []
if (n === 1) return [[0, 1]]
if (n === 2) return [[0, 1], [0, 2], [1, 2]]
const edges: [number, number][] = []
for (let i = 0; i < n; i++) {
edges.push([0, i + 1])
@@ -121,19 +133,56 @@ export function generateStartRegion(rng: () => number): StartRegion {
export interface RingRegion {
systems: WorldSystem[]
edges: [number, number][] // indices into [anchor, ...systems] — index 0 is the anchor itself
attaches: number[] // indices into the attachCandidates array that were attached (loop-closed) rather than minted
}
// Jumping into an undiscovered system grows the chart: a fresh ring of (undiscovered)
// neighbours around it. `takenNames`/`takenPositions` are every system already in the save.
// neighbours around it. #51 — the ring width is a seeded-random 1–4 legs; each leg may
// instead attach an already-known system (closing a loop) with probability ATTACH_PROB.
// `takenNames`/`takenPositions` are every system already in the save. `attachCandidates`
// are the systems eligible for loop-closing (all save systems except the anchor).
// `existingEdgeKeys` is the set of `${min(a,b)}-${max(a,b)}` keys for routes already
// touching the anchor, so an attach never duplicates one. `anchorId` is the DB id of the
// anchor system (used to build edge keys).
export function generateRing(
rng: () => number,
anchorX: number,
anchorY: number,
takenNames: Set<string>,
takenPositions: WorldSystem[],
attachCandidates: (WorldSystem & { id: number })[] = [],
existingEdgeKeys: Set<string> = new Set(),
anchorId: number = 0,
): RingRegion {
const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions)
return { systems, edges: ringEdges(systems.length) }
const legCount = EXPAND_MIN + Math.floor(rng() * (EXPAND_MAX - EXPAND_MIN + 1))
let mints = 0
const attachSet = new Set<number>()
let lastAttachCi = -1
for (let leg = 0; leg < legCount; leg++) {
let isAttach = false
if (attachCandidates.length > 0 && rng() < ATTACH_PROB) {
const ci = Math.floor(rng() * attachCandidates.length)
const a = Math.min(anchorId, attachCandidates[ci]!.id)
const b = Math.max(anchorId, attachCandidates[ci]!.id)
if (!existingEdgeKeys.has(`${a}-${b}`)) {
attachSet.add(ci)
lastAttachCi = ci
isAttach = true
}
}
if (!isAttach) mints++
}
// Frontier guarantee: at least one new system must be minted so the chart always grows.
if (mints === 0 && lastAttachCi >= 0) {
attachSet.delete(lastAttachCi)
mints = 1
}
const systems = placeRing(rng, anchorX, anchorY, mints, takenNames, takenPositions)
return { systems, edges: ringEdges(mints), attaches: [...attachSet] }
}
// BFS over jump routes: every system within maxHops of fromId, mapped to its shortest hop count.