051a111855
- 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
169 lines
6.9 KiB
JavaScript
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;
|
|
}
|