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 * survival - buyP) * qty; // EV: buy cost is sunk even if cargo is lost 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, // Planned route (public knowledge for clouds) — unlike legs, never // truncated by a hidden 'destroyed' roll. routeLanes: best.route.lanes, startSys: origin.systemId, startTime: t, ...itin, }; npc.lastPlan = null; // superseded; v1 simplification: a fresh cloud implies the previous run ended 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.lastPlan = plan; // cloud persists from the envelope until observed/expired npc.plan = null; npc.cargo = null; npc.state = 'destroyed'; 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.lastPlan = plan; // unobserved arrival: the player's cloud persists a while 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; }