feat: daybreak finale turns the contact arrival into a sunrise with a dawn chord

This commit is contained in:
2026-10-11 05:32:08 +03:00
parent c9e6ac0f29
commit b6e22a1299
37 changed files with 756 additions and 44 deletions

View File

@@ -12,6 +12,7 @@ import {
} from '@/audio/soundtrack-transport';
import type { AdaptiveMixer } from '@/audio/adaptive-mixer';
import type { DriveTexture } from '@/audio/drive-texture';
import type { DawnResolve } from '@/audio/dawn-resolve';
import type {
AudioInitialState,
SoundtrackSources,
@@ -41,6 +42,7 @@ interface EngineHarnessOptions {
readonly rejectDriveLift?: boolean;
readonly rejectDriveTexture?: boolean;
readonly deferDriveLift?: boolean;
readonly rejectDawn?: boolean;
}
interface Deferred<T> {
@@ -71,10 +73,12 @@ function createEngineHarness(
const bedInput = harness.context.createGain();
const driveLiftInput = harness.context.createGain();
const driveTextureInput = harness.context.createGain();
const dawnInput = harness.context.createGain();
const mixer = {
bedInput,
driveLiftInput,
driveTextureInput,
dawnInput,
setChapterIntent: vi.fn(),
setDriveEnergy: vi.fn(),
setTransportAnchor: vi.fn(),
@@ -85,6 +89,14 @@ function createEngineHarness(
setEnergy: vi.fn(),
dispose: vi.fn(),
} satisfies DriveTexture;
const dawn = {
setDawn: vi.fn(),
dispose: vi.fn(),
} satisfies DawnResolve;
const createDawnResolve = vi.fn((_context: AudioContext, _destination: AudioNode) => {
if (options.rejectDawn) throw new Error('dawn chord unavailable');
return dawn;
});
const createMixer = vi.fn(() => mixer);
const createDriveTexture = vi.fn(() => {
if (options.rejectDriveTexture) throw new Error('drive texture unavailable');
@@ -116,6 +128,7 @@ function createEngineHarness(
loadBuffer,
createMixer,
createDriveTexture,
createDawnResolve,
});
const sourcesFor = (url: string): FakeSourceRecord[] => {
@@ -144,6 +157,8 @@ function createEngineHarness(
harness,
mixer,
texture,
dawn,
createDawnResolve,
loadCalls,
sourcesFor,
sourceFor,
@@ -348,7 +363,35 @@ describe('soundtrack engine', () => {
expect(source.disconnectCount).toBe(1);
}
expect(test.texture.dispose).toHaveBeenCalledOnce();
expect(test.dawn.dispose).toHaveBeenCalledOnce();
expect(test.mixer.dispose).toHaveBeenCalledOnce();
expect(test.harness.closeCount).toBe(1);
});
it('routes the dawn chord into the mixer and resolves it only at the destination', () => {
const test = createEngineHarness();
expect(test.createDawnResolve).toHaveBeenCalledWith(test.harness.context, test.mixer.dawnInput);
expect(test.dawn.setDawn).not.toHaveBeenCalled();
test.graph.setChapterIntent(resolveChapterIntent('toolkit', 1));
expect(test.dawn.setDawn).toHaveBeenLastCalledWith(false);
test.graph.setChapterIntent(resolveChapterIntent('contact', 2));
expect(test.dawn.setDawn).toHaveBeenLastCalledWith(true);
// A repeated intent for the same chapter does not re-trigger the chord.
const calls = test.dawn.setDawn.mock.calls.length;
test.graph.setChapterIntent(resolveChapterIntent('contact', 3));
expect(test.dawn.setDawn).toHaveBeenCalledTimes(calls);
test.graph.setChapterIntent(resolveChapterIntent('toolkit', 4));
expect(test.dawn.setDawn).toHaveBeenLastCalledWith(false);
});
it('keeps playing when the dawn chord cannot be built, and ignores it after close', async () => {
const test = createEngineHarness(initialState, { rejectDawn: true });
expect(() => test.graph.setChapterIntent(resolveChapterIntent('contact', 1))).not.toThrow();
expect(test.dawn.setDawn).not.toHaveBeenCalled();
const closed = createEngineHarness();
await closed.graph.close();
closed.graph.setChapterIntent(resolveChapterIntent('contact', 1));
expect(closed.dawn.setDawn).not.toHaveBeenCalled();
expect(closed.dawn.dispose).toHaveBeenCalledOnce();
});
});