196 lines
8.6 KiB
TypeScript
196 lines
8.6 KiB
TypeScript
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_CORE,
|
|
RUNG_FLASH_GAP_SECONDS,
|
|
RUNG_HALO,
|
|
RUNG_HALO_GAIN,
|
|
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('portrait: one large transmitter carries the rungs of every group, bottom-up in group order', () => {
|
|
const masts = relayLayout(0.5, counts, false, 390 / 844);
|
|
expect(masts).toHaveLength(4);
|
|
expect(masts.filter((mast) => mast.shown)).toHaveLength(1);
|
|
const [stand] = masts;
|
|
// One stand, and it is big: well over twice the design mast.
|
|
masts.forEach((mast) => expect([mast.x, mast.z, mast.scale]).toEqual([stand!.x, stand!.z, stand!.scale]));
|
|
expect(stand!.height).toBeGreaterThan(2 * relayLayout(0.5, counts)[0]!.height);
|
|
// On screen, left of the road and inside the frame (portrait lens: no shift).
|
|
const tanHalfWidth = Math.tan((62 / 2) * (Math.PI / 180)) * (390 / 844);
|
|
const fx = stand!.x / (tanHalfWidth * (25 - stand!.z));
|
|
expect(fx).toBeLessThan(-0.2);
|
|
expect(fx).toBeGreaterThan(-0.7);
|
|
// Rungs: one per technology, strictly rising through the groups, inside the mast.
|
|
const ys = masts.flatMap((mast) => mast.rungs.map((rung) => rung.y));
|
|
expect(ys).toHaveLength(counts.reduce((sum, count) => sum + count, 0));
|
|
ys.slice(1).forEach((y, i) => expect(y).toBeGreaterThan(ys[i]!));
|
|
expect(ys.at(-1)!).toBeLessThan(stand!.height);
|
|
});
|
|
|
|
it('keeps lit rung cores under the tone shoulder and bloom threshold, violet not white', () => {
|
|
const luminance = (c: readonly number[]) => 0.2126 * c[0]! + 0.7152 * c[1]! + 0.0722 * c[2]!;
|
|
expect(luminance(RUNG_CORE)).toBeLessThan(0.3);
|
|
expect(luminance(RUNG_CORE.map((v, i) => v + RUNG_HALO[i]! * RUNG_HALO_GAIN))).toBeLessThan(0.85);
|
|
// Saturated: blue dominates, green is a small fraction (lavender-white would be near-equal channels).
|
|
expect(RUNG_CORE[1] / RUNG_CORE[2]).toBeLessThan(0.2);
|
|
expect(RUNG_HALO[1] / RUNG_HALO[2]).toBeLessThan(0.2);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|