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); }); });