From cbab17047b50e37dee668f1a1d7127d6e4cc26f6 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Mon, 28 Sep 2026 22:04:23 +0000 Subject: [PATCH] game: seeded planet/moon generation per system + deterministic exploration roll --- lib/game/world.test.ts | 57 ++++++++++++++++++++++- lib/game/world.ts | 102 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/lib/game/world.test.ts b/lib/game/world.test.ts index 4987da1..0c37d78 100644 --- a/lib/game/world.test.ts +++ b/lib/game/world.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { generateRing, generateStartRegion, makeName, mulberry32 } from './world' +import { bodyRng, explorationRng, generatePlanets, generateRing, generateStartRegion, makeName, mulberry32, rollExploration } from './world' describe('generateStartRegion', () => { const region = generateStartRegion(mulberry32(42)) @@ -77,3 +77,58 @@ describe('generateRing (expansion)', () => { expect(taken.size).toBe(40) }) }) + +describe('generatePlanets', () => { + it('gives every system 2–5 planets and keeps body names unique within the system', () => { + for (const seed of [1, 7, 42, 999]) { + const planets = generatePlanets(bodyRng(String(seed), seed)) + expect(planets.length).toBeGreaterThanOrEqual(2) + expect(planets.length).toBeLessThanOrEqual(5) + + const names: string[] = [] + for (const p of planets) { + names.push(p.name, ...p.moons.map((m) => m.name)) + expect(['TERRESTRIAL', 'GAS GIANT', 'ROCKY', 'ICE WORLD', 'VOLCANIC', 'OCEAN', 'ASTEROID FIELD']).toContain(p.type) + for (const m of p.moons) { + expect(['MOON', 'ICE MOON', 'VOLCANIC MOON']).toContain(m.type) + } + } + expect(new Set(names).size).toBe(names.length) // planets + moons share one name pool per system + } + }) + + it('is deterministic per (seed, system id) and differs across systems', () => { + const a = generatePlanets(bodyRng('42', 7)) + expect(generatePlanets(bodyRng('42', 7))).toEqual(a) // same save+system → same bodies + const b = generatePlanets(bodyRng('43', 7)) + expect(JSON.stringify(b)).not.toBe(JSON.stringify(a)) // different seed → different world + }) +}) + +describe('rollExploration', () => { + it('is deterministic for the same save+planet and yields valid items', () => { + const first = rollExploration(explorationRng('42', 13)) + expect(rollExploration(explorationRng('42', 13))).toEqual(first) + + for (const item of first) { + expect(['resource', 'part']).toContain(item.kind) + if (item.kind === 'resource') { + expect(item.qty).toBeGreaterThanOrEqual(1) + expect(item.qty).toBeLessThanOrEqual(4) + } else { + expect(item.qty).toBe(1) + } + } + }) + + it('across many planets produces every outcome shape: resource, part, both, or nothing', () => { + const shapes = new Set() + for (let planetId = 1; planetId <= 200; planetId++) { + const found = rollExploration(explorationRng('42', planetId)) + const hasResource = found.some((f) => f.kind === 'resource') + const hasPart = found.some((f) => f.kind === 'part') + shapes.add(`${hasResource ? 'r' : '-'}${hasPart ? 'p' : '-'}`) + } + expect(shapes).toEqual(new Set(['rp', 'r-', '-p', '--'])) + }) +}) diff --git a/lib/game/world.ts b/lib/game/world.ts index 24fba68..0807953 100644 --- a/lib/game/world.ts +++ b/lib/game/world.ts @@ -123,3 +123,105 @@ export function generateRing( const systems = placeRing(rng, anchorX, anchorY, EXPAND_RING, takenNames, takenPositions) return { systems, edges: ringEdges(systems.length) } } + +// --- Bodies (planets + moons) and exploration ------------------------------- + +export interface WorldMoon { + name: string + type: string + hazard: boolean +} + +export interface WorldPlanet { + name: string + type: string + hazard: boolean + moons: WorldMoon[] +} + +// Derive an independent PRNG stream for one entity from the save seed, so a system's bodies +// and a planet's exploration haul are reproducible offline (tests recompute them directly). +export function bodyRng(seed: string, systemId: number): () => number { + return mulberry32((Number(seed) + Math.imul(systemId, 0x9e3779b1)) >>> 0) +} + +export function explorationRng(seed: string, planetId: number): () => number { + return mulberry32((Number(seed) + Math.imul(planetId, 0x85ebca6b)) >>> 0) +} + +const BODY_NAMES = [ + 'AURELLE', 'BRIMSTONE', 'CINDERROCK', 'DRIFTBAND', 'PALEMOON', 'ASHFALL', 'VESPERINE', 'KORR', + 'MIRELLA', 'SABLET', 'EMBERLING', 'FROSTHOLD', 'GALEN', 'IRONVEIN', 'JUNO', 'KESTREL', + 'LUMEN', 'NADIR', 'OPALINE', 'PYRE', 'QUILL', 'RUSTBARK', 'SIROCCO', 'TARN', + 'UMBRA', 'VELVETINE', 'WYRM', 'ZEPHYR', 'HALCYON', 'MORROW', 'ONYX', 'THALASSA', +] + +const PLANET_TYPES = ['TERRESTRIAL', 'GAS GIANT', 'ROCKY', 'ICE WORLD', 'VOLCANIC', 'OCEAN', 'ASTEROID FIELD'] +const MOON_TYPES = ['MOON', 'ICE MOON', 'VOLCANIC MOON'] + +const HAZARD_CHANCE = 0.25 +const MOON_CHANCE = 0.6 // a planet has any moons at all +const PLANET_COUNT_MIN = 2 +const PLANET_COUNT_SPREAD = 4 // 2..5 planets per system + +// A body name unique within its system (planets and moons share one pool). Max bodies per +// system is 5 + 5*3 = 20 < BODY_NAMES.length, so the loop always terminates. +function makeBodyName(rng: () => number, taken: Set): string { + for (;;) { + const name = title(BODY_NAMES[Math.floor(rng() * BODY_NAMES.length)]) + if (!taken.has(name)) { + taken.add(name) + return name + } + } +} + +function pick(rng: () => number, arr: T[]): T { + return arr[Math.floor(rng() * arr.length)] +} + +// The bodies of one system: 2–5 planets, some with 1–3 moons. Pure — the caller derives the +// rng from (save seed, system id) so a given system always gets the same bodies. +export function generatePlanets(rng: () => number): WorldPlanet[] { + const taken = new Set() + const planets: WorldPlanet[] = [] + for (let i = 0; i < PLANET_COUNT_MIN + Math.floor(rng() * PLANET_COUNT_SPREAD); i++) { + const name = makeBodyName(rng, taken) + const type = pick(rng, PLANET_TYPES) + const hazard = rng() < HAZARD_CHANCE + const moons: WorldMoon[] = [] + if (rng() < MOON_CHANCE) { + for (let m = 0; m < 1 + Math.floor(rng() * 3); m++) { + moons.push({ name: makeBodyName(rng, taken), type: pick(rng, MOON_TYPES), hazard: rng() < HAZARD_CHANCE }) + } + } + planets.push({ name, type, hazard, moons }) + } + return planets +} + +export interface FoundItem { + kind: 'resource' | 'part' + name: string + qty: number +} + +const RESOURCE_NAMES = ['IRON ORE', 'CRYSTAL SHARDS', 'GAS CELLS', 'WATER ICE', 'RARE EARTH'] +const PART_NAMES = ['NAV MODULE', 'HULL PLATE', 'REACTOR CORE', 'COMMS ARRAY', 'ENGINE TUNING'] + +const RESOURCE_CHANCE = 0.7 +const PART_CHANCE = 0.3 // parts are rarer than resources + +// The haul from scanning one body: a resource and/or a part, or nothing (~21% of scans). +// Pure — the caller derives the rng from (save seed, planet id), so the same save+planet +// always yields the same outcome. +export function rollExploration(rng: () => number): FoundItem[] { + const found: FoundItem[] = [] + if (rng() < RESOURCE_CHANCE) { + found.push({ kind: 'resource', name: pick(rng, RESOURCE_NAMES), qty: 1 + Math.floor(rng() * 4) }) // 1..4 + } + if (rng() < PART_CHANCE) { + found.push({ kind: 'part', name: pick(rng, PART_NAMES), qty: 1 }) + } + return found +}