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(); }); });