import { describe, it, expect } from 'vitest'; import { cloudFor, excludeVisibleBand, totalMass, playerClouds } 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([]); }); 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))); } }); it('a cloud persists after a hidden early arrival or destruction until the envelope expires', () => { const g = chainGalaxy(); const lastPlan = chainPlan(); lastPlan.routeLanes = [0, 1, 2]; lastPlan.startSys = 0; const world = { t: 4.5, // past a truncated arrival, inside the envelope (max = 6 * 1.15 * 1.6 = 11.04) galaxy: g, npcs: [{ id: 0, name: 'Ghost', alive: false, state: 'destroyed', plan: null, lastPlan }], player: { seenNpcs: new Set([0]) }, }; expect(playerClouds(world, null).length).toBe(1); world.t = 12; // envelope exhausted: cloud finally gone expect(playerClouds(world, null)).toEqual([]); }); });