feat: NPC planner with seeded branch itineraries and arrivals

This commit is contained in:
2026-06-12 14:14:43 +00:00
parent c31c80c00c
commit 3783a8075b
2 changed files with 284 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest';
import { planNpc, buildItinerary, hazardProbs, shipsIn, JIT_MIN, JIT_SPAN, DELAY_MULT } from '../src/sim/npc.js';
import { generateGalaxy, hopMatrix } from '../src/sim/galaxy.js';
import { NewsLedger } from '../src/sim/news.js';
import { EventQueue } from '../src/sim/events.js';
function makeFixtureWorld(seed = 'npc-test') {
const galaxy = generateGalaxy(seed, { systemCount: 8 });
const news = new NewsLedger();
news.record(galaxy, 0);
return { seed, t: 0, galaxy, hops: hopMatrix(galaxy), news, queue: new EventQueue(), npcs: [], stats: { trades: 0, npcDestroyed: 0 } };
}
function makeNpc(world, planetId = 0) {
const npc = {
id: 0, seed: 'w:test:npc:0', name: 'Tester', alive: true,
credits: 5000, capacity: 30, cargo: null,
planetId, systemId: world.galaxy.planets[planetId].systemId,
state: 'docked', plan: null, planCount: 0,
};
world.npcs.push(npc);
return npc;
}
describe('npc planner', () => {
it('plans a profitable trade when an obvious gradient exists', () => {
const world = makeFixtureWorld();
const origin = world.galaxy.planets[0];
// A destination within 3 jumps that is starving for ore:
const dest = world.galaxy.planets.find(
(p) => p.id !== 0 && world.hops[origin.systemId][p.systemId] <= 3
);
origin.stock.ore = 800; // ore dirt cheap here
dest.stock.ore = 5; // ore precious there
world.news.record(world.galaxy, 0); // refresh news to reflect the gradient
const npc = makeNpc(world);
planNpc(world, npc);
expect(npc.plan).not.toBeNull();
expect(npc.state).toBe('transit');
expect(npc.cargo.qty).toBeGreaterThanOrEqual(1);
expect(npc.credits).toBeLessThan(5000); // paid for the cargo
expect(npc.plan.arriveTime).toBeGreaterThan(world.t);
expect(world.queue.size).toBe(1); // npc-arrive scheduled
expect(world.queue.peek().type).toBe('npc-arrive');
});
it('is deterministic: identical fixture -> identical plan', () => {
const mk = () => {
const world = makeFixtureWorld();
world.galaxy.planets[0].stock.ore = 800;
world.galaxy.planets[1].stock.ore = 5;
world.news.record(world.galaxy, 0);
const npc = makeNpc(world);
planNpc(world, npc);
return npc.plan;
};
expect(JSON.stringify(mk())).toBe(JSON.stringify(mk()));
});
it('schedules a retry instead of a plan when nothing is profitable', () => {
const world = makeFixtureWorld();
const npc = makeNpc(world);
npc.credits = 0; // cannot afford anything
planNpc(world, npc);
expect(npc.plan).toBeNull();
expect(world.queue.peek().type).toBe('npc-replan');
});
it('buildItinerary timing stays inside the analytic envelope', () => {
const world = makeFixtureWorld();
const lane = world.galaxy.lanes[0];
for (let i = 0; i < 50; i++) {
const npc = { seed: `env:${i}` };
const itin = buildItinerary(world.galaxy, npc, [lane.id], lane.a, 0, 'p1');
const leg = itin.legs[0];
expect(leg.arrive - leg.depart).toBeGreaterThanOrEqual(lane.days * JIT_MIN - 1e-9);
expect(leg.arrive - leg.depart).toBeLessThanOrEqual(lane.days * (JIT_MIN + JIT_SPAN) * DELAY_MULT + 1e-9);
}
});
it('hazard outcomes over many seeds match hazardProbs statistically', () => {
const world = makeFixtureWorld();
const lane = { ...world.galaxy.lanes[0], hazard: 0.5 };
world.galaxy.lanes[0] = lane;
const probs = hazardProbs(0.5);
let destroyed = 0, lost = 0;
const N = 800;
for (let i = 0; i < N; i++) {
const itin = buildItinerary(world.galaxy, { seed: `hz:${i}` }, [lane.id], lane.a, 0, 'p1');
if (itin.finalOutcome === 'destroyed') destroyed++;
if (itin.finalOutcome === 'cargoLost') lost++;
}
expect(destroyed / N).toBeGreaterThan(probs.destroyed * 0.5);
expect(destroyed / N).toBeLessThan(probs.destroyed * 1.8);
expect(lost / N).toBeGreaterThan(probs.cargoLost * 0.5);
expect(lost / N).toBeLessThan(probs.cargoLost * 1.8);
});
it('shipsIn reports docked ships and ships in the visibility band of a lane', () => {
const world = makeFixtureWorld();
const npc = makeNpc(world);
expect(shipsIn(world, npc.systemId).map((s) => s.npc.id)).toContain(0);
// Put the npc in transit at 5% along a lane leaving its system.
const laneId = world.galaxy.systems[npc.systemId].lanes[0];
const lane = world.galaxy.lanes[laneId];
const toSys = lane.a === npc.systemId ? lane.b : lane.a;
npc.state = 'transit';
npc.plan = {
id: 'p1', legs: [{ laneId, fromSys: npc.systemId, toSys, depart: 0, arrive: 10, outcome: 'safe' }],
startTime: 0, arriveTime: 10, finalOutcome: 'ok', originPlanet: 0, destPlanet: 1,
};
world.t = 0.5; // f = 0.05, inside departure band
const seen = shipsIn(world, npc.systemId);
expect(seen.some((s) => s.npc.id === 0 && s.kind === 'departing')).toBe(true);
world.t = 5; // mid-lane: visible nowhere
expect(shipsIn(world, npc.systemId)).toHaveLength(0);
expect(shipsIn(world, toSys)).toHaveLength(0);
world.t = 9.5; // f = 0.95: arriving band of the far system
expect(shipsIn(world, toSys).some((s) => s.npc.id === 0 && s.kind === 'arriving')).toBe(true);
});
});