feat: scroll-driven maintenance window set-piece in the infrastructure chapter

This commit is contained in:
2026-10-11 04:25:28 +03:00
parent bd9bd692b9
commit 52b4a1ad14
45 changed files with 1063 additions and 10 deletions

View File

@@ -0,0 +1,155 @@
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();
});
});