From ca32407c43fef2483a583e5c37744e57797b7187 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Fri, 12 Jun 2026 14:44:24 +0000 Subject: [PATCH] fix: compute clouds from planned route so destruction cannot leak (review) --- src/sim/clouds.js | 19 +++++++++++++++---- src/sim/npc.js | 3 +++ tests/clouds.test.js | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/sim/clouds.js b/src/sim/clouds.js index dbb0884..ce8af3b 100644 --- a/src/sim/clouds.js +++ b/src/sim/clouds.js @@ -1,5 +1,6 @@ import { VIS_BAND } from './constants.js'; import { JIT_MIN, JIT_SPAN, DELAY_MULT, hazardProbs } from './npc.js'; +import { otherEnd } from './galaxy.js'; export function totalMass(segs) { return segs.reduce((sum, s) => sum + s.w, 0); @@ -12,14 +13,22 @@ export function totalMass(segs) { // 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 []; + if (!plan) return []; + // Walk the PLANNED route, not the resolved legs: a destroyed itinerary has + // truncated legs, and using them would leak the hidden outcome via the + // cloud's shape. Synthetic plans without routeLanes fall back to legs. + const laneIds = plan.routeLanes ?? plan.legs.map((l) => l.laneId); + if (laneIds.length === 0) return []; const e = t - plan.startTime; if (e <= 0) return []; + let sys = plan.startSys ?? (plan.legs.length > 0 ? plan.legs[0].fromSys : null); + if (sys == null) return []; let minStart = 0, maxStart = 0, survival = 1; const raw = []; - for (const leg of plan.legs) { - const lane = galaxy.lanes[leg.laneId]; + for (const laneId of laneIds) { + const lane = galaxy.lanes[laneId]; + const toSys = otherEnd(lane, sys); 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 @@ -29,12 +38,13 @@ export function cloudFor(galaxy, plan, t) { 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 }); + raw.push({ laneId, fromSys: sys, toSys, f0: cf0, f1: cf1, w: (cf1 - cf0) * lane.days * survival }); } const p = hazardProbs(lane.hazard); survival *= 1 - p.destroyed; minStart += minD; maxStart += maxD; + sys = toSys; } return normalize(raw); } @@ -55,6 +65,7 @@ export function excludeVisibleBand(galaxy, segs, observedSystemId) { const bandLo = nearFrom ? 0 : 1 - VIS_BAND; const bandHi = nearFrom ? VIS_BAND : 1; const den = seg.f1 - seg.f0; + if (den <= 0) continue; // degenerate segment: drop rather than NaN-poison // keep the part below the band if (seg.f0 < bandLo) { const f1 = Math.min(seg.f1, bandLo); diff --git a/src/sim/npc.js b/src/sim/npc.js index 4165a46..908647c 100644 --- a/src/sim/npc.js +++ b/src/sim/npc.js @@ -97,6 +97,9 @@ export function planNpc(world, npc) { 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.state = 'transit'; diff --git a/tests/clouds.test.js b/tests/clouds.test.js index dc165a4..a5f890f 100644 --- a/tests/clouds.test.js +++ b/tests/clouds.test.js @@ -73,4 +73,19 @@ describe('clouds', () => { expect(cloudFor(g, chainPlan(), 99)).toEqual([]); expect(cloudFor(g, { ...chainPlan(), legs: [] }, 1)).toEqual([]); }); + + it('a hidden destroyed outcome does not change the cloud shape', () => { + const g = chainGalaxy(); + const intact = chainPlan(); + intact.routeLanes = [0, 1, 2]; + intact.startSys = 0; + const doomed = chainPlan(); + doomed.routeLanes = [0, 1, 2]; + doomed.startSys = 0; + doomed.finalOutcome = 'destroyed'; + doomed.legs = doomed.legs.slice(0, 1); // itinerary truncated at the hidden destruction + for (const t of [1, 3, 4.5]) { + expect(JSON.stringify(cloudFor(g, doomed, t))).toBe(JSON.stringify(cloudFor(g, intact, t))); + } + }); });