feat: probability clouds with envelope math and band exclusion
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { cloudFor, excludeVisibleBand, totalMass } from '../src/sim/clouds.js';
|
||||||
|
|
||||||
|
// Synthetic 3-lane chain: systems 0-1-2-3, each lane 2 days.
|
||||||
|
function chainGalaxy() {
|
||||||
|
return {
|
||||||
|
lanes: [
|
||||||
|
{ id: 0, a: 0, b: 1, days: 2, hazard: 0.1 },
|
||||||
|
{ id: 1, a: 1, b: 2, days: 2, hazard: 0.1 },
|
||||||
|
{ id: 2, a: 2, b: 3, days: 2, hazard: 0.1 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function chainPlan() {
|
||||||
|
return {
|
||||||
|
id: 'p1', startTime: 0, arriveTime: 6, finalOutcome: 'ok',
|
||||||
|
originPlanet: 0, destPlanet: 1,
|
||||||
|
legs: [
|
||||||
|
{ laneId: 0, fromSys: 0, toSys: 1, depart: 0, arrive: 2, outcome: 'safe' },
|
||||||
|
{ laneId: 1, fromSys: 1, toSys: 2, depart: 2, arrive: 4, outcome: 'safe' },
|
||||||
|
{ laneId: 2, fromSys: 2, toSys: 3, depart: 4, arrive: 6, outcome: 'safe' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackLength = (segs, galaxy) =>
|
||||||
|
segs.reduce((sum, s) => sum + (s.f1 - s.f0) * galaxy.lanes[s.laneId].days, 0);
|
||||||
|
|
||||||
|
describe('clouds', () => {
|
||||||
|
it('total mass is 1 while the journey is plausibly in progress', () => {
|
||||||
|
const g = chainGalaxy();
|
||||||
|
const plan = chainPlan();
|
||||||
|
for (const t of [0.5, 1.7, 3, 4.5]) {
|
||||||
|
const segs = cloudFor(g, plan, t);
|
||||||
|
expect(segs.length).toBeGreaterThan(0);
|
||||||
|
expect(totalMass(segs)).toBeCloseTo(1, 5);
|
||||||
|
for (const s of segs) {
|
||||||
|
expect(s.f0).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(s.f1).toBeLessThanOrEqual(1);
|
||||||
|
expect(s.f1).toBeGreaterThan(s.f0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uncertainty grows with elapsed legs (cloud covers more track later)', () => {
|
||||||
|
const g = chainGalaxy();
|
||||||
|
const plan = chainPlan();
|
||||||
|
const early = trackLength(cloudFor(g, plan, 1.0), g);
|
||||||
|
const late = trackLength(cloudFor(g, plan, 4.2), g);
|
||||||
|
expect(late).toBeGreaterThan(early);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mid-journey, mass spans more than one lane', () => {
|
||||||
|
const g = chainGalaxy();
|
||||||
|
const segs = cloudFor(g, chainPlan(), 3.5);
|
||||||
|
expect(new Set(segs.map((s) => s.laneId)).size).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludeVisibleBand removes the observed band and renormalizes', () => {
|
||||||
|
const segs = [
|
||||||
|
{ laneId: 0, fromSys: 0, toSys: 1, f0: 0.0, f1: 0.5, w: 0.5 },
|
||||||
|
{ laneId: 0, fromSys: 0, toSys: 1, f0: 0.5, f1: 1.0, w: 0.5 },
|
||||||
|
];
|
||||||
|
// Player sits in system 1: the band f > 0.85 on this lane is visible.
|
||||||
|
const out = excludeVisibleBand({ lanes: [{ id: 0, a: 0, b: 1, days: 2 }] }, segs, 1);
|
||||||
|
expect(totalMass(out)).toBeCloseTo(1, 5);
|
||||||
|
for (const s of out) expect(s.f1).toBeLessThanOrEqual(0.85 + 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty for finished or empty plans', () => {
|
||||||
|
const g = chainGalaxy();
|
||||||
|
expect(cloudFor(g, chainPlan(), 99)).toEqual([]);
|
||||||
|
expect(cloudFor(g, { ...chainPlan(), legs: [] }, 1)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user