From f5639343147353b492c2c69f3846fead8e2d0c3c Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Fri, 12 Jun 2026 14:38:50 +0000 Subject: [PATCH] feat: probability clouds with envelope math and band exclusion --- src/sim/clouds.js | 89 ++++++++++++++++++++++++++++++++++++++++++++ tests/clouds.test.js | 76 +++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 src/sim/clouds.js create mode 100644 tests/clouds.test.js diff --git a/src/sim/clouds.js b/src/sim/clouds.js new file mode 100644 index 0000000..dbb0884 --- /dev/null +++ b/src/sim/clouds.js @@ -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; +} diff --git a/tests/clouds.test.js b/tests/clouds.test.js new file mode 100644 index 0000000..dc165a4 --- /dev/null +++ b/tests/clouds.test.js @@ -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([]); + }); +});