feat: relay station masts come online through the toolkit chapter

This commit is contained in:
2026-10-11 08:32:33 +03:00
parent 568b0802b4
commit 33585f7e34
22 changed files with 935 additions and 2 deletions

View File

@@ -0,0 +1,158 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SceneResourceBag } from '@/scene/resources';
import { BEAM_INTENSITY, createRelayMasts, RUNG_FLASH_GAP_SECONDS, relayLayout } from '@/scene/world/relay-masts';
import { publishRelay } from '@/journey/relay-progress';
import { readSwitchboard } from '@/audio/music-pulse';
import { playRelayTone } from '@/audio/relay-tone';
import type { PortfolioContent, SectionId } from '@/content/types';
const content = JSON.parse(
readFileSync(resolve(process.cwd(), 'content/public/portfolio-content.json'), 'utf8'),
) as PortfolioContent;
const counts = content.toolkit.map((group) => group.technologies.length);
afterEach(() => publishRelay([]));
const station = (full: boolean) => {
publishRelay(counts);
const tone = vi.fn();
return { world: createRelayMasts(new SceneResourceBag(), full, tone), tone };
};
describe('relay mast layout', () => {
it('stands four big masts on the far shoulders, alternating sides, first on the right', () => {
const masts = relayLayout(0.5, counts);
expect(masts).toHaveLength(4);
masts.forEach((mast, i) => {
expect(Math.abs(mast.x)).toBeGreaterThanOrEqual(50);
expect(mast.height).toBeGreaterThanOrEqual(40);
expect(Math.sign(mast.x)).toBe(i % 2 === 0 ? 1 : -1);
expect(mast.z).toBeLessThanOrEqual(-40);
expect(mast.z).toBeGreaterThanOrEqual(-130);
});
expect(masts.map((mast) => mast.rungs.length)).toEqual(counts);
expect(BEAM_INTENSITY).toBeLessThanOrEqual(0.8);
});
it('keeps the pair on each side apart and inside the frame, on wide and narrow lenses', () => {
for (const aspect of [1.6, 1024 / 700]) {
const masts = relayLayout(0.5, counts, true, aspect);
const tanHalfWidth = Math.tan((62 / 2) * (Math.PI / 180)) * aspect;
// Screen x of a mast's centre as a fraction of the half-width, and its lamp half-span in the same units.
const frame = (mast: (typeof masts)[number]) => {
const distance = 25 - mast.z;
const unit = 1 / (tanHalfWidth * distance);
return { centre: Math.abs(mast.x) * unit, span: mast.rungs[0]!.halfWidth * 1.15 * unit };
};
const near = frame(masts[0]!);
const far = frame(masts[2]!);
expect(near.centre + near.span).toBeLessThanOrEqual(1);
expect(far.centre - far.span).toBeGreaterThanOrEqual(0.84);
expect(far.centre + far.span).toBeLessThan(near.centre - near.span);
}
});
it('is absent outside the chapter and fades at its edges', () => {
expect(relayLayout(null, counts).every((mast) => mast.presence === 0)).toBe(true);
expect(relayLayout(0, counts).every((mast) => mast.presence === 0)).toBe(true);
expect(relayLayout(1, counts).every((mast) => mast.presence === 0)).toBe(true);
expect(relayLayout(0.5, counts).every((mast) => mast.presence === 1)).toBe(true);
});
});
describe('relay masts world', () => {
it('draws one rung instance per technology for the current content', () => {
const { world } = station(true);
world.update('toolkit', 0.5, 0, undefined, 0.016, 0);
expect(world.rungs()).toHaveLength(26);
expect(world.group.visible).toBe(true);
});
it('is hidden outside Toolkit', () => {
const { world } = station(true);
for (const section of ['start', 'selected-builds', 'contact'] as SectionId[]) {
world.update(section, 0.5, 0, undefined, 0.016, 0);
expect(world.group.visible).toBe(false);
}
world.update('toolkit', null, 0, undefined, 0.016, 0);
expect(world.group.visible).toBe(false);
});
it('lights rungs in order with scroll and unlights them in reverse', () => {
const { world } = station(true);
world.update('toolkit', 0.3, 0, undefined, 0.016, 0);
const lit = world.rungs().map((rung) => rung.lit);
expect(lit.slice(0, 11).every((v) => v === 1)).toBe(true);
expect(lit.slice(19).every((v) => v === 0)).toBe(true);
world.update('toolkit', 0.1, 0, undefined, 0.016, 16);
expect(world.rungs().slice(11).every((rung) => rung.lit === 0)).toBe(true);
});
it('chirps once per mast coming online, never on a jump into the chapter', () => {
const { world, tone } = station(true);
world.update('contact', null, 0, undefined, 0.016, 0);
world.update('toolkit', 0.5, 0, undefined, 0.016, 16);
expect(tone).not.toHaveBeenCalled();
world.update('toolkit', 0.6, 0, undefined, 0.016, 32);
expect(tone).toHaveBeenCalledTimes(1);
world.update('toolkit', 0.3, 0, undefined, 0.016, 48);
expect(tone).toHaveBeenCalledTimes(1);
});
it('flashes a hovered rung at most every 0.35 s', () => {
const { world } = station(true);
world.update('toolkit', 0.5, 0, undefined, 0.016, 0);
world.update('toolkit', 0.5, 0, '0:2', 0.016, 16);
expect(world.rungs()[2]!.flash).toBe(1);
world.update('toolkit', 0.5, 0, '1:0', 0.1, 116);
expect(world.rungs()[11]!.flash).toBe(1);
world.update('toolkit', 0.5, 0, '0:2', 0.1, 216);
expect(world.rungs()[2]!.flash).toBeLessThan(1);
world.update('toolkit', 0.5, 0, '1:0', RUNG_FLASH_GAP_SECONDS, 566);
world.update('toolkit', 0.5, 0, '0:2', 0.016, 582);
expect(world.rungs()[2]!.flash).toBe(1);
});
it('lite: every rung lit from chapter entry, steady beacons, no hover flash and no tone', () => {
const { world, tone } = station(false);
world.update('toolkit', 0.02, 0, undefined, 0.016, 0);
expect(world.rungs().every((rung) => rung.lit === 1)).toBe(true);
const levels = new Set<number>();
for (let t = 0; t < 4000; t += 100) {
world.update('toolkit', 0.3 + t / 20000, 0, t % 200 === 0 ? '0:1' : '0:2', 0.1, t);
world.beacons().forEach((level) => levels.add(Math.round(level * 1000)));
}
expect(levels.size).toBe(1);
expect(world.rungs().every((rung) => rung.flash === 0)).toBe(true);
expect(tone).not.toHaveBeenCalled();
});
it('full: beacons blink at 0.5 Hz (one cycle every 2 s)', () => {
const { world } = station(true);
const samples: number[] = [];
for (let t = 0; t <= 2000; t += 50) {
world.update('toolkit', 0.7, 0, undefined, 0.05, t);
samples.push(world.beacons()[0]!);
}
expect(samples[0]).toBeCloseTo(samples[40]!, 5);
expect(Math.max(...samples) - Math.min(...samples)).toBeGreaterThan(0.5);
});
it('nudges the lens while a mast is online and eases back', () => {
const { world } = station(true);
let nudge = 0;
for (let i = 0; i < 60; i += 1) nudge = world.update('toolkit', 0.5, 0, undefined, 0.016, i * 16);
expect(nudge).toBe(1);
for (let i = 0; i < 60; i += 1) nudge = world.update('contact', null, 0, undefined, 0.016, 1000 + i * 16);
expect(nudge).toBe(0);
});
});
describe('relay tone', () => {
it('stays silent without an audible switchboard', () => {
expect(readSwitchboard()).toBeNull();
expect(playRelayTone()).toBe(false);
});
});