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;
|
||||
}
|
||||
Reference in New Issue
Block a user