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

@@ -7,3 +7,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
## Baseline
- 688 vitest tests pass at fork.
## Round 1: The 03:12 Window (Infrastructure)
- **What:** scroll-driven maintenance window across the Infrastructure chapter. HUD sub-line ("MAINTENANCE WINDOW · 03:12" -> BACKUP -> CONVERT · TRANSFER -> VERIFY -> "CUTOVER OK · 03:51", "ROLLBACK CHECKPOINT" on reverse scroll), storm sky tint, soft capped lightning (<=0.31 Hz, >=2.8 s apart), district power-down/relight, amber backup packets to a far "offsite" block, rain on the lens (LensFinishPass uRain), diagram nodes 01-04 lighting per step. Progress is scroll-driven, so scrolling back is a rollback.
- **Why:** makes the "night shift" idea literal and dramatises the real Nextcloud migration (backups, convert, transfer, verify, cutover) with no invented copy. Closes the gaps "Infrastructure has no world beat" and "no rain / sky event".
- **Build:** src/journey/maintenance-progress.ts, src/scene/world/maintenance-window.ts, useMaintenanceWindow.ts, maintenance.css; edits in environment, districts, palette, cinematic-grade, JourneyHud, AppShell. Reduced motion/static: no scene, diagram and sub-line still step. Low tier: dim/relight only.
- **Verdict: KEEP with fixes** (Fable review on hardware GL, 1440 and 390): storm tint and lightning strengthened, clock holds 03:51 to end of chapter, phone sub-line fits. 709 tests green (+21), budgets clean (initial JS 111.7 KB gz, scene chunk +6.9 KB).
- **Known weakness:** the reading panel covers the scene during p 0.1-0.75, so district power-down, packets and offsite are barely visible; relight uses the normal accent so the payoff reads as "back to normal". Audio rain bed skipped. Do not revisit before round 5.
- Screenshots: docs/overnight/round1/ (before-* vs p*-1440/390).

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -14,6 +14,7 @@ import './styles/wayfinding.css';
import './styles/route.css';
import './styles/gantry.css';
import './styles/cards.css';
import './styles/maintenance.css';
import './styles/print.css';
defineProps<{ inputs: Readonly<ProductionInputs> }>();

View File

@@ -18,6 +18,7 @@ import { useAudioIntent } from '@/composables/useAudioIntent';
import { useCardEntrances } from '@/composables/useCardEntrances';
import { useJourneySections } from '@/composables/useJourneySections';
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
import { useMaintenanceWindow } from '@/composables/useMaintenanceWindow';
import { useMotionPreferences } from '@/composables/useMotionPreferences';
import { useRouteMemory } from '@/composables/useRouteMemory';
import { assetUrl } from '@/content/asset-url';
@@ -81,6 +82,8 @@ const journeyBinding = useJourneySections(
computed(() => motion.policy.value.allowSceneRequest),
);
const journey = journeyBinding.snapshot;
/* The 03:12 window (Infrastructure): the plate's sub-line and the diagram steps. */
const maintenanceSubline = useMaintenanceWindow(journey);
useCardEntrances();
const route = useRouteMemory(canonicalSectionIds, () => journey.value.activeSectionId);
const resume = computed(() => {
@@ -258,6 +261,7 @@ onUnmounted(() => removePageListeners());
:exit="exitFor(journey.activeSectionId)"
:next-label="nextLabelFor(journey.activeSectionId)"
:next-exit="nextExitFor(journey.activeSectionId)"
:subline="maintenanceSubline"
/>
<header class="site-header">
<SiteNavigation

View File

@@ -9,6 +9,8 @@ const props = defineProps<{
exit?: string | undefined;
nextLabel?: string | undefined;
nextExit?: string | undefined;
/** Set client-side only (the 03:12 window); empty on the server. */
subline?: string | undefined;
}>();
/** Decorative next-exit line (CSS generated content keeps SSR text stable). */
@@ -40,5 +42,6 @@ const routePercent = computed(() => `${Math.round(route.value * 100)}%`);
<span class="journey-hud__route" :data-next="next"><span>ROUTE</span> <strong>{{ routePercent }}</strong></span>
<span class="journey-hud__section">{{ sectionLabel }}</span>
<strong v-if="journey.arrived" class="journey-hud__arrival">ARRIVED</strong>
<span class="journey-hud__subline">{{ subline }}</span>
</aside>
</template>

View File

@@ -0,0 +1,41 @@
import { computed, onMounted, onUnmounted, shallowRef, watch, type Ref } from 'vue';
import type { JourneySnapshot } from '@/journey/types';
import {
createMaintenanceDirection,
maintenanceProgress,
maintenanceStep,
maintenanceSubline,
} from '@/journey/maintenance-progress';
import { bucharestClock } from '@/lib/operations';
/**
* The DOM side of the 03:12 window: the gantry plate's sub-line and
* html[data-maintenance-step] for the diagram. Both are client-only and only
* start after mount, so the server HTML (no attribute, an empty sub-line)
* hydrates unchanged. Works without the scene (reduced motion, static).
*/
export function useMaintenanceWindow(journey: Readonly<Ref<JourneySnapshot>>): Readonly<Ref<string>> {
const mounted = shallowRef(false);
const reversing = shallowRef(false);
const track = createMaintenanceDirection();
const progress = computed(() => (mounted.value ? maintenanceProgress(journey.value) : null));
const step = computed(() => (progress.value === null ? null : maintenanceStep(progress.value)));
watch(progress, (p) => { reversing.value = track(p); }, { flush: 'sync' });
watch(step, (value) => {
const root = document.documentElement;
if (value === null) delete root.dataset.maintenanceStep;
else root.dataset.maintenanceStep = value;
});
onMounted(() => { mounted.value = true; });
onUnmounted(() => {
mounted.value = false;
delete document.documentElement.dataset.maintenanceStep;
});
return computed(() => {
const p = progress.value;
return p === null ? '' : maintenanceSubline(p, reversing.value, () => bucharestClock(new Date()));
});
}

View File

@@ -0,0 +1,88 @@
import type { JourneySnapshot } from './types';
/**
* The 03:12 window: the Infrastructure chapter's set-piece is driven by how
* far the visitor has read through the chapter, never by time, so scrolling
* back up replays it in reverse (a rollback). Shared by the DOM (HUD sub-line,
* diagram steps) and the lazy scene chunk, so it stays tiny and pure.
*
* The beats follow the featured operation in the content: back up, convert
* and transfer, verify, then cut over.
*/
export type MaintenanceStep = 'open' | 'backup' | 'transfer' | 'verify' | 'done';
/** Upper bounds of each step, in section-local progress. */
export const MAINTENANCE_BOUNDS = { open: 0.1, backup: 0.35, transfer: 0.65, verify: 0.9 } as const;
/**
* The clock reads live Bucharest time again from here. The window keeps its
* own clock right up to the next chapter: "CUTOVER OK" always reads 03:51.
*/
export const MAINTENANCE_LIVE_FROM = 1;
type ProgressInput = Pick<JourneySnapshot, 'activeSectionId' | 'sectionActivation'>;
/**
* Section-local progress: 0 with the Infrastructure heading on the activation
* line, 1 when the next chapter's heading reaches it. Null outside the chapter.
*/
export function maintenanceProgress(snapshot: ProgressInput): number | null {
if (snapshot.activeSectionId !== 'infrastructure') return null;
const { activeHeadingOffsetVh: active, nextHeadingOffsetVh: next } = snapshot.sectionActivation;
if (next === null || !Number.isFinite(active) || !Number.isFinite(next)) return null;
const span = next - active;
if (!(span > 0)) return null;
return Math.min(1, Math.max(0, -active / span));
}
export function maintenanceStep(p: number): MaintenanceStep {
if (p < MAINTENANCE_BOUNDS.open) return 'open';
if (p < MAINTENANCE_BOUNDS.backup) return 'backup';
if (p < MAINTENANCE_BOUNDS.transfer) return 'transfer';
if (p < MAINTENANCE_BOUNDS.verify) return 'verify';
return 'done';
}
/** The window opens at 03:12 and cuts over at 03:51; the clock runs with the scroll. */
export function maintenanceClock(p: number, live: () => string): string {
if (p >= MAINTENANCE_LIVE_FROM) return live();
if (p >= MAINTENANCE_BOUNDS.verify) return '03:51';
const run = Math.min(1, Math.max(0, (p - MAINTENANCE_BOUNDS.open) / (MAINTENANCE_BOUNDS.verify - MAINTENANCE_BOUNDS.open)));
return `03:${String(12 + Math.round(run * 39)).padStart(2, '0')}`;
}
const LABELS: Readonly<Record<MaintenanceStep, string>> = {
open: 'MAINTENANCE WINDOW',
backup: 'BACKUP',
transfer: 'CONVERT · TRANSFER',
verify: 'VERIFY',
done: 'CUTOVER OK',
};
/** The gantry plate's sub-line, e.g. "MAINTENANCE WINDOW · 03:12". */
export function maintenanceSubline(p: number, reversing: boolean, live: () => string): string {
const label = reversing ? 'ROLLBACK CHECKPOINT' : LABELS[maintenanceStep(p)];
return `${label} · ${maintenanceClock(p, live)}`;
}
/**
* Latches the scroll direction inside the window: progress falling means a
* rollback until it rises again. Small jitter is ignored.
*/
export function createMaintenanceDirection(): (p: number | null) => boolean {
let last: number | null = null;
let reversing = false;
return (p) => {
if (p === null) {
last = null;
reversing = false;
return false;
}
if (last !== null) {
if (p < last - 0.001) reversing = true;
else if (p > last + 0.001) reversing = false;
}
last = p;
return reversing && p > 0 && p < 1;
};
}

View File

@@ -50,6 +50,10 @@ export interface LensState {
letterbox: number;
/** Drive energy: widens aberration and streaks a little. */
energy: number;
/** Windshield rain (the 03:12 window), 0..1. */
rain?: number;
/** Seconds, for the drops' slow slide. */
rainTime?: number;
}
/**
@@ -68,6 +72,8 @@ export class LensFinishPass extends Pass {
uAberration: { value: 0.0035 },
uGrain: { value: 0.03 },
uVignette: { value: 0.42 },
uRain: { value: 0 },
uRainTime: { value: 0 },
toneMappingExposure: { value: 1 },
};
@@ -105,6 +111,8 @@ export class LensFinishPass extends Pass {
uniform float uAberration;
uniform float uGrain;
uniform float uVignette;
uniform float uRain;
uniform float uRainTime;
varying vec2 vUv;
#include <tonemapping_pars_fragment>
#include <colorspace_pars_fragment>
@@ -115,15 +123,58 @@ export class LensFinishPass extends Pass {
return fract( ( p3.x + p3.y ) * p3.z );
}
vec3 rainHash( vec2 p ) {
vec3 q = fract( vec3( p.xyx ) * vec3( 0.1031, 0.1030, 0.0973 ) );
q += dot( q, q.yxz + 33.33 );
return fract( ( q.xxy + q.yzz ) * q.zyx );
}
// Rain on the windshield: one layer of drops on a grid, each with its
// own slow life (fade in, maybe slide, fade out over about 8 s), so
// nothing pops. A drop refracts the frame through itself; a sliding
// drop leaves a smeared trail above it. xy: sample offset, z: trail.
vec3 rainLayer( vec2 uv, float scale, float t, float density ) {
vec2 grid = vec2( uv.x * uAspect, uv.y ) * scale;
vec2 id = floor( grid );
vec2 f = fract( grid ) - 0.5;
vec3 h = rainHash( id + scale );
float present = smoothstep( h.x, h.x + 0.08, density );
if ( present <= 0.0 ) return vec3( 0.0 );
float life = fract( t * ( 0.09 + h.z * 0.05 ) + h.y );
float seen = smoothstep( 0.0, 0.18, life ) * ( 1.0 - smoothstep( 0.72, 1.0, life ) ) * present;
float slides = step( 0.55, h.z );
vec2 center = vec2( ( h.y - 0.5 ) * 0.5, 0.32 - slides * life * 0.62 );
float radius = 0.07 + h.z * 0.09;
vec2 d = ( f - center ) * vec2( 1.0, 0.85 );
float drop = ( 1.0 - smoothstep( radius * 0.55, radius, length( d ) ) ) * seen;
float trail = slides * seen
* ( 1.0 - smoothstep( 0.0, radius * 0.45, abs( f.x - center.x ) ) )
* step( center.y, f.y ) * ( 1.0 - smoothstep( center.y, 0.5, f.y ) );
return vec3( -d * drop * 2.4 / scale, trail * 0.6 );
}
void main() {
vec2 sampleUv = vUv;
float smear = 0.0;
if ( uRain > 0.001 ) {
vec3 near = rainLayer( vUv, 7.0, uRainTime, uRain * 0.75 );
vec3 far = rainLayer( vUv + 0.37, 13.0, uRainTime * 1.3, uRain * 0.6 );
sampleUv += ( near.xy + far.xy * 0.6 ) * vec2( 1.0 / uAspect, 1.0 );
smear = clamp( near.z + far.z * 0.5, 0.0, 1.0 ) * uRain;
}
vec2 centered = vUv - 0.5;
float radius2 = dot( centered * vec2( uAspect, 1.0 ), centered * vec2( uAspect, 1.0 ) );
vec2 shift = centered * radius2 * uAberration;
vec3 color = vec3(
texture2D( tDiffuse, vUv - shift ).r,
texture2D( tDiffuse, vUv ).g,
texture2D( tDiffuse, vUv + shift ).b
texture2D( tDiffuse, sampleUv - shift ).r,
texture2D( tDiffuse, sampleUv ).g,
texture2D( tDiffuse, sampleUv + shift ).b
);
if ( smear > 0.002 ) {
vec3 streaked = ( texture2D( tDiffuse, sampleUv + vec2( 0.0, 0.008 ) ).rgb
+ texture2D( tDiffuse, sampleUv + vec2( 0.0, 0.018 ) ).rgb ) * 0.5;
color = mix( color, streaked, smear * 0.8 );
}
if ( uStreak > 0.0 ) {
vec3 streak = vec3( 0.0 );
for ( int i = 1; i <= 7; i++ ) {
@@ -161,6 +212,8 @@ export class LensFinishPass extends Pass {
this.uniforms.uTime.value = state.time;
this.uniforms.uLetterbox.value = THREE.MathUtils.clamp(state.letterbox, 0, 0.2);
this.uniforms.uAberration.value = 0.0035 + energy * 0.006;
this.uniforms.uRain.value = THREE.MathUtils.clamp(state.rain ?? 0, 0, 1);
this.uniforms.uRainTime.value = state.rainTime ?? state.time;
if (this.uniforms.tStreak.value) {
this.uniforms.uStreak.value = this.streakStrength * (1 + energy * 0.6);
}

View File

@@ -105,9 +105,11 @@ const VERTEX = `
varying float vDepth;
varying float vRise;
varying float vHeight;
varying float vStagger;
void main() {
vec4 origin = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
float stagger = fract(sin(dot(origin.xz, vec2(12.9898, 78.233))) * 43758.5453);
vStagger = stagger;
float rise = clamp(uRise * 1.45 - stagger * 0.45, 0.0, 1.0);
rise = rise * rise * (3.0 - 2.0 * rise);
vRise = rise;
@@ -128,6 +130,32 @@ const FADES = `
if (fade < 0.02) discard;
`;
/** Pieces the 03:12 window powers down and re-lights (the Infrastructure district). */
const SERVICED: ReadonlySet<DistrictPiece> = new Set(['rack-row', 'mast']);
/** The infrastructure accent the service re-lights in. */
export const SERVICE_ACCENT = 0x8be9ed;
/* The 03:12 window. Each piece (a rack row, a mast) powers down to about 10%
on its own staggered beat, then re-lights lamp row by lamp row, bottom up,
in the infrastructure accent. Scroll-driven, so it never flickers. */
const SERVICE_PARS = `
uniform float uServiceDim;
uniform float uServiceRelight;
uniform float uPieceHeight;
uniform vec3 uServiceColor;
varying float vStagger;
float serviceRowDim() {
return clamp(uServiceDim * 1.6 - vStagger * 0.6, 0.0, 1.0);
}
float serviceRelit() {
float rows = 7.0;
float row = floor(clamp(vHeight / max(0.001, uPieceHeight), 0.0, 0.999) * rows);
float front = uServiceRelight * 1.3 - vStagger * 0.3;
float at = (row + 0.5) / rows;
return smoothstep(at - 0.05, at + 0.05, front);
}
`;
export interface DistrictsWorld {
group: { name: string; add(...objects: unknown[]): void };
update(
@@ -139,6 +167,8 @@ export interface DistrictsWorld {
): void;
/** Pieces currently raised (for tests and diagnostics). */
raised(): DistrictPiece[];
/** The 03:12 window: power-down (0..1) and re-light (0..1). Uniform writes only. */
setServiceState(dim: number, relight: number): void;
}
export function createDistricts(
@@ -154,6 +184,11 @@ export function createDistricts(
uTime: { value: 0 },
uAccent: { value: new THREE.Color(signAccentFor('start')) },
};
const service = {
uServiceDim: { value: 0 },
uServiceRelight: { value: 0 },
uServiceColor: { value: new THREE.Color(SERVICE_ACCENT) },
};
const targetAccent = new THREE.Color(signAccentFor('start'));
const state = new Map<
DistrictPiece,
@@ -162,11 +197,11 @@ export function createDistricts(
const pieces = Object.keys(DISTRICT_PRESENCE) as DistrictPiece[];
for (const piece of pieces) state.set(piece, { rise: { value: 0 }, meshes: [] });
const bodyMaterial = (rise: { value: number }) =>
const bodyMaterial = (rise: { value: number }, serviced: boolean, height: { value: number }) =>
resources.own(
'material',
new THREE.ShaderMaterial({
uniforms: { ...shared, uRise: rise },
uniforms: { ...shared, ...service, uRise: rise, uPieceHeight: height },
vertexShader: VERTEX,
fragmentShader: `
uniform vec3 uAccent;
@@ -174,6 +209,7 @@ export function createDistricts(
varying float vDepth;
varying float vRise;
varying float vHeight;
${serviced ? SERVICE_PARS : ''}
void main() {
${FADES}
// Opaque dark steel, like the sign truss: fades dissolve through a
@@ -191,7 +227,12 @@ export function createDistricts(
// the tops of the pieces carry a thin lit band.
float rim = pow(1.0 - abs(dot(n, normalize(cameraPosition - vWorld))), 4.0);
float crown = smoothstep(0.92, 1.0, n.y) * smoothstep(1.0, 6.0, vHeight);
color += uAccent * (rim * 0.22 + crown * 0.05);
${serviced ? `
float dimmed = serviceRowDim();
float relit = serviceRelit();
float power = mix(1.0, mix(0.1, 1.0, relit), dimmed);
color += mix(uAccent, uServiceColor, dimmed * relit) * (rim * 0.22 + crown * 0.05) * power;` : `
color += uAccent * (rim * 0.22 + crown * 0.05);`}
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
@@ -202,11 +243,11 @@ export function createDistricts(
side: THREE.DoubleSide,
}),
);
const lightMaterial = (rise: { value: number }, accentMix: number) =>
const lightMaterial = (rise: { value: number }, accentMix: number, serviced: boolean, height: { value: number }) =>
resources.own(
'material',
new THREE.ShaderMaterial({
uniforms: { ...shared, uRise: rise },
uniforms: { ...shared, ...service, uRise: rise, uPieceHeight: height },
vertexShader: VERTEX,
fragmentShader: `
uniform vec3 uAccent;
@@ -215,6 +256,8 @@ export function createDistricts(
varying vec3 vWorld;
varying float vDepth;
varying float vRise;
varying float vHeight;
${serviced ? SERVICE_PARS : ''}
void main() {
${FADES}
// Neon tubes warm up as the district finishes rising. A smooth
@@ -226,6 +269,11 @@ export function createDistricts(
// Close passes dim, so overhead neon never floods the bloom pass.
float near = mix(0.3, 1.0, smoothstep(5.0, 32.0, vDepth));
vec3 color = tone * (1.1 + uEnergy * 0.4) * near;
${serviced ? `
float dimmed = serviceRowDim();
float relit = serviceRelit();
color = mix(color, uServiceColor * (1.1 + uEnergy * 0.4) * near, dimmed * relit * 0.85);
color *= mix(1.0, mix(0.1, 1.0, relit), dimmed);` : ''}
gl_FragColor = vec4(color * on * fade, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
@@ -247,9 +295,16 @@ export function createDistricts(
if (!entry) return;
const placements = placementsFor(piece, quality.postEffects);
const accentMix = piece === 'arch' ? 1 : 0.35;
const serviced = SERVICED.has(piece);
const height = { value: 1 };
if (serviced) {
const bounds = (body as Geometry & { computeBoundingBox?(): void; boundingBox?: { max: { y: number } } | null });
bounds.computeBoundingBox?.();
height.value = Math.max(0.5, bounds.boundingBox?.max.y ?? 1);
}
for (const [geometry, material] of [
[body, bodyMaterial(entry.rise)],
[light, lightMaterial(entry.rise, accentMix)],
[body, bodyMaterial(entry.rise, serviced, height)],
[light, lightMaterial(entry.rise, accentMix, serviced, height)],
] as const) {
resources.own('geometry', geometry);
const mesh = resources.own(
@@ -318,5 +373,9 @@ export function createDistricts(
},
raised: () =>
pieces.filter((piece) => (DISTRICT_PRESENCE[piece][current] ?? 0) > 0),
setServiceState(dim, relight) {
service.uServiceDim.value = Number.isFinite(dim) ? Math.min(1, Math.max(0, dim)) : 0;
service.uServiceRelight.value = Number.isFinite(relight) ? Math.min(1, Math.max(0, relight)) : 0;
},
};
}

View File

@@ -26,9 +26,13 @@ import { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
import { createTravelDistance } from '@/scene/world/travel-distance';
import {
createMutableChapterPalette,
STORM_STAR_SCALE,
STORM_TINT,
writeChapterPalette,
type MutableChapterPalette,
} from '@/scene/world/palette';
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
import { maintenanceProgress } from '@/journey/maintenance-progress';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
@@ -141,7 +145,16 @@ export async function createProceduralSceneGeneration(
maximumTextureSize: renderer.capabilities.maxTextureSize,
});
const atmosphere = createAtmosphere(context.resources, quality);
// The 03:12 window. Phones, coarse pointers and the low tier keep only the
// power-down, the re-light and the palette nudge: no rain, packets or lightning.
const maintenance = createMaintenanceWindow(context.resources);
const maintenanceTier =
context.quality === 'low'
|| (typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)
? 'lite'
: 'full';
scene.add(
maintenance.group,
atmosphere.group,
road.group,
expressway.group,
@@ -307,6 +320,19 @@ export async function createProceduralSceneGeneration(
terrainSpectrumPhase =
(terrainSpectrumPhase + deltaSeconds * overdrive * 0.38) % 1;
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
// The 03:12 window: section-local scroll progress drives every beat.
const service = maintenance.update(
maintenanceProgress(snapshot),
snapshot.direction,
deltaSeconds,
maintenanceTier,
);
if (service.storm > 0) {
targetPalette.sky.lerp(STORM_SKY, 0.55 * service.storm);
targetPalette.fog.lerp(STORM_SKY, 0.4 * service.storm);
targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm;
}
districts.setServiceState(service.dim, service.relight);
// Engine off: parked at the destination the sky drops about 10%, more
// stars come out, and the drive winds down to a stop.
const engineOff = THREE.MathUtils.smoothstep(outroProgress, 0.12, 0.7);
@@ -388,6 +414,13 @@ export async function createProceduralSceneGeneration(
);
sign.setResting(snapshot.activeSectionId);
sign.update(snapshot.sign, now, snapshot.driveSpeed);
// Distant lightning lifts the sky a little, toward a still-dark lilac.
if (service.flash > 0) {
flashRestore.sky.copy(currentPalette.sky);
flashRestore.fog.copy(currentPalette.fog);
currentPalette.sky.lerp(LIGHTNING_SKY, service.flash * 0.7);
currentPalette.fog.lerp(LIGHTNING_SKY, service.flash * 0.3);
}
atmosphere.update(
driveDistance,
visualSpeed,
@@ -402,6 +435,10 @@ export async function createProceduralSceneGeneration(
.copy(currentPalette.haze)
.lerp(currentPalette.terrainHigh, 0.26);
ambient.groundColor.copy(currentPalette.fog);
if (service.flash > 0) {
currentPalette.sky.copy(flashRestore.sky);
currentPalette.fog.copy(flashRestore.fog);
}
ambient.intensity = 1.05 + currentPalette.warmth * 0.18;
carKeyLight.color.setHex(0xb2d5ff);
carKeyLight.intensity = 32 + response.bloomEnergy * 3.6 + overdrive * 8;
@@ -620,6 +657,8 @@ export async function createProceduralSceneGeneration(
time: elapsedSeconds,
letterbox: 0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
energy: overdrive,
rain: service.rain,
rainTime: elapsedSeconds,
});
postProcessor.render(scene, camera);
} else {
@@ -648,6 +687,9 @@ const OUTRO_SECONDS = 5.5;
const OUTRO_RETURN_SECONDS = 1.6;
const OUTRO_REACH = 0.62;
const ENGINE_OFF_NIGHT = new THREE.Color(0x000000);
const STORM_SKY = new THREE.Color(STORM_TINT);
const LIGHTNING_SKY = new THREE.Color(0x5a5ea8);
const flashRestore = { sky: new THREE.Color(), fog: new THREE.Color() };
/** Plates rise back over 720 ms with up to three 90 ms staggers. */
const INTERLUDE_RISE_MS = 800;

View File

@@ -0,0 +1,351 @@
import * as THREE from 'three';
import type { SceneResourceBag } from '@/scene/resources';
import { maintenanceStep, type MaintenanceStep } from '@/journey/maintenance-progress';
/**
* THE 03:12 WINDOW, the Infrastructure chapter's set-piece. A maintenance
* window opens over the city: one district powers down lamp by lamp, amber
* backup packets stream to an offsite silhouette on the far right, rain
* starts on the windshield, then the service re-lights in the infrastructure
* accent. Everything is a pure function of section-local scroll progress, so
* scrolling back up replays it in reverse. Only the distant lightning keeps
* its own (bounded) clock.
*
* Photosensitivity (round 5): lightning is at most one soft pulse per 3.2 s
* (0.31 Hz, never two within 2.8 s), its peak is capped and it lights only a
* distant patch of sky. Nothing here blinks.
*/
export interface MaintenanceFrame {
step: MaintenanceStep;
/** District emissive power-down, 0..1 (rows stagger in the shader). */
dim: number;
/** Bottom-up re-light in the infrastructure accent, 0..1. */
relight: number;
/** Windshield rain, 0..0.7. */
rain: number;
/** 0..1 outbound backup packets, 1..2 the verified return trip. */
packetPhase: number;
/** How lit the offsite silhouette is, 0..1. */
offsite: number;
/** Storm palette nudge (cold navy, fewer stars), 0..1. */
storm: number;
/** Whether distant lightning may strike, 0..1. */
lightning: number;
}
export const MAINTENANCE_RAIN_PEAK = 0.7;
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
const smooth = (from: number, to: number, value: number): number => {
const t = clamp01((value - from) / (to - from));
return t * t * (3 - 2 * t);
};
/** The window as a pure function of section-local progress p (0..1). */
export function maintenanceTimeline(p: number): MaintenanceFrame {
const x = Number.isFinite(p) ? clamp01(p) : 0;
return {
step: maintenanceStep(x),
dim: smooth(0.1, 0.35, x),
relight: smooth(0.35, 0.65, x),
rain: MAINTENANCE_RAIN_PEAK * smooth(0.1, 0.3, x) * (1 - smooth(0.7, 0.9, x)),
packetPhase: clamp01((x - 0.12) / 0.21) + clamp01((x - 0.67) / 0.21),
offsite: smooth(0.14, 0.36, x),
storm: smooth(0, 0.06, x) * (1 - smooth(0.68, 0.92, x)),
lightning: smooth(0, 0.03, x) * (1 - smooth(0.62, 0.7, x)),
};
}
export const LIGHTNING = Object.freeze({
/** Seconds between strikes. 1 / 3.2 s = 0.31 Hz. */
minGap: 3.2,
maxGap: 7.6,
/** A strike never lands this soon after the window opens. */
settle: 1.4,
rise: 0.07,
decay: 0.5,
/** Peak flash, as a fraction of the (already dim) flash colour. */
peak: 0.6,
});
/** Distant lightning: one soft pulse at a time, seeded so tests are exact. */
export function createLightning(seed = 0x0312) {
let state = seed >>> 0 || 1;
const random = (): number => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
let clock = 0;
let lastStrike = -Infinity;
let next: number = LIGHTNING.settle;
return {
sample(dt: number, enable: number): number {
clock += Math.max(0, Number.isFinite(dt) ? dt : 0);
if (enable < 0.5) next = Math.max(next, clock + LIGHTNING.settle);
else if (clock >= next && clock - lastStrike >= LIGHTNING.minGap) {
lastStrike = clock;
next = clock + LIGHTNING.minGap + random() * (LIGHTNING.maxGap - LIGHTNING.minGap);
}
const age = clock - lastStrike;
const envelope = age < LIGHTNING.rise
? age / LIGHTNING.rise
: 1 - smooth(LIGHTNING.rise, LIGHTNING.rise + LIGHTNING.decay, age);
return LIGHTNING.peak * envelope * clamp01(enable);
},
};
}
export type MaintenanceTier = 'full' | 'lite';
export interface MaintenanceState extends MaintenanceFrame {
/** 1 while the Infrastructure chapter is current, easing to 0 after. */
presence: number;
/** Current lightning flash, 0..LIGHTNING.peak. */
flash: number;
reversing: boolean;
}
/** Far right of the infrastructure framing, on the horizon. */
export const OFFSITE = Object.freeze({ x: 250, y: 0, z: -540 });
const PACKET_COUNT = 24;
export function createMaintenanceWindow(resources: SceneResourceBag) {
const group = new THREE.Group();
group.name = 'maintenance-window';
// ---- Offsite: a low campus silhouette whose windows light as copies land.
const offsiteUniforms = { uLit: { value: 0 }, uPresence: { value: 0 }, uFlash: { value: 0 } };
const boxGeometry = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
boxGeometry.translate(0, 0.5, 0);
const offsiteMaterial = resources.own('material', new THREE.ShaderMaterial({
uniforms: offsiteUniforms,
vertexShader: /* glsl */ `
varying vec3 vWorld;
varying vec3 vNormal2;
void main() {
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
vWorld = world.xyz;
vNormal2 = normal;
gl_Position = projectionMatrix * viewMatrix * world;
}`,
fragmentShader: /* glsl */ `
uniform float uLit;
uniform float uPresence;
uniform float uFlash;
varying vec3 vWorld;
varying vec3 vNormal2;
float hash(vec2 p) { return fract(sin(dot(p, vec2(41.3, 289.1))) * 15731.743); }
void main() {
vec3 color = vec3(0.016, 0.014, 0.032) + vec3(0.02, 0.022, 0.05) * max(0.0, vNormal2.y);
// Window rows on the faces that look back at the road.
vec2 grid = vec2(vWorld.x * 0.42 + vWorld.z * 0.42, vWorld.y * 0.5);
vec2 cell = floor(grid);
vec2 f = fract(grid);
float pane = step(0.25, f.x) * step(f.x, 0.75) * step(0.3, f.y) * step(f.y, 0.72)
* (1.0 - step(0.5, abs(vNormal2.y)));
float order = hash(cell);
float lit = smoothstep(order - 0.05, order + 0.05, uLit * 1.05) * uPresence;
vec3 amber = vec3(1.0, 0.62, 0.26);
vec3 accent = vec3(0.545, 0.914, 0.929);
color += mix(amber, accent, step(0.7, hash(cell * 1.7))) * pane * lit * 1.3;
// Lightning behind it only rims the roofs.
color += vec3(0.32, 0.36, 0.62) * uFlash * smoothstep(0.6, 1.0, vNormal2.y);
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}`,
fog: false,
}));
const campus = [
[0, 0, 44, 20, 24],
[-15, 5, 10, 46, 10],
[11, -6, 12, 34, 12],
[28, 8, 16, 14, 16],
[-32, -3, 14, 11, 14],
] as const;
const offsite = resources.own('other', new THREE.InstancedMesh(boxGeometry, offsiteMaterial, campus.length));
offsite.name = 'maintenance-offsite';
const dummy = new THREE.Object3D();
campus.forEach(([dx, dz, w, h, d], index) => {
dummy.position.set(OFFSITE.x + dx, OFFSITE.y, OFFSITE.z + dz);
dummy.rotation.set(0, -0.35, 0);
dummy.scale.set(w, h, d);
dummy.updateMatrix();
offsite.setMatrixAt(index, dummy.matrix);
});
offsite.instanceMatrix.needsUpdate = true;
offsite.frustumCulled = false;
offsite.visible = false;
// ---- Distant lightning: a soft patch of sky behind the offsite.
const glowUniforms = { uFlash: { value: 0 } };
const glow = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(420, 200)),
resources.own('material', new THREE.ShaderMaterial({
uniforms: glowUniforms,
vertexShader: /* glsl */ `
varying vec2 vUv;
void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: /* glsl */ `
uniform float uFlash;
varying vec2 vUv;
void main() {
vec2 c = (vUv - vec2(0.55, 0.42)) * vec2(1.0, 1.9);
float cloud = exp(-dot(c, c) * 7.0);
gl_FragColor = vec4(vec3(0.42, 0.46, 0.9) * cloud * uFlash, 1.0);
#include <colorspace_fragment>
}`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
fog: false,
})),
);
glow.name = 'maintenance-lightning';
glow.position.set(OFFSITE.x - 20, 70, OFFSITE.z - 80);
glow.frustumCulled = false;
glow.visible = false;
// ---- Backup packets: 24 amber streaks, the flight lives in the shader.
const packetUniforms = {
uOut: { value: 0 },
uBack: { value: 0 },
uPresence: { value: 0 },
uTarget: { value: new THREE.Vector3(OFFSITE.x, OFFSITE.y + 30, OFFSITE.z) },
};
const packetGeometry = resources.own('geometry', new THREE.InstancedBufferGeometry());
packetGeometry.setAttribute('position', new THREE.Float32BufferAttribute([0, -1, 0, 1, -1, 0, 1, 1, 0, 0, 1, 0], 3));
packetGeometry.setIndex([0, 1, 2, 0, 2, 3]);
const sources: number[] = [];
const seeds: number[] = [];
for (let i = 0; i < PACKET_COUNT; i += 1) {
const side = i % 2 ? 1 : -1;
const row = Math.floor(i / 2);
sources.push(side * (14.6 + (row % 3) * 2.3), 3 + ((i * 7) % 5), -48 - row * 11);
seeds.push(((i * 0.618) % 1), 34 + ((i * 13) % 7) * 6, 0.32 + ((i * 5) % 3) * 0.08);
}
packetGeometry.setAttribute('aSource', new THREE.InstancedBufferAttribute(new Float32Array(sources), 3));
packetGeometry.setAttribute('aSeed', new THREE.InstancedBufferAttribute(new Float32Array(seeds), 3));
packetGeometry.instanceCount = PACKET_COUNT;
const packets = new THREE.Mesh(packetGeometry, resources.own('material', new THREE.ShaderMaterial({
uniforms: packetUniforms,
vertexShader: /* glsl */ `
uniform float uOut;
uniform float uBack;
uniform float uPresence;
uniform vec3 uTarget;
attribute vec3 aSource;
attribute vec3 aSeed;
varying vec2 vUv;
varying float vAlpha;
varying float vReturn;
float travel(float phase) { return clamp(phase * 1.6 - aSeed.x * 0.6, 0.0, 1.0); }
vec3 along(float e) {
float s = e * e * (3.0 - 2.0 * e);
vec3 p = mix(aSource, uTarget, s);
p.y += sin(3.14159 * s) * aSeed.y;
return p;
}
void main() {
float returning = step(0.0001, uBack);
float o = travel(uOut);
float b = travel(uBack);
float local = mix(o, b, returning);
float e = mix(o, 1.0 - b, returning);
vAlpha = smoothstep(0.0, 0.05, local) * (1.0 - smoothstep(0.88, 1.0, local)) * uPresence;
vReturn = returning;
vUv = position.xy;
vec4 head = modelViewMatrix * vec4(along(e), 1.0);
vec4 tail = modelViewMatrix * vec4(along(clamp(e - mix(0.04, -0.04, returning), 0.0, 1.0)), 1.0);
vec2 dir = head.xy - tail.xy;
float len = length(dir);
dir = len > 0.0001 ? dir / len : vec2(1.0, 0.0);
float width = aSeed.z * max(1.0, -head.z * 0.0045);
vec4 v = mix(tail, head, position.x);
v.xy += vec2(-dir.y, dir.x) * position.y * width + dir * (position.x - 0.5) * width * 2.0;
gl_Position = vAlpha < 0.001 ? vec4(2.0, 2.0, 2.0, 1.0) : projectionMatrix * v;
}`,
fragmentShader: /* glsl */ `
varying vec2 vUv;
varying float vAlpha;
varying float vReturn;
void main() {
float across = abs(vUv.y);
float body = (1.0 - smoothstep(0.0, 1.0, across)) * pow(vUv.x, 1.6);
float head = 1.0 - smoothstep(0.0, 1.0, length(vec2((vUv.x - 0.9) * 5.0, across)));
float alpha = max(body * 0.7, head) * vAlpha;
if (alpha < 0.004) discard;
vec3 tone = mix(vec3(1.0, 0.56, 0.16), vec3(0.545, 0.914, 0.929), vReturn);
gl_FragColor = vec4(tone * 1.9 * alpha, alpha);
#include <colorspace_fragment>
}`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
toneMapped: false,
})));
packets.name = 'maintenance-packets';
packets.frustumCulled = false;
packets.visible = false;
group.add(glow, offsite, packets);
const lightning = createLightning();
let smoothed = 0;
let presence = 0;
let reversing = false;
let previous: number | null = null;
// One hidden-but-drawn frame compiles the programs before the chapter.
let warm = true;
return {
group,
update(p: number | null, direction: -1 | 0 | 1, dt: number, tier: MaintenanceTier): MaintenanceState {
const step = Math.max(0, Math.min(0.1, Number.isFinite(dt) ? dt : 0));
const inWindow = p !== null && Number.isFinite(p);
if (inWindow) {
if (presence < 0.02) smoothed = p;
smoothed += (p - smoothed) * (1 - Math.exp(-step / 0.16));
if (previous !== null && Math.abs(p - previous) > 0.0001) reversing = p < previous;
else if (direction !== 0) reversing = direction < 0;
previous = p;
} else {
previous = null;
reversing = false;
}
presence += ((inWindow ? 1 : 0) - presence) * (1 - Math.exp(-step / 0.5));
if (presence < 0.002 && !inWindow) presence = 0;
const frame = maintenanceTimeline(smoothed);
const full = tier === 'full';
const flash = full ? lightning.sample(step, frame.lightning * presence) : 0;
const live = presence > 0;
offsite.visible = full && (warm || live);
glow.visible = full && (warm || flash > 0.0005);
packets.visible = full && (warm || (live && frame.packetPhase > 0 && frame.packetPhase < 2));
warm = false;
offsiteUniforms.uLit.value = frame.offsite;
offsiteUniforms.uPresence.value = presence;
offsiteUniforms.uFlash.value = flash;
glowUniforms.uFlash.value = flash;
packetUniforms.uOut.value = Math.min(1, frame.packetPhase);
packetUniforms.uBack.value = Math.max(0, frame.packetPhase - 1);
packetUniforms.uPresence.value = presence;
return {
...frame,
dim: frame.dim * presence,
rain: full ? frame.rain * presence : 0,
storm: frame.storm * presence,
lightning: full ? frame.lightning * presence : 0,
presence,
flash,
reversing,
};
},
};
}

View File

@@ -111,6 +111,14 @@ export const CHAPTER_PALETTES: Readonly<Record<EnvironmentChapter, ChapterPalett
},
};
/**
* The 03:12 window's storm: a colder navy the Infrastructure sky leans toward
* (about 55%) while the maintenance window is open. A tint, not a chapter.
*/
export const STORM_TINT = 0x060c26;
/** Star visibility while the storm is in. */
export const STORM_STAR_SCALE = 0.4;
const paletteColors = Object.fromEntries(
Object.entries(CHAPTER_PALETTES).map(([chapter, palette]) => [chapter, {
sky: new THREE.Color(palette.sky),

View File

@@ -0,0 +1,63 @@
/* ---------- The 03:12 window (Infrastructure set-piece) ----------
html[data-maintenance-step] is set client-side while the Infrastructure
chapter is current, from section-local scroll progress (never time), so
the steps replay in reverse on the way back up. The server HTML carries
no attribute and an empty sub-line. */
/* The gantry plate's sub-line hangs under the window like a variable
message strip: "MAINTENANCE WINDOW · 03:12" ... "CUTOVER OK · 03:51". */
html .journey-hud__subline {
position: absolute;
inset: calc(100% + 5px) 0 auto;
display: block;
overflow: hidden;
padding: 3px 8px 2px;
background: #0b0918;
box-shadow: inset 0 1px rgb(139 233 237 / 32%);
color: #8be9ed;
font: 650 0.56rem/1.3 var(--mono);
letter-spacing: 0.14em;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
html .journey-hud__subline:empty { display: none; }
html[data-maintenance-step='open'] .journey-hud__subline,
html[data-maintenance-step='backup'] .journey-hud__subline { color: #ffc27a; box-shadow: inset 0 1px rgb(255 194 122 / 34%); }
@media (max-width: 767px) {
/* The phone plate is about 155px wide: "MAINTENANCE WINDOW · 03:12" (26
characters) only fits with tighter tracking and a hair of overhang. */
html .journey-hud__subline { inset: calc(100% + 3px) -6px auto; padding-inline: 2px; font-size: 0.5rem; letter-spacing: 0.04em; }
}
@media (forced-colors: active) {
html .journey-hud__subline { background: Canvas; color: CanvasText; border: 1px solid CanvasText; box-shadow: none; }
}
/* The diagram follows the window: stages 01-04 go dark when it opens and
light again one by one, in the infrastructure accent, as it runs. */
@media screen {
html[data-maintenance-step] .infrastructure-node:not(.infrastructure-node--visitor) {
--node-accent: color-mix(in srgb, #8b86a6 55%, #201a35);
}
html[data-maintenance-step='backup'] .infrastructure-node--ingress,
html[data-maintenance-step='transfer'] :is(.infrastructure-node--ingress, .infrastructure-node--access),
html[data-maintenance-step='verify'] :is(.infrastructure-node--ingress, .infrastructure-node--access, .infrastructure-node--services),
html[data-maintenance-step='done'] :is(.infrastructure-node--ingress, .infrastructure-node--access, .infrastructure-node--services, .infrastructure-node--operations) {
--node-accent: #8be9ed;
box-shadow:
inset 0 2px var(--node-accent),
0 0 0 1px rgb(139 233 237 / 30%),
0 0 26px rgb(139 233 237 / 14%),
0 12px 28px rgb(11 4 30 / 22%);
}
}
@media screen and (prefers-reduced-motion: no-preference) {
html .infrastructure-node,
html .infrastructure-node :is(strong, .infrastructure-node__sequence, .infrastructure-boundary, .infrastructure-node__guard, .infrastructure-node__connector) {
transition:
background-color 320ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 320ms cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 320ms cubic-bezier(0.22, 1, 0.36, 1),
color 320ms cubic-bezier(0.22, 1, 0.36, 1);
}
}

View File

@@ -150,3 +150,15 @@ describe('semantic portfolio document', () => {
expect(document.body.textContent).toContain('Footy Tracker');
});
});
describe('the 03:12 window on the server', () => {
it('ships no maintenance step and an empty, hidden HUD sub-line', async () => {
const html = await render('/', validInputs);
expect(html).not.toContain('data-maintenance-step');
expect(html).not.toContain('MAINTENANCE WINDOW');
const document = await renderedDocument();
const subline = document.querySelector('.journey-hud__subline');
expect(subline).not.toBeNull();
expect(subline?.textContent).toBe('');
});
});

View File

@@ -430,4 +430,22 @@ describe("JourneyHud", () => {
expect(wrapper.text()).not.toContain("Low");
expect(wrapper.attributes("aria-hidden")).toBe("true");
});
it("hangs the maintenance sub-line under the plate only when one is given", () => {
const quiet = mount(JourneyHud, {
props: { journey: journey({ activeSectionId: "experience" }), sectionLabel: "Experience" },
});
expect(quiet.get(".journey-hud__subline").text()).toBe("");
quiet.unmount();
const window = mount(JourneyHud, {
props: {
journey: journey({ activeSectionId: "infrastructure" }),
sectionLabel: "Infrastructure",
subline: "MAINTENANCE WINDOW \u00b7 03:12",
},
});
expect(window.get(".journey-hud__subline").text()).toBe("MAINTENANCE WINDOW \u00b7 03:12");
window.unmount();
});
});

View File

@@ -0,0 +1,147 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick, shallowRef } from 'vue';
import { useMaintenanceWindow } from '@/composables/useMaintenanceWindow';
import type { JourneySnapshot } from '@/journey/types';
import {
createMaintenanceDirection,
maintenanceClock,
maintenanceProgress,
maintenanceStep,
maintenanceSubline,
} from '@/journey/maintenance-progress';
import type { SectionId } from '@/content/types';
const at = (
activeSectionId: SectionId,
activeHeadingOffsetVh: number,
nextHeadingOffsetVh: number | null,
) => ({
activeSectionId,
sectionActivation: { layoutRevision: 1, activeHeadingOffsetVh, nextHeadingOffsetVh },
});
const live = () => '14:07';
describe('maintenance progress', () => {
it('is 0 at the Infrastructure heading and 1 at the next heading', () => {
expect(maintenanceProgress(at('infrastructure', 0, 2.5))).toBe(0);
expect(maintenanceProgress(at('infrastructure', -1.25, 1.25))).toBeCloseTo(0.5);
expect(maintenanceProgress(at('infrastructure', -2.5, 0))).toBe(1);
});
it('clamps to the window and is null outside the chapter or without geometry', () => {
expect(maintenanceProgress(at('infrastructure', 0.2, 2.7))).toBe(0);
expect(maintenanceProgress(at('infrastructure', -3, -0.5))).toBe(1);
expect(maintenanceProgress(at('experience', -1, 1))).toBeNull();
expect(maintenanceProgress(at('selected-builds', -1, 1))).toBeNull();
expect(maintenanceProgress(at('infrastructure', 0, null))).toBeNull();
expect(maintenanceProgress(at('infrastructure', 1, 1))).toBeNull();
expect(maintenanceProgress(at('infrastructure', Number.NaN, 1))).toBeNull();
});
it('maps progress onto open, backup, transfer, verify and done', () => {
expect(maintenanceStep(0)).toBe('open');
expect(maintenanceStep(0.099)).toBe('open');
expect(maintenanceStep(0.1)).toBe('backup');
expect(maintenanceStep(0.349)).toBe('backup');
expect(maintenanceStep(0.35)).toBe('transfer');
expect(maintenanceStep(0.649)).toBe('transfer');
expect(maintenanceStep(0.65)).toBe('verify');
expect(maintenanceStep(0.899)).toBe('verify');
expect(maintenanceStep(0.9)).toBe('done');
expect(maintenanceStep(1)).toBe('done');
});
it('holds the clock at 03:12 while the window opens, cuts over at 03:51, then goes live', () => {
expect(maintenanceClock(0, live)).toBe('03:12');
expect(maintenanceClock(0.1, live)).toBe('03:12');
expect(maintenanceClock(0.5, live)).toBe('03:32');
expect(maintenanceClock(0.9, live)).toBe('03:51');
expect(maintenanceClock(0.999, live)).toBe('03:51');
expect(maintenanceClock(1, live)).toBe('14:07');
let previous = -1;
for (let p = 0; p < 1; p += 0.01) {
const minutes = Number(maintenanceClock(p, live).slice(3));
expect(minutes).toBeGreaterThanOrEqual(previous);
previous = minutes;
}
});
it('writes the sub-line from the step, and a rollback while reversing', () => {
expect(maintenanceSubline(0.02, false, live)).toBe('MAINTENANCE WINDOW · 03:12');
expect(maintenanceSubline(0.92, false, live)).toBe('CUTOVER OK · 03:51');
expect(maintenanceSubline(0.5, true, live)).toMatch(/^ROLLBACK CHECKPOINT · 03:\d\d$/u);
expect(maintenanceSubline(0.97, false, live)).toBe('CUTOVER OK · 03:51');
});
it('latches a rollback while progress falls and clears it when it rises', () => {
const track = createMaintenanceDirection();
expect(track(0.2)).toBe(false);
expect(track(0.3)).toBe(false);
expect(track(0.25)).toBe(true);
expect(track(0.25)).toBe(true);
expect(track(0.2505)).toBe(true);
expect(track(0.3)).toBe(false);
expect(track(0.2)).toBe(true);
expect(track(null)).toBe(false);
expect(track(0.9)).toBe(false);
expect(track(0)).toBe(false);
});
});
describe('useMaintenanceWindow', () => {
const snapshot = (activeSectionId: SectionId, a: number, n: number | null): JourneySnapshot => ({
scrollY: 0,
routeProgress: 0.4,
direction: 0,
acceleration: 0,
driveSpeed: 0,
activeSectionId,
chapter: 'infrastructure',
sign: null,
arrived: false,
sectionActivation: { layoutRevision: 1, activeHeadingOffsetVh: a, nextHeadingOffsetVh: n },
});
it('steps html[data-maintenance-step] and the sub-line from scroll, then cleans up', async () => {
const journey = shallowRef(snapshot('experience', -1, 1));
let subline = shallowRef('');
const wrapper = mount(defineComponent({
setup() {
subline = useMaintenanceWindow(journey) as typeof subline;
return () => h('span', subline.value);
},
}));
const root = document.documentElement;
await nextTick();
expect(root.dataset.maintenanceStep).toBeUndefined();
expect(subline.value).toBe('');
journey.value = snapshot('infrastructure', 0, 2);
await nextTick();
expect(root.dataset.maintenanceStep).toBe('open');
expect(subline.value).toBe('MAINTENANCE WINDOW \u00b7 03:12');
journey.value = snapshot('infrastructure', -1.84, 0.16);
await nextTick();
expect(root.dataset.maintenanceStep).toBe('done');
expect(subline.value).toBe('CUTOVER OK \u00b7 03:51');
journey.value = snapshot('infrastructure', -1, 1);
await nextTick();
expect(root.dataset.maintenanceStep).toBe('transfer');
expect(subline.value).toMatch(/^ROLLBACK CHECKPOINT/u);
journey.value = snapshot('selected-builds', 0, 2);
await nextTick();
expect(root.dataset.maintenanceStep).toBeUndefined();
expect(subline.value).toBe('');
journey.value = snapshot('infrastructure', -0.4, 1.6);
await nextTick();
expect(root.dataset.maintenanceStep).toBe('backup');
wrapper.unmount();
expect(root.dataset.maintenanceStep).toBeUndefined();
});
});

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