fix: compute clouds from planned route so destruction cannot leak (review)
This commit is contained in:
+15
-4
@@ -1,5 +1,6 @@
|
|||||||
import { VIS_BAND } from './constants.js';
|
import { VIS_BAND } from './constants.js';
|
||||||
import { JIT_MIN, JIT_SPAN, DELAY_MULT, hazardProbs } from './npc.js';
|
import { JIT_MIN, JIT_SPAN, DELAY_MULT, hazardProbs } from './npc.js';
|
||||||
|
import { otherEnd } from './galaxy.js';
|
||||||
|
|
||||||
export function totalMass(segs) {
|
export function totalMass(segs) {
|
||||||
return segs.reduce((sum, s) => sum + s.w, 0);
|
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)
|
// f0: progress if everything ran slowest (latest entry, slowest leg)
|
||||||
// Weight ~ feasible track-days on that leg x survival probability, normalized.
|
// Weight ~ feasible track-days on that leg x survival probability, normalized.
|
||||||
export function cloudFor(galaxy, plan, t) {
|
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;
|
const e = t - plan.startTime;
|
||||||
if (e <= 0) return [];
|
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;
|
let minStart = 0, maxStart = 0, survival = 1;
|
||||||
const raw = [];
|
const raw = [];
|
||||||
for (const leg of plan.legs) {
|
for (const laneId of laneIds) {
|
||||||
const lane = galaxy.lanes[leg.laneId];
|
const lane = galaxy.lanes[laneId];
|
||||||
|
const toSys = otherEnd(lane, sys);
|
||||||
const minD = lane.days * JIT_MIN;
|
const minD = lane.days * JIT_MIN;
|
||||||
const maxD = lane.days * (JIT_MIN + JIT_SPAN) * DELAY_MULT;
|
const maxD = lane.days * (JIT_MIN + JIT_SPAN) * DELAY_MULT;
|
||||||
// fastest possible progress: entered at minStart, moving at max speed
|
// 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 cf0 = Math.min(1, Math.max(0, f0));
|
||||||
const cf1 = Math.min(1, Math.max(0, f1));
|
const cf1 = Math.min(1, Math.max(0, f1));
|
||||||
if (cf1 > cf0) {
|
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);
|
const p = hazardProbs(lane.hazard);
|
||||||
survival *= 1 - p.destroyed;
|
survival *= 1 - p.destroyed;
|
||||||
minStart += minD;
|
minStart += minD;
|
||||||
maxStart += maxD;
|
maxStart += maxD;
|
||||||
|
sys = toSys;
|
||||||
}
|
}
|
||||||
return normalize(raw);
|
return normalize(raw);
|
||||||
}
|
}
|
||||||
@@ -55,6 +65,7 @@ export function excludeVisibleBand(galaxy, segs, observedSystemId) {
|
|||||||
const bandLo = nearFrom ? 0 : 1 - VIS_BAND;
|
const bandLo = nearFrom ? 0 : 1 - VIS_BAND;
|
||||||
const bandHi = nearFrom ? VIS_BAND : 1;
|
const bandHi = nearFrom ? VIS_BAND : 1;
|
||||||
const den = seg.f1 - seg.f0;
|
const den = seg.f1 - seg.f0;
|
||||||
|
if (den <= 0) continue; // degenerate segment: drop rather than NaN-poison
|
||||||
// keep the part below the band
|
// keep the part below the band
|
||||||
if (seg.f0 < bandLo) {
|
if (seg.f0 < bandLo) {
|
||||||
const f1 = Math.min(seg.f1, bandLo);
|
const f1 = Math.min(seg.f1, bandLo);
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ export function planNpc(world, npc) {
|
|||||||
npc.plan = {
|
npc.plan = {
|
||||||
id: planId, good: best.good, qty: best.qty,
|
id: planId, good: best.good, qty: best.qty,
|
||||||
originPlanet: origin.id, destPlanet: best.dest.id,
|
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,
|
startTime: t, ...itin,
|
||||||
};
|
};
|
||||||
npc.state = 'transit';
|
npc.state = 'transit';
|
||||||
|
|||||||
@@ -73,4 +73,19 @@ describe('clouds', () => {
|
|||||||
expect(cloudFor(g, chainPlan(), 99)).toEqual([]);
|
expect(cloudFor(g, chainPlan(), 99)).toEqual([]);
|
||||||
expect(cloudFor(g, { ...chainPlan(), legs: [] }, 1)).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)));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user