feat: NPC planner with seeded branch itineraries and arrivals
This commit is contained in:
+160
@@ -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;
|
||||||
|
}
|
||||||
@@ -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