125 lines
5.2 KiB
JavaScript
125 lines
5.2 KiB
JavaScript
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);
|
|
});
|
|
});
|