feat: world orchestrator with living NPC economy
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export function markSeen() {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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])));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user