From 1634c0602eb9143f40b814874e99f9e3de2d1d5b Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 21:25:14 +0000 Subject: [PATCH] =?UTF-8?q?game:=20seeded=20world=20generation=20(pure)=20?= =?UTF-8?q?=E2=80=94=20start=20region=20+=20expansion=20rings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/game/world.test.ts | 79 ++++++++++++++++++++++++++ lib/game/world.ts | 125 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 lib/game/world.test.ts create mode 100644 lib/game/world.ts diff --git a/lib/game/world.test.ts b/lib/game/world.test.ts new file mode 100644 index 0000000..4987da1 --- /dev/null +++ b/lib/game/world.test.ts @@ -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() + for (let i = 0; i < 40; i++) makeName(rng, taken) + expect(taken.size).toBe(40) + }) +}) diff --git a/lib/game/world.ts b/lib/game/world.ts new file mode 100644 index 0000000..24fba68 --- /dev/null +++ b/lib/game/world.ts @@ -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 { + 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, + 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() + 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, + takenPositions: WorldSystem[], +): RingRegion { + const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions) + return { systems, edges: ringEdges(systems.length) } +}