477 lines
21 KiB
TypeScript
477 lines
21 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("bounds decoded audio memory, refreshes recent cues, and preserves active playback", async () => {
|
|
vi.resetModules();
|
|
const audio = await import("./combatAudio");
|
|
const sources: { stop: ReturnType<typeof vi.fn>; buffer: unknown }[] = [];
|
|
const buffer = { duration: 1, length: 1024 * 1024, numberOfChannels: 2 };
|
|
vi.stubGlobal("window", { AudioContext: function () {
|
|
return {
|
|
currentTime: 0, state: "running", destination: {},
|
|
createGain: () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), value: 1 } }),
|
|
createBufferSource: () => {
|
|
const source = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, onended: null };
|
|
sources.push(source);
|
|
return source;
|
|
},
|
|
decodeAudioData: async () => buffer,
|
|
resume: async () => undefined,
|
|
};
|
|
} });
|
|
combatMedia.ready = true;
|
|
combatMedia.fetch.mockImplementation(async () => new Response(new Uint8Array([1])));
|
|
audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
|
|
for (const cue of [1, 2, 3, 4, 1, 5]) await audio.playMasterAudioCue(`/cue-${cue}.wav`);
|
|
expect(combatMedia.fetch).toHaveBeenCalledTimes(5);
|
|
await audio.playMasterAudioCue("/cue-1.wav");
|
|
expect(combatMedia.fetch).toHaveBeenCalledTimes(5);
|
|
await audio.playMasterAudioCue("/cue-2.wav");
|
|
expect(combatMedia.fetch).toHaveBeenCalledTimes(6);
|
|
expect(sources[1].buffer).toBe(buffer);
|
|
expect(sources.every((source) => source.stop.mock.calls.length === 0)).toBe(true);
|
|
audio.resetCombatAudio();
|
|
});
|
|
|
|
it("bounds the number of cached cues, shares pending decodes, and retries failed audio", async () => {
|
|
vi.resetModules();
|
|
const audio = await import("./combatAudio");
|
|
vi.stubGlobal("window", { AudioContext: function () {
|
|
return {
|
|
currentTime: 0, state: "running", destination: {},
|
|
createGain: () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: { setTargetAtTime: vi.fn(), value: 1 } }),
|
|
createBufferSource: () => ({ connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(), buffer: null, onended: null }),
|
|
decodeAudioData: async () => ({ duration: 0.1, length: 100, numberOfChannels: 1 }),
|
|
resume: async () => undefined,
|
|
};
|
|
} });
|
|
combatMedia.ready = true;
|
|
combatMedia.fetch.mockImplementation(async () => new Response(new Uint8Array([1])));
|
|
audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
|
|
const stops = await Promise.all([audio.playMasterAudioCue("/shared.wav"), audio.playMasterAudioCue("/shared.wav")]);
|
|
stops.forEach((stop) => stop?.());
|
|
expect(combatMedia.fetch).toHaveBeenCalledTimes(1);
|
|
for (let index = 0; index < 65; index += 1) (await audio.playMasterAudioCue(`/small-${index}.wav`))?.();
|
|
const calls = combatMedia.fetch.mock.calls.length;
|
|
(await audio.playMasterAudioCue("/small-64.wav"))?.();
|
|
expect(combatMedia.fetch).toHaveBeenCalledTimes(calls);
|
|
(await audio.playMasterAudioCue("/shared.wav"))?.();
|
|
expect(combatMedia.fetch).toHaveBeenCalledTimes(calls + 1);
|
|
combatMedia.fetch.mockResolvedValueOnce(new Response(null, { status: 404 }));
|
|
expect(await audio.playMasterAudioCue("/retry.wav")).toBeNull();
|
|
expect(await audio.playMasterAudioCue("/retry.wav")).toBeTypeOf("function");
|
|
audio.resetCombatAudio();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|