One shared window (journey/card-gaps.ts, CARD_GAP) describes the open road after each chapter's panel in section-local progress; the district relight and cutover, the last yard lift and the relay station's beams now key off it instead of their own numbers, and the chapter margin widens from 44vh to 48vh so the gap reads as a stretch of road. Unit tests prove every payoff's peak sits inside the window for every chapter span.
156 lines
6.4 KiB
TypeScript
156 lines
6.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { SceneResourceBag } from '@/scene/resources';
|
|
import {
|
|
createLightning,
|
|
createMaintenanceWindow,
|
|
LIGHTNING,
|
|
MAINTENANCE_RAIN_PEAK,
|
|
maintenanceTimeline,
|
|
type MaintenanceFrame,
|
|
} from '@/scene/world/maintenance-window';
|
|
|
|
const sweep = (from: number, to: number, steps = 400): number[] =>
|
|
Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps);
|
|
|
|
describe('the 03:12 window timeline', () => {
|
|
it('powers down, re-lights and moves packets monotonically with progress', () => {
|
|
let previous: MaintenanceFrame | null = null;
|
|
for (const p of sweep(0, 1)) {
|
|
const frame = maintenanceTimeline(p);
|
|
if (previous !== null) {
|
|
expect(frame.dim).toBeGreaterThanOrEqual(previous.dim);
|
|
expect(frame.relight).toBeGreaterThanOrEqual(previous.relight);
|
|
expect(frame.packetPhase).toBeGreaterThanOrEqual(previous.packetPhase);
|
|
expect(frame.offsite).toBeGreaterThanOrEqual(previous.offsite);
|
|
}
|
|
previous = frame;
|
|
}
|
|
expect(maintenanceTimeline(0)).toMatchObject({ dim: 0, relight: 0, packetPhase: 0, step: 'open' });
|
|
expect(maintenanceTimeline(1)).toMatchObject({ dim: 1, relight: 1, packetPhase: 2, step: 'done' });
|
|
});
|
|
|
|
it('replays exactly in reverse (a rollback is the same frames backwards)', () => {
|
|
const ps = sweep(0, 1);
|
|
const forward = ps.map(maintenanceTimeline);
|
|
const backward = [...ps].reverse().map(maintenanceTimeline).reverse();
|
|
expect(backward).toEqual(forward);
|
|
});
|
|
|
|
it('keeps the windshield dry at the edges of the window and peaks at 0.7', () => {
|
|
for (const p of [...sweep(0, 0.1, 20), ...sweep(0.9, 1, 20)]) {
|
|
expect(maintenanceTimeline(p).rain).toBe(0);
|
|
}
|
|
const peak = Math.max(...sweep(0, 1).map((p) => maintenanceTimeline(p).rain));
|
|
expect(peak).toBeCloseTo(MAINTENANCE_RAIN_PEAK, 5);
|
|
expect(maintenanceTimeline(0.5).rain).toBeCloseTo(MAINTENANCE_RAIN_PEAK, 5);
|
|
});
|
|
|
|
it('stops the lightning and eases the storm out by cutover', () => {
|
|
expect(maintenanceTimeline(0.3).lightning).toBe(1);
|
|
for (const p of sweep(0.7, 1, 30)) expect(maintenanceTimeline(p).lightning).toBe(0);
|
|
expect(maintenanceTimeline(0.05).storm).toBeGreaterThan(0.5);
|
|
expect(maintenanceTimeline(0.95).storm).toBe(0);
|
|
expect(maintenanceTimeline(0).storm).toBe(0);
|
|
});
|
|
|
|
it('sends the packets out during the backup and back during verification', () => {
|
|
expect(maintenanceTimeline(0.1).packetPhase).toBe(0);
|
|
expect(maintenanceTimeline(0.36).packetPhase).toBe(1);
|
|
expect(maintenanceTimeline(0.6).packetPhase).toBe(1);
|
|
expect(maintenanceTimeline(0.65).packetPhase).toBe(1);
|
|
expect(maintenanceTimeline(0.9).packetPhase).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('distant lightning', () => {
|
|
const strikes = (seconds: number, enable = 1, dt = 1 / 60, seed?: number) => {
|
|
const lightning = createLightning(seed);
|
|
const onsets: number[] = [];
|
|
let previous = 0;
|
|
let peak = 0;
|
|
for (let t = 0; t < seconds; t += dt) {
|
|
const flash = lightning.sample(dt, enable);
|
|
if (flash > previous && previous === 0) onsets.push(t);
|
|
previous = flash;
|
|
peak = Math.max(peak, flash);
|
|
}
|
|
return { onsets, peak };
|
|
};
|
|
|
|
it('strikes at most 0.35 times a second, never twice within 2.8 s', () => {
|
|
for (const seed of [1, 0x0312, 99, 4242]) {
|
|
const { onsets } = strikes(300, 1, 1 / 60, seed);
|
|
expect(onsets.length).toBeGreaterThan(20);
|
|
expect(onsets.length / 300).toBeLessThanOrEqual(0.35);
|
|
for (let i = 1; i < onsets.length; i += 1) {
|
|
expect(onsets[i]! - onsets[i - 1]!).toBeGreaterThanOrEqual(2.8);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('caps the peak and stays dark while disabled', () => {
|
|
expect(strikes(120).peak).toBeLessThanOrEqual(LIGHTNING.peak + 1e-9);
|
|
expect(strikes(120, 0).onsets).toHaveLength(0);
|
|
expect(strikes(120, 0).peak).toBe(0);
|
|
});
|
|
|
|
it('settles before the first strike once the window opens', () => {
|
|
const { onsets } = strikes(20);
|
|
expect(onsets[0]).toBeGreaterThanOrEqual(LIGHTNING.settle - 1e-6);
|
|
});
|
|
|
|
it('holds the rate at a low frame rate too', () => {
|
|
const { onsets } = strikes(300, 1, 0.1);
|
|
for (let i = 1; i < onsets.length; i += 1) {
|
|
expect(onsets[i]! - onsets[i - 1]!).toBeGreaterThanOrEqual(2.8);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('maintenance window meshes', () => {
|
|
it('drops rain, packets and lightning on the lite tier but keeps the power-down', () => {
|
|
const resources = new SceneResourceBag();
|
|
const window = createMaintenanceWindow(resources);
|
|
let state = window.update(0.95, 1, 0.016, 'lite');
|
|
for (let i = 0; i < 200; i += 1) state = window.update(0.95, 1, 0.05, 'lite');
|
|
expect(state.rain).toBe(0);
|
|
expect(state.flash).toBe(0);
|
|
expect(state.lightning).toBe(0);
|
|
expect(state.dim).toBeGreaterThan(0.95);
|
|
expect(state.relight).toBeGreaterThan(0.4);
|
|
const packets = window.group.getObjectByName('maintenance-packets') as { visible: boolean };
|
|
expect(packets.visible).toBe(false);
|
|
resources.dispose();
|
|
});
|
|
|
|
it('eases out after the chapter and replays the same state when scrolled back', () => {
|
|
const resources = new SceneResourceBag();
|
|
const window = createMaintenanceWindow(resources);
|
|
for (let i = 0; i < 100; i += 1) window.update(0.5, 1, 0.05, 'full');
|
|
const forward = window.update(0.5, 1, 0.05, 'full');
|
|
expect(forward.rain).toBeCloseTo(MAINTENANCE_RAIN_PEAK, 2);
|
|
for (let i = 0; i < 100; i += 1) window.update(0.8, 1, 0.05, 'full');
|
|
for (let i = 0; i < 100; i += 1) window.update(0.5, -1, 0.05, 'full');
|
|
const back = window.update(0.5, -1, 0.05, 'full');
|
|
expect(back.reversing).toBe(true);
|
|
expect(back.dim).toBeCloseTo(forward.dim, 3);
|
|
expect(back.relight).toBeCloseTo(forward.relight, 3);
|
|
expect(back.rain).toBeCloseTo(forward.rain, 3);
|
|
for (let i = 0; i < 200; i += 1) window.update(null, 1, 0.05, 'full');
|
|
const after = window.update(null, 1, 0.05, 'full');
|
|
expect(after.presence).toBe(0);
|
|
expect(after.dim).toBe(0);
|
|
expect(after.rain).toBe(0);
|
|
resources.dispose();
|
|
});
|
|
|
|
it('reports a rollback while progress falls', () => {
|
|
const resources = new SceneResourceBag();
|
|
const window = createMaintenanceWindow(resources);
|
|
window.update(0.6, 1, 0.05, 'full');
|
|
expect(window.update(0.55, -1, 0.05, 'full').reversing).toBe(true);
|
|
expect(window.update(0.58, 1, 0.05, 'full').reversing).toBe(false);
|
|
resources.dispose();
|
|
});
|
|
});
|