feat: probability clouds with envelope math and band exclusion

This commit is contained in:
2026-06-12 14:38:50 +00:00
parent 2da0bee712
commit f563934314
2 changed files with 165 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
import { VIS_BAND } from './constants.js';
import { JIT_MIN, JIT_SPAN, DELAY_MULT, hazardProbs } from './npc.js';
export function totalMass(segs) {
return segs.reduce((sum, s) => sum + s.w, 0);
}
// The cloud is computed from the ANALYTIC envelope of the plan — possible
// durations per leg — never from the hidden seed rolls. For each leg we
// compute the feasible progress interval [f0, f1] at time t:
// f1: progress if everything so far ran fastest (earliest entry, fastest leg)
// f0: progress if everything ran slowest (latest entry, slowest leg)
// Weight ~ feasible track-days on that leg x survival probability, normalized.
export function cloudFor(galaxy, plan, t) {
if (!plan || plan.legs.length === 0) return [];
const e = t - plan.startTime;
if (e <= 0) return [];
let minStart = 0, maxStart = 0, survival = 1;
const raw = [];
for (const leg of plan.legs) {
const lane = galaxy.lanes[leg.laneId];
const minD = lane.days * JIT_MIN;
const maxD = lane.days * (JIT_MIN + JIT_SPAN) * DELAY_MULT;
// fastest possible progress: entered at minStart, moving at max speed
const f1 = (e - minStart) / minD;
// slowest possible progress: entered at maxStart, moving at min speed
const f0 = (e - maxStart) / maxD;
const cf0 = Math.min(1, Math.max(0, f0));
const cf1 = Math.min(1, Math.max(0, f1));
if (cf1 > cf0) {
raw.push({ laneId: leg.laneId, fromSys: leg.fromSys, toSys: leg.toSys, f0: cf0, f1: cf1, w: (cf1 - cf0) * lane.days * survival });
}
const p = hazardProbs(lane.hazard);
survival *= 1 - p.destroyed;
minStart += minD;
maxStart += maxD;
}
return normalize(raw);
}
// Player observes the VIS_BAND ends of every lane touching their system.
// A ship not concretely visible there cannot be there: cut those intervals
// out of the cloud and renormalize the rest.
export function excludeVisibleBand(galaxy, segs, observedSystemId) {
const out = [];
for (const seg of segs) {
const lane = galaxy.lanes[seg.laneId];
if (lane.a !== observedSystemId && lane.b !== observedSystemId) {
out.push({ ...seg });
continue;
}
// Band near the observed system, in this segment's fromSys->toSys orientation.
const nearFrom = seg.fromSys === observedSystemId;
const bandLo = nearFrom ? 0 : 1 - VIS_BAND;
const bandHi = nearFrom ? VIS_BAND : 1;
const den = seg.f1 - seg.f0;
// keep the part below the band
if (seg.f0 < bandLo) {
const f1 = Math.min(seg.f1, bandLo);
out.push({ ...seg, f1, w: seg.w * ((f1 - seg.f0) / den) });
}
// keep the part above the band
if (seg.f1 > bandHi) {
const f0 = Math.max(seg.f0, bandHi);
out.push({ ...seg, f0, w: seg.w * ((seg.f1 - f0) / den) });
}
}
return normalize(out);
}
function normalize(segs) {
const m = totalMass(segs);
if (m <= 0) return [];
return segs.map((s) => ({ ...s, w: s.w / m }));
}
// All player-visible clouds: seen, alive, in-transit NPCs on multi-system runs.
export function playerClouds(world, observedSystemId) {
const out = [];
for (const npcId of world.player.seenNpcs) {
const npc = world.npcs[npcId];
if (!npc.alive || npc.state !== 'transit' || !npc.plan) continue;
let segs = cloudFor(world.galaxy, npc.plan, world.t);
if (observedSystemId != null) segs = excludeVisibleBand(world.galaxy, segs, observedSystemId);
if (segs.length > 0) out.push({ npcId, name: npc.name, segs });
}
return out;
}