From 3783a8075b7fe76c816adcd10f87158bb92a52fe Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Fri, 12 Jun 2026 14:14:43 +0000 Subject: [PATCH] feat: NPC planner with seeded branch itineraries and arrivals --- src/sim/npc.js | 160 ++++++++++++++++++++++++++++++++++++++++++++++ tests/npc.test.js | 124 +++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 src/sim/npc.js create mode 100644 tests/npc.test.js diff --git a/src/sim/npc.js b/src/sim/npc.js new file mode 100644 index 0000000..972244e --- /dev/null +++ b/src/sim/npc.js @@ -0,0 +1,160 @@ +import { GOODS, DOCK_DELAY, VIS_BAND } from './constants.js'; +import { advancePlanet, priceOf, applyTrade } from './economy.js'; +import { findRoute, otherEnd } from './galaxy.js'; +import { rollFor } from './rng.js'; + +// Leg duration = lane.days * uniform(JIT_MIN, JIT_MIN+JIT_SPAN); delayed legs x DELAY_MULT. +export const JIT_MIN = 0.85; +export const JIT_SPAN = 0.3; +export const DELAY_MULT = 1.6; +const LOCAL_HOP_DAYS = 0.4; // same-system planet-to-planet trade run +const MAX_JUMPS = 3; + +export function hazardProbs(h) { + return { destroyed: h * 0.06, cargoLost: h * 0.2, delayed: h * 0.3 }; +} + +// Resolve the whole journey from the NPC's seed: route timing and hazard +// outcomes are fixed at plan time, revealed only when something observes them. +export function buildItinerary(galaxy, npc, routeLanes, startSys, t0, planId) { + let t = t0, sys = startSys, finalOutcome = 'ok'; + const legs = []; + for (let i = 0; i < routeLanes.length; i++) { + const lane = galaxy.lanes[routeLanes[i]]; + const toSys = otherEnd(lane, sys); + let dur = lane.days * (JIT_MIN + JIT_SPAN * rollFor(npc.seed, `${planId}:leg${i}:jit`)); + const p = hazardProbs(lane.hazard); + const r = rollFor(npc.seed, `${planId}:leg${i}:hazard`); + let outcome = 'safe'; + if (r < p.destroyed) outcome = 'destroyed'; + else if (r < p.destroyed + p.cargoLost) outcome = 'cargoLost'; + else if (r < p.destroyed + p.cargoLost + p.delayed) { outcome = 'delayed'; dur *= DELAY_MULT; } + legs.push({ laneId: lane.id, fromSys: sys, toSys, depart: t, arrive: t + dur, outcome }); + t += dur; + sys = toSys; + if (outcome === 'destroyed') { finalOutcome = 'destroyed'; break; } + if (outcome === 'cargoLost') finalOutcome = 'cargoLost'; + } + return { legs, finalOutcome, arriveTime: t }; +} + +// Score arbitrage options using stale news, buy cargo, fix the itinerary, +// and schedule the arrival event. Falls back to a retry if nothing pays. +export function planNpc(world, npc) { + const t = world.t; + npc.planCount = (npc.planCount || 0) + 1; + const planId = `p${npc.planCount}`; + const origin = world.galaxy.planets[npc.planetId]; + advancePlanet(origin, t); + + let best = null; + for (const dest of world.galaxy.planets) { + if (dest.id === origin.id) continue; + const hops = world.hops[origin.systemId][dest.systemId]; + if (hops > MAX_JUMPS) continue; + const known = world.news.knownPrices(dest.id, hops, t); + if (!known) continue; + const route = hops === 0 + ? { lanes: [], days: LOCAL_HOP_DAYS } + : findRoute(world.galaxy, origin.systemId, dest.systemId); + if (!route) continue; + + let survival = 1; + for (const laneId of route.lanes) { + const p = hazardProbs(world.galaxy.lanes[laneId].hazard); + survival *= 1 - p.destroyed - p.cargoLost; + } + for (const good of GOODS) { + const buyP = priceOf(good, origin.stock[good]); + const sellP = known.prices[good]; + if (sellP <= buyP * 1.15) continue; // demand a margin over price noise + const qty = Math.min(npc.capacity, Math.floor(npc.credits / buyP), Math.floor(origin.stock[good] * 0.5)); + if (qty < 1) continue; + const profit = (sellP - buyP) * qty * survival; + if (profit <= 0) continue; + const days = Math.max(LOCAL_HOP_DAYS, route.days); + const noise = 0.9 + 0.2 * rollFor(npc.seed, `${planId}:noise:${dest.id}:${good}`); + const score = (profit / days) * noise; + if (!best || score > best.score) best = { score, dest, good, qty, route }; + } + } + + if (!best) { + npc.plan = null; + npc.state = 'docked'; + world.queue.push(t + 2, 'npc-replan', { npcId: npc.id }); + return; + } + + const price = applyTrade(origin, t, best.good, -best.qty); + npc.credits = Math.round((npc.credits - price * best.qty) * 100) / 100; + npc.cargo = { good: best.good, qty: best.qty }; + + const itin = best.route.lanes.length > 0 + ? buildItinerary(world.galaxy, npc, best.route.lanes, origin.systemId, t, planId) + : { legs: [], finalOutcome: 'ok', arriveTime: t + LOCAL_HOP_DAYS }; + + npc.plan = { + id: planId, good: best.good, qty: best.qty, + originPlanet: origin.id, destPlanet: best.dest.id, + startTime: t, ...itin, + }; + npc.state = 'transit'; + world.queue.push(itin.arriveTime, 'npc-arrive', { npcId: npc.id, planId }); +} + +// Arrival: reveal the journey's outcome and commit its market effects. +export function onNpcArrive(world, { npcId, planId }) { + const npc = world.npcs[npcId]; + if (!npc.alive || !npc.plan || npc.plan.id !== planId) return; + const plan = npc.plan; + if (plan.finalOutcome === 'destroyed') { + npc.alive = false; + npc.plan = null; + world.stats.npcDestroyed++; + return; + } + const dest = world.galaxy.planets[plan.destPlanet]; + npc.planetId = dest.id; + npc.systemId = dest.systemId; + npc.state = 'docked'; + if (plan.finalOutcome !== 'cargoLost' && npc.cargo) { + const price = applyTrade(dest, world.t, npc.cargo.good, npc.cargo.qty); + npc.credits = Math.round((npc.credits + price * npc.cargo.qty) * 100) / 100; + world.stats.trades++; + } + npc.cargo = null; + npc.plan = null; + world.queue.push(world.t + DOCK_DELAY, 'npc-replan', { npcId }); +} + +// Concrete ships present in a system right now: docked ones, local hops, +// and ships within the VIS_BAND fraction of a lane adjacent to the system. +export function shipsIn(world, systemId) { + const out = []; + for (const npc of world.npcs) { + if (!npc.alive) continue; + if (npc.state === 'docked') { + if (npc.systemId === systemId) out.push({ npc, kind: 'docked', planetId: npc.planetId }); + continue; + } + if (!npc.plan) continue; + if (npc.plan.legs.length === 0) { + // same-system hop between planets + if (world.galaxy.planets[npc.plan.originPlanet].systemId === systemId) { + const f = (world.t - npc.plan.startTime) / (npc.plan.arriveTime - npc.plan.startTime); + out.push({ npc, kind: 'local-hop', progress: Math.min(1, Math.max(0, f)) }); + } + continue; + } + const leg = npc.plan.legs.find((l) => l.depart <= world.t && world.t < l.arrive); + if (!leg) continue; + const f = (world.t - leg.depart) / (leg.arrive - leg.depart); + if (leg.fromSys === systemId && f < VIS_BAND) { + out.push({ npc, kind: 'departing', laneId: leg.laneId, progress: f / VIS_BAND }); + } else if (leg.toSys === systemId && f > 1 - VIS_BAND) { + out.push({ npc, kind: 'arriving', laneId: leg.laneId, progress: (f - (1 - VIS_BAND)) / VIS_BAND }); + } + } + return out; +} diff --git a/tests/npc.test.js b/tests/npc.test.js new file mode 100644 index 0000000..c815f38 --- /dev/null +++ b/tests/npc.test.js @@ -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); + }); +});