051a111855
- Wrap main.js body in async main() to eliminate top-level await deadlock in Rollup - Add npc.lastPlan so clouds persist after hidden arrival/destruction until analytic envelope expires - Add maxEnvelopeArrival() helper; rewrite playerClouds() to use lastPlan for envelope-gated clouds - Block markSeen() while player is mid-lane (no observation from hyperspace) - Pass obsSys=null to playerClouds when player is in a lane (don't exclude visible band from hyperspace) - Galaxy map clicks now select-only (intel); jumps via Nav buttons / gate diamonds only - Add preview script, favicon no-op, .playwright-mcp/ and *.png to .gitignore, update README controls text
108 lines
3.9 KiB
JavaScript
108 lines
3.9 KiB
JavaScript
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([]);
|
|
});
|
|
});
|