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