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