feat: NPC planner with seeded branch itineraries and arrivals
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user