feat: world orchestrator with living NPC economy

This commit is contained in:
2026-06-12 14:22:42 +00:00
parent 29b1aef7d3
commit b2aef9e72c
4 changed files with 148 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export function markSeen() {}
+6
View File
@@ -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;
}
+91
View File
@@ -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;
}
+50
View File
@@ -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])));
});
});