import { afterEach, describe, expect, it, vi } from "vitest"; const combatMedia = vi.hoisted(() => ({ ready: false, fetch: vi.fn(), entry: vi.fn(() => null as import("./combatPresentation").CombatEffectManifestEntry | null), })); vi.mock("./combatMediaPack", async (importOriginal) => ({ ...(await importOriginal()), combatMediaPackReady: () => combatMedia.ready, fetchOptionalMediaAsset: combatMedia.fetch, })); vi.mock("./combatPresentation", async (importOriginal) => ({ ...(await importOriginal()), combatEffectManifestEntry: combatMedia.entry, })); import { DEFAULT_COMBAT_PRESENTATION_SETTINGS, type CombatPresentationEvent, } from "./combatPresentation"; import { combatAudioOutputEnabled, authenticCombatAudioEnabled, combatMediaPreloadEnabled, configureCombatAudio, masterAudioOutputEnabled, playCombatAudio, playMasterAudioCue, selectCombatSoundAssets, } from "./combatAudio"; const fixtureEvent: CombatPresentationEvent = { id: 1, sessionId: 1, occurredAt: 1, phase: "impact", actorGroup: "player", sourceActorId: "player", targetActorId: "enemy", abilityId: "fixture", name: "Fixture", source: "fallback", school: "holy", delivery: "projectile", origin: [0, 0, 0], targetPosition: [1, 0, 0], }; afterEach(() => { combatMedia.ready = false; combatMedia.fetch.mockReset(); combatMedia.entry.mockReset(); vi.unstubAllGlobals(); }); describe("combat audio output policy", () => { it("plays source cues only on their mapped phase, with instant-cast release support", () => { const cast = { url: "/cast.mp3", phase: "cast-start" as const }; const impact = { url: "/impact.mp3", phase: "impact" as const }; expect(selectCombatSoundAssets([cast, impact], { phase: "cast-start" })).toEqual([cast]); expect(selectCombatSoundAssets([cast, impact], { phase: "impact" })).toEqual([impact]); expect(selectCombatSoundAssets([cast, impact], { phase: "tick" })).toEqual([]); expect(selectCombatSoundAssets([cast], { phase: "release", instantCast: true })).toEqual([cast]); expect(selectCombatSoundAssets([cast], { phase: "release" })).toEqual([]); }); it("does not create or fetch audio while the master volume is disabled", async () => { const AudioContextConstructor = vi.fn(); const fetch = vi.fn(); vi.stubGlobal("window", { AudioContext: AudioContextConstructor }); vi.stubGlobal("fetch", fetch); configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0); playCombatAudio(fixtureEvent); await expect(playMasterAudioCue("/fixture.wav")).resolves.toBeNull(); expect(AudioContextConstructor).not.toHaveBeenCalled(); expect(fetch).not.toHaveBeenCalled(); }); it("allows explicitly enabled positive-volume output", () => { expect(masterAudioOutputEnabled(0)).toBe(false); expect(masterAudioOutputEnabled(0.6)).toBe(true); expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0)).toBe(false); expect(combatAudioOutputEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(true); expect(authenticCombatAudioEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS)).toBe(false); combatMedia.ready = true; expect(authenticCombatAudioEnabled({ ...DEFAULT_COMBAT_PRESENTATION_SETTINGS, originalMediaEnabled: true, })).toBe(true); expect(combatMediaPreloadEnabled(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6)).toBe(true); expect(combatMediaPreloadEnabled({ ...DEFAULT_COMBAT_PRESENTATION_SETTINGS, originalMediaEnabled: true, }, 0.6)).toBe(true); }); it("does not fetch packaged sound effects before the player downloads them", async () => { const AudioContextConstructor = vi.fn(); const fetch = vi.fn(); vi.stubGlobal("window", { AudioContext: AudioContextConstructor }); vi.stubGlobal("fetch", fetch); configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6); await expect(playMasterAudioCue("/assets/game/combat-effects/audio/fixture.wav")).resolves.toBeNull(); expect(AudioContextConstructor).not.toHaveBeenCalled(); expect(fetch).not.toHaveBeenCalled(); }); it("does not initialize audio for a zero-volume actor group", () => { const AudioContextConstructor = vi.fn(); vi.stubGlobal("window", { AudioContext: AudioContextConstructor }); configureCombatAudio({ ...DEFAULT_COMBAT_PRESENTATION_SETTINGS, audioVolume: { ...DEFAULT_COMBAT_PRESENTATION_SETTINGS.audioVolume, player: 0, }, }, 0.6); playCombatAudio(fixtureEvent); expect(AudioContextConstructor).not.toHaveBeenCalled(); }); it("plays a generated spatial cue when an original sound is unavailable", () => { const oscillator = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), frequency: { setValueAtTime: vi.fn() }, type: "sine", onended: null, }; const audioContext = { currentTime: 0, state: "running", destination: {}, createGain: () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), setValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn(), value: 1, }, }), createStereoPanner: () => ({ connect: vi.fn(), disconnect: vi.fn() }), createOscillator: vi.fn(() => oscillator), resume: vi.fn(async () => undefined), }; vi.stubGlobal("window", { AudioContext: function () { return audioContext; } }); configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6); playCombatAudio(fixtureEvent); expect(audioContext.createOscillator).toHaveBeenCalledTimes(1); expect(oscillator.start).toHaveBeenCalledTimes(1); expect(oscillator.stop).toHaveBeenCalledTimes(1); expect(combatMedia.fetch).not.toHaveBeenCalled(); }); it.each(["original", "missing-file", "bad-audio"])("uses original audio or a generated fallback for %s", async (scenario) => { vi.resetModules(); const audio = await import("./combatAudio"); const source = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, loop: false, onended: null }; const oscillator = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), frequency: { setValueAtTime: vi.fn() }, type: "sine", onended: null, }; const audioContext = { currentTime: 0, state: "running", destination: {}, createGain: () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), setValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn(), value: 1, }, }), createStereoPanner: () => ({ connect: vi.fn(), disconnect: vi.fn() }), createBufferSource: vi.fn(() => source), createOscillator: vi.fn(() => oscillator), decodeAudioData: vi.fn(async () => { if (scenario === "bad-audio") throw new Error("Invalid audio"); return { duration: 1 }; }), resume: vi.fn(async () => undefined), }; vi.stubGlobal("window", { AudioContext: function () { return audioContext; } }); combatMedia.ready = true; combatMedia.entry.mockReturnValue({ key: "wow335:2061", source: "wow335", spellId: 2061, sounds: [{ url: "/assets/game/combat-effects/audio/flash-heal.wav" }], }); combatMedia.fetch.mockResolvedValue(new Response(new Uint8Array([1, 2, 3]), { status: scenario === "missing-file" ? 404 : 200, })); audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6); audio.playCombatAudio({ ...fixtureEvent, source: "wow335", sourceSpellId: 2061 }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(combatMedia.fetch).toHaveBeenCalledWith("/assets/game/combat-effects/audio/flash-heal.wav"); expect(source.start).toHaveBeenCalledTimes(scenario === "original" ? 1 : 0); expect(source.loop).toBe(false); expect(audioContext.createBufferSource).toHaveBeenCalledTimes(scenario === "original" ? 1 : 0); expect(audioContext.createOscillator).toHaveBeenCalledTimes(scenario === "original" ? 0 : 1); expect(oscillator.start).toHaveBeenCalledTimes(scenario === "original" ? 0 : 1); audio.resetCombatAudio(); }); it("positions release audio at the caster and impact audio at the target", async () => { vi.resetModules(); const audio = await import("./combatAudio"); const panners: Array<{ positionX: { value: number }; positionY: { value: number }; positionZ: { value: number }; }> = []; const audioContext = { currentTime: 0, state: "running", destination: {}, createGain: () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), value: 1, } }), createPanner: () => { const panner = { connect: vi.fn(), disconnect: vi.fn(), positionX: { value: 0 }, positionY: { value: 0 }, positionZ: { value: 0 }, panningModel: "HRTF", distanceModel: "inverse", refDistance: 0, maxDistance: 0, rolloffFactor: 0, }; panners.push(panner); return panner; }, createBufferSource: () => ({ connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, loop: false, onended: null, }), decodeAudioData: vi.fn(async () => ({ duration: 0.2 })), resume: vi.fn(async () => undefined), }; vi.stubGlobal("window", { AudioContext: function () { return audioContext; } }); combatMedia.ready = true; combatMedia.entry.mockReturnValue({ key: "runewaker:490256", source: "runewaker", spellId: 490256, sounds: [ { url: "/assets/game/combat-effects/audio/rising-release.mp3", phase: "release" }, { url: "/assets/game/combat-effects/audio/rising-impact.mp3", phase: "impact" }, ], }); combatMedia.fetch.mockImplementation(async () => new Response(new Uint8Array([1, 2, 3]))); audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6); const release = { ...fixtureEvent, source: "runewaker" as const, sourceSpellId: 490256, phase: "release" as const, origin: [1, 2, 3] as const, targetPosition: [9, 8, 7] as const, }; audio.playCombatAudio(release); audio.playCombatAudio({ ...release, id: 2, phase: "impact" }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(panners.map((panner) => [ panner.positionX.value, panner.positionY.value, panner.positionZ.value, ])).toEqual([[1, 2, 3], [9, 8, 7]]); audio.resetCombatAudio(); }); it("loops only a flagged cast sound and stops the matching actor and ability", async () => { vi.resetModules(); const audio = await import("./combatAudio"); const sources: Array<{ connect: ReturnType; disconnect: ReturnType; start: ReturnType; stop: ReturnType; buffer: null; loop: boolean; onended: null; }> = []; const oscillator = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), frequency: { setValueAtTime: vi.fn() }, type: "sine", onended: null, }; const audioContext = { currentTime: 0, state: "running", destination: {}, createGain: () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), setValueAtTime: vi.fn(), exponentialRampToValueAtTime: vi.fn(), value: 1, }, }), createStereoPanner: () => ({ connect: vi.fn(), disconnect: vi.fn() }), createBufferSource: vi.fn(() => { const source = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, loop: false, onended: null, }; sources.push(source); return source; }), createOscillator: vi.fn(() => oscillator), decodeAudioData: vi.fn(async () => ({ duration: 0.2 })), resume: vi.fn(async () => undefined), }; vi.stubGlobal("window", { AudioContext: function () { return audioContext; } }); combatMedia.ready = true; combatMedia.entry.mockReturnValue({ key: "runewaker:491147", source: "runewaker", spellId: 491147, sounds: [{ url: "/assets/game/combat-effects/audio/holy-cast.wav", phase: "cast-start", loopDuringCast: true }], }); combatMedia.fetch.mockResolvedValue(new Response(new Uint8Array([1, 2, 3]))); audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6); const cast = { ...fixtureEvent, source: "runewaker" as const, sourceSpellId: 491147, phase: "cast-start" as const, abilityId: "rom-priest-general-0", durationMs: 1_000, }; audio.playCombatAudio(cast); audio.playCombatAudio({ ...cast, id: 2, sourceActorId: "second-priest" }); audio.playCombatAudio({ ...cast, id: 5, abilityId: "rom-priest-primary-2" }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(sources).toHaveLength(3); expect(sources.every((source) => source.loop && source.start.mock.calls.length === 1)).toBe(true); audio.playCombatAudio({ ...cast, id: 3, phase: "cast-cancel" }); expect(sources[0].stop).toHaveBeenCalledTimes(1); expect(sources[1].stop).not.toHaveBeenCalled(); expect(sources[2].stop).not.toHaveBeenCalled(); audio.playCombatAudio({ ...cast, id: 4, sourceActorId: "second-priest", phase: "release" }); expect(sources[1].stop).toHaveBeenCalledTimes(1); expect(sources[2].stop).not.toHaveBeenCalled(); audio.playCombatAudio({ ...cast, id: 6, abilityId: "rom-priest-primary-2", phase: "cast-cancel" }); expect(sources[2].stop).toHaveBeenCalledTimes(1); audio.resetCombatAudio(); }); it("plays separate RuneWaker source cues together on one impact", async () => { vi.resetModules(); const audio = await import("./combatAudio"); const sources: Array<{ start: ReturnType; loop: boolean }> = []; const audioContext = { currentTime: 0, state: "running", destination: {}, createGain: () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), value: 1, } }), createStereoPanner: () => ({ connect: vi.fn(), disconnect: vi.fn() }), createBufferSource: vi.fn(() => { const source = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, loop: false, onended: null }; sources.push(source); return source; }), createOscillator: vi.fn(), decodeAudioData: vi.fn(async () => ({ duration: 0.2 })), resume: vi.fn(async () => undefined), }; vi.stubGlobal("window", { AudioContext: function () { return audioContext; } }); combatMedia.ready = true; combatMedia.entry.mockReturnValue({ key: "runewaker:491168", source: "runewaker", spellId: 491168, sounds: [ { url: "/assets/game/combat-effects/audio/hit.mp3", phase: "impact" }, { url: "/assets/game/combat-effects/audio/dot.mp3", phase: "impact" }, ], }); combatMedia.fetch.mockImplementation(async () => new Response(new Uint8Array([1, 2, 3]))); audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6); audio.playCombatAudio({ ...fixtureEvent, source: "runewaker", sourceSpellId: 491168 }); await new Promise((resolve) => setTimeout(resolve, 10)); expect(sources).toHaveLength(2); expect(sources.every((source) => source.start.mock.calls.length === 1 && !source.loop)).toBe(true); expect(combatMedia.fetch).toHaveBeenCalledTimes(2); audio.resetCombatAudio(); }); it("does not start a loop after cancellation and caps a cast loop at its duration", async () => { vi.resetModules(); const audio = await import("./combatAudio"); const source = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, loop: false, onended: null, }; const audioContext = { currentTime: 0, state: "running", destination: {}, createGain: () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), value: 1, } }), createStereoPanner: () => ({ connect: vi.fn(), disconnect: vi.fn() }), createBufferSource: vi.fn(() => source), decodeAudioData: vi.fn(async () => ({ duration: 0.2 })), resume: vi.fn(async () => undefined), }; vi.stubGlobal("window", { AudioContext: function () { return audioContext; } }); combatMedia.ready = true; combatMedia.entry.mockReturnValue({ key: "runewaker:491147", source: "runewaker", spellId: 491147, sounds: [{ url: "/assets/game/combat-effects/audio/holy-cast.wav", phase: "cast-start", loopDuringCast: true }], }); let resolveFetch!: (response: Response) => void; combatMedia.fetch.mockImplementation(() => new Promise((resolve) => { resolveFetch = resolve; })); audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6); const cast = { ...fixtureEvent, source: "runewaker" as const, sourceSpellId: 491147, phase: "cast-start" as const, abilityId: "rom-priest-general-0", durationMs: 25, }; audio.playCombatAudio(cast); audio.playCombatAudio({ ...cast, id: 2, phase: "cast-cancel" }); resolveFetch(new Response(new Uint8Array([1, 2, 3]))); await new Promise((resolve) => setTimeout(resolve, 0)); expect(audioContext.createBufferSource).not.toHaveBeenCalled(); audio.playCombatAudio({ ...cast, id: 3 }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(source.loop).toBe(true); expect(source.start).toHaveBeenCalledTimes(1); await new Promise((resolve) => setTimeout(resolve, 40)); expect(source.stop).toHaveBeenCalledTimes(1); audio.resetCombatAudio(); }); });