Files
healer-man/src/game/combatAudio.test.ts
T
2026-09-25 18:46:09 -04:00

415 lines
17 KiB
TypeScript

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<typeof import("./combatMediaPack")>()),
combatMediaPackReady: () => combatMedia.ready,
fetchOptionalMediaAsset: combatMedia.fetch,
}));
vi.mock("./combatPresentation", async (importOriginal) => ({
...(await importOriginal<typeof import("./combatPresentation")>()),
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<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn>;
start: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn>;
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<typeof vi.fn>; 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<Response>((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();
});
});