diff --git a/src/sim/knowledge.js b/src/sim/knowledge.js new file mode 100644 index 0000000..272ecbd --- /dev/null +++ b/src/sim/knowledge.js @@ -0,0 +1 @@ +export function markSeen() {} diff --git a/src/sim/player.js b/src/sim/player.js new file mode 100644 index 0000000..e044442 --- /dev/null +++ b/src/sim/player.js @@ -0,0 +1,6 @@ +export function onPlayerArrive() {} +export function playerSystem(world) { + const loc = world.player.loc; + if (loc.kind === 'docked') return world.galaxy.planets[loc.planetId].systemId; + return loc.systemId ?? null; +} diff --git a/src/sim/world.js b/src/sim/world.js new file mode 100644 index 0000000..95f457c --- /dev/null +++ b/src/sim/world.js @@ -0,0 +1,91 @@ +import { OBSERVE_INTERVAL, PLAYER_START_CREDITS, PLAYER_CAPACITY } from './constants.js'; +import { generateGalaxy, hopMatrix } from './galaxy.js'; +import { NewsLedger } from './news.js'; +import { EventQueue } from './events.js'; +import { makeRng, hash32 } from './rng.js'; +import { planNpc, onNpcArrive } from './npc.js'; +import { onPlayerArrive } from './player.js'; +import { markSeen } from './knowledge.js'; + +const NPC_FIRST_NAMES = ['Vask', 'Orla', 'Dex', 'Mira', 'Joss', 'Kade', 'Nyra', 'Tarn', 'Sela', 'Bram']; + +export function createWorld(seed, { systemCount = 25, npcCount = 120 } = {}) { + const galaxy = generateGalaxy(seed, { systemCount }); + const rng = makeRng(hash32('world:' + seed)); + const news = new NewsLedger(); + news.record(galaxy, 0); + + const npcs = []; + for (let i = 0; i < npcCount; i++) { + const planet = galaxy.planets[Math.floor(rng() * galaxy.planets.length)]; + npcs.push({ + id: i, + seed: `w:${seed}:npc:${i}`, + name: `${NPC_FIRST_NAMES[i % NPC_FIRST_NAMES.length]}-${i}`, + alive: true, + credits: Math.floor(1500 + rng() * 2000), + capacity: 20 + Math.floor(rng() * 40), + cargo: null, + planetId: planet.id, + systemId: planet.systemId, + state: 'docked', + plan: null, + planCount: 0, + }); + } + + const startPlanet = galaxy.planets[0]; + const world = { + seed, t: 0, galaxy, hops: hopMatrix(galaxy), news, npcs, + queue: new EventQueue(), + stats: { trades: 0, npcDestroyed: 0 }, + log: [], + player: { + credits: PLAYER_START_CREDITS, + capacity: PLAYER_CAPACITY, + cargo: {}, + loc: { kind: 'docked', planetId: startPlanet.id }, + intent: null, + tripCount: 0, + alive: true, + won: false, + priceBook: {}, + seenNpcs: new Set(), + }, + }; + + // Stagger initial NPC planning across the first day. + for (const npc of npcs) world.queue.push(rng(), 'npc-replan', { npcId: npc.id }); + world.queue.push(1, 'snapshot', {}); + world.queue.push(OBSERVE_INTERVAL, 'observe', {}); + return world; +} + +export function pushLog(world, msg) { + world.log.push(`[day ${world.t.toFixed(1)}] ${msg}`); + if (world.log.length > 50) world.log.shift(); +} + +const HANDLERS = { + 'npc-replan': (world, data) => planNpc(world, world.npcs[data.npcId]), + 'npc-arrive': (world, data) => onNpcArrive(world, data), + 'player-arrive': (world, data) => onPlayerArrive(world, data), + snapshot: (world) => { + world.news.record(world.galaxy, world.t); + world.queue.push(world.t + 1, 'snapshot', {}); + }, + observe: (world) => { + markSeen(world); + world.queue.push(world.t + OBSERVE_INTERVAL, 'observe', {}); + }, +}; + +// Advance the world to absolute time toTime, processing all due events in order. +export function advance(world, toTime) { + while (world.queue.size > 0 && world.queue.peek().time <= toTime) { + const ev = world.queue.pop(); + world.t = ev.time; + HANDLERS[ev.type](world, ev.data); + } + world.t = toTime; +} diff --git a/tests/world.test.js b/tests/world.test.js new file mode 100644 index 0000000..f978562 --- /dev/null +++ b/tests/world.test.js @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { createWorld, advance } from '../src/sim/world.js'; + +describe('world', () => { + it('creates a populated world with player at a planet', () => { + const w = createWorld('alpha', { systemCount: 12, npcCount: 30 }); + expect(w.npcs).toHaveLength(30); + expect(w.galaxy.systems).toHaveLength(12); + expect(w.player.loc.kind).toBe('docked'); + expect(w.player.credits).toBeGreaterThan(0); + for (const npc of w.npcs) { + expect(npc.alive).toBe(true); + expect(w.galaxy.planets[npc.planetId]).toBeTruthy(); + } + }); + + it('advance processes events; NPCs actually trade', () => { + const w = createWorld('alpha', { systemCount: 12, npcCount: 30 }); + advance(w, 40); + expect(w.t).toBe(40); + expect(w.stats.trades).toBeGreaterThan(30); // every npc averages >1 completed trade + }); + + it('is deterministic end-to-end', () => { + const run = () => { + const w = createWorld('alpha', { systemCount: 12, npcCount: 30 }); + advance(w, 40); + return w.npcs.map((n) => [n.credits, n.planetId, n.alive]); + }; + expect(JSON.stringify(run())).toBe(JSON.stringify(run())); + }); + + it('different seeds diverge', () => { + const w1 = createWorld('alpha', { systemCount: 12, npcCount: 30 }); + const w2 = createWorld('beta', { systemCount: 12, npcCount: 30 }); + advance(w1, 20); + advance(w2, 20); + expect(JSON.stringify(w1.npcs.map((n) => n.credits))) + .not.toBe(JSON.stringify(w2.npcs.map((n) => n.credits))); + }); + + it('advance is incremental: many small steps equal one big step', () => { + const a = createWorld('alpha', { systemCount: 12, npcCount: 30 }); + const b = createWorld('alpha', { systemCount: 12, npcCount: 30 }); + advance(a, 20); + for (let t = 0.5; t <= 20.0001; t += 0.5) advance(b, t); + expect(JSON.stringify(b.npcs.map((n) => [n.credits, n.planetId]))) + .toBe(JSON.stringify(a.npcs.map((n) => [n.credits, n.planetId]))); + }); +});