game: seeded world generation (pure) — start region + expansion rings
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { generateRing, generateStartRegion, makeName, mulberry32 } 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('generateRing (expansion)', () => {
|
||||||
|
it('adds four systems spaced away from the anchor and existing positions', () => {
|
||||||
|
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
|
||||||
|
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('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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Pure, seeded world generation — no DB, no Date, no Math.random. The same seed always
|
||||||
|
// yields the same world, which is what makes unit tests (and save regeneration) deterministic.
|
||||||
|
|
||||||
|
export interface WorldSystem {
|
||||||
|
name: string
|
||||||
|
x: number // 0–100 coordinate space, rendered directly by NodeGraph
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
export function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0
|
||||||
|
return () => {
|
||||||
|
a |= 0
|
||||||
|
a = (a + 0x6d2b79f5) | 0
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIRST = [
|
||||||
|
'KEPLER', 'VESPER', 'HOLLOW', 'CINDER', 'LANTERN', 'SABLE', 'ORPHEUS', 'MIRE',
|
||||||
|
'THALASSA', 'KUIPER', 'AURELLE', 'BRIMSTONE', 'EMBER', 'FROST', 'GALE', 'IRON',
|
||||||
|
]
|
||||||
|
const SECOND = ['DRIFT', 'REACH', 'GATE', 'FIELD', 'VOID', 'DEEP', 'BEND', 'SHARD']
|
||||||
|
|
||||||
|
function title(word: string): string {
|
||||||
|
return word.charAt(0) + word.slice(1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picks a name not in `taken` and adds it, so callers can't accidentally reuse one.
|
||||||
|
export function makeName(rng: () => number, taken: Set<string>): string {
|
||||||
|
for (;;) {
|
||||||
|
const name = `${title(FIRST[Math.floor(rng() * FIRST.length)])} ${title(SECOND[Math.floor(rng() * SECOND.length)])}`
|
||||||
|
if (!taken.has(name)) {
|
||||||
|
taken.add(name)
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(v: number): number {
|
||||||
|
return Math.min(96, Math.max(4, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Places `count` systems on a ring around (cx,cy), nudging each candidate until it is at
|
||||||
|
// least MIN_SPACING away from every known position. After 12 tries it accepts the last
|
||||||
|
// candidate anyway — ponytail: a slightly crowded node beats an infinite loop; with ≤6
|
||||||
|
// rings per region and spacing 16 in a 0–100 box this never triggers in practice.
|
||||||
|
function placeRing(
|
||||||
|
rng: () => number,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
count: number,
|
||||||
|
takenNames: Set<string>,
|
||||||
|
takenPositions: WorldSystem[],
|
||||||
|
): WorldSystem[] {
|
||||||
|
const placed: WorldSystem[] = []
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const baseAngle = (i / count) * Math.PI * 2 + (rng() - 0.5) * 0.6
|
||||||
|
let x = cx
|
||||||
|
let y = cy
|
||||||
|
for (let attempt = 0; attempt < 12; attempt++) {
|
||||||
|
const angle = baseAngle + attempt * 0.45
|
||||||
|
const radius = 26 + rng() * 8
|
||||||
|
x = clamp(cx + Math.cos(angle) * radius)
|
||||||
|
y = clamp(cy + Math.sin(angle) * radius)
|
||||||
|
if ([...takenPositions, ...placed].every((p) => Math.hypot(p.x - x, p.y - y) >= MIN_SPACING)) break
|
||||||
|
}
|
||||||
|
placed.push({ name: makeName(rng, takenNames), x, y })
|
||||||
|
}
|
||||||
|
return placed
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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][] {
|
||||||
|
const edges: [number, number][] = []
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
edges.push([0, i + 1])
|
||||||
|
edges.push([i + 1, ((i + 1) % n) + 1])
|
||||||
|
}
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartRegion {
|
||||||
|
start: WorldSystem
|
||||||
|
neighbors: WorldSystem[]
|
||||||
|
edges: [number, number][] // indices into [start, ...neighbors]
|
||||||
|
}
|
||||||
|
|
||||||
|
// New game: the starting system at chart centre plus a ring of undiscovered neighbours.
|
||||||
|
export function generateStartRegion(rng: () => number): StartRegion {
|
||||||
|
const taken = new Set<string>()
|
||||||
|
const start: WorldSystem = { name: makeName(rng, taken), x: START_X, y: START_Y }
|
||||||
|
const neighbors = placeRing(rng, START_X, START_Y, START_RING, taken, [start])
|
||||||
|
return { start, neighbors, edges: ringEdges(neighbors.length) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RingRegion {
|
||||||
|
systems: WorldSystem[]
|
||||||
|
edges: [number, number][] // indices into [anchor, ...systems] — index 0 is the anchor itself
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export function generateRing(
|
||||||
|
rng: () => number,
|
||||||
|
anchorX: number,
|
||||||
|
anchorY: number,
|
||||||
|
takenNames: Set<string>,
|
||||||
|
takenPositions: WorldSystem[],
|
||||||
|
): RingRegion {
|
||||||
|
const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions)
|
||||||
|
return { systems, edges: ringEdges(systems.length) }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user