game: seeded planet/moon generation per system + deterministic exploration roll
This commit is contained in:
+56
-1
@@ -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<string>()
|
||||
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', '--']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>): string {
|
||||
for (;;) {
|
||||
const name = title(BODY_NAMES[Math.floor(rng() * BODY_NAMES.length)])
|
||||
if (!taken.has(name)) {
|
||||
taken.add(name)
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pick<T>(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<string>()
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user