polish: time the scene payoffs to the gaps between cards

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.
This commit is contained in:
2026-10-12 19:20:43 +03:00
parent c6ce7c24f5
commit 75108562a4
15 changed files with 193 additions and 28 deletions

View File

@@ -0,0 +1,75 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { CARD_CLEAR_BAND, CARD_GAP, CARD_GAP_OPEN_ROAD_VH, CARD_GAP_SPANS_VH, CARD_TITLE_VH, gapWindow } from '@/journey/card-gaps';
import { MAINTENANCE_BOUNDS, maintenanceStep } from '@/journey/maintenance-progress';
import { RELAY_STOPS, stationLive } from '@/journey/relay-progress';
import { plateLit, spreaderDrop, YARD_HOLD, YARD_STOPS } from '@/journey/yard-progress';
import { maintenanceTimeline } from '@/scene/world/maintenance-window';
import { relayPresence } from '@/scene/world/relay-masts';
const sweep = (from: number, to: number, steps = 200): number[] =>
Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps);
const spans = sweep(CARD_GAP_SPANS_VH.min, CARD_GAP_SPANS_VH.max, 40);
describe('card gaps', () => {
it('matches the open road the stylesheet leaves between chapters', () => {
const css = readFileSync(resolve(process.cwd(), 'src/styles/route.css'), 'utf8');
const rule = /\.reading-stage \.portfolio-section \{[^}]*margin-block: clamp\(\d+px, (\d+)vh, \d+px\) 0;\s*padding-block-end: clamp\(\d+px, (\d+)vh, \d+px\);/u.exec(css);
expect(rule).not.toBeNull();
expect(Number(rule![1]) + Number(rule![2])).toBe(CARD_GAP_OPEN_ROAD_VH);
expect(CARD_TITLE_VH).toBeGreaterThan(0);
expect(CARD_CLEAR_BAND.top).toBeLessThan(CARD_CLEAR_BAND.bottom);
});
it('keeps the static window inside the measured gap for every chapter span', () => {
for (const span of spans) {
const window = gapWindow(span);
expect(window.from).toBeLessThanOrEqual(CARD_GAP.from);
expect(window.to).toBeGreaterThanOrEqual(CARD_GAP.to);
// The static peak sits where the gap is fully in frame, whatever the span.
expect(CARD_GAP.peak).toBeGreaterThan(window.from);
expect(CARD_GAP.peak).toBeLessThan(window.to);
}
expect(CARD_GAP.from).toBeLessThan(CARD_GAP.peak);
expect(CARD_GAP.peak).toBeLessThan(CARD_GAP.to);
expect(CARD_GAP.to).toBeLessThan(1);
});
it('relights the district and cuts over in the gap', () => {
expect(maintenanceTimeline(CARD_GAP.from).relight).toBe(0);
expect(maintenanceTimeline(CARD_GAP.from - 0.01).relight).toBe(0);
expect(maintenanceTimeline(CARD_GAP.peak).relight).toBe(1);
expect(maintenanceTimeline(CARD_GAP.from).packetPhase).toBe(2);
expect(MAINTENANCE_BOUNDS.verify).toBe(CARD_GAP.from);
expect(maintenanceStep(CARD_GAP.peak)).toBe('done');
// The peak of the relight (its steepest frame) lies inside the window.
const ps = sweep(0, 1, 1000);
const slopes = ps.map((p, i) => (i === 0 ? 0 : maintenanceTimeline(p).relight - maintenanceTimeline(ps[i - 1]!).relight));
const steepest = ps[slopes.indexOf(Math.max(...slopes))]!;
expect(steepest).toBeGreaterThan(CARD_GAP.from);
expect(steepest).toBeLessThan(CARD_GAP.to);
});
it('lands the last yard lift in the gap, with the spreader down and the plate lit before the yard fades', () => {
const last = YARD_STOPS[YARD_STOPS.length - 1]!;
expect(last).toBe(CARD_GAP.peak);
expect(spreaderDrop(last)).toBe(1);
expect(plateLit(last, YARD_STOPS.length - 1)).toBe(1);
expect(last + YARD_HOLD).toBeGreaterThan(CARD_GAP.to);
// The spreader is still up (no work-lamp strike yet) as the gap comes fully into frame.
expect(spreaderDrop(CARD_GAP.from)).toBeLessThan(0.6);
expect(plateLit(CARD_GAP.from, YARD_STOPS.length - 1)).toBe(0);
for (let i = 1; i < YARD_STOPS.length; i += 1) expect(YARD_STOPS[i]!).toBeGreaterThan(YARD_STOPS[i - 1]! + 2 * YARD_HOLD);
});
it('brings the relay station live in the gap, after every mast is online and before they fade', () => {
expect(stationLive(null)).toBe(0);
expect(stationLive(CARD_GAP.from)).toBe(0);
expect(stationLive(CARD_GAP.peak)).toBe(1);
expect(Math.max(...RELAY_STOPS)).toBeLessThan(CARD_GAP.from);
for (const p of sweep(CARD_GAP.from, CARD_GAP.to)) expect(relayPresence(p)).toBe(1);
const forward = sweep(0, 1).map(stationLive);
forward.forEach((v, i) => { if (i > 0) expect(v).toBeGreaterThanOrEqual(forward[i - 1]!); });
});
});