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.66).packetPhase).toBe(1);
|
|
expect(maintenanceTimeline(0.89).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.5, 1, 0.016, 'lite');
|
|
for (let i = 0; i < 200; i += 1) state = window.update(0.5, 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();
|
|
});
|
|
});
|