Files
probability/src/sim/npc.js
T
tes 051a111855 fix: prod-bundle boot, cloud-existence leak, observation gating (final review)
- Wrap main.js body in async main() to eliminate top-level await deadlock in Rollup
- Add npc.lastPlan so clouds persist after hidden arrival/destruction until analytic envelope expires
- Add maxEnvelopeArrival() helper; rewrite playerClouds() to use lastPlan for envelope-gated clouds
- Block markSeen() while player is mid-lane (no observation from hyperspace)
- Pass obsSys=null to playerClouds when player is in a lane (don't exclude visible band from hyperspace)
- Galaxy map clicks now select-only (intel); jumps via Nav buttons / gate diamonds only
- Add preview script, favicon no-op, .playwright-mcp/ and *.png to .gitignore, update README controls text
2026-06-12 15:35:20 +00:00

169 lines
6.9 KiB
JavaScript

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