import { open } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import campaign from "../../dungeon-pipeline/dungeon-campaign.json"; import { DUNGEON_DEFINITIONS } from "./dungeonRegistry"; import { WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS, WOW_DUNGEON_NO_TRASH_EXCEPTIONS, } from "./wowDungeonContentExceptions"; import { createMobAnimationTransform, getMobAnimationPhase, resolveMobAnimationClip, resolveMobCombatAnimationClip, sampleMobAnimation, sampleMobCombatFallback, } from "./mobAnimation"; const publicRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../public"); const campaignIds = new Set(campaign.dungeons.map((dungeon) => dungeon.slug)); const basicOnlyBossExceptions = new Set(WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.map(({ key }) => key)); const noTrashExceptions = new Set(WOW_DUNGEON_NO_TRASH_EXCEPTIONS.map(({ key }) => key)); const animationCache = new Map>(); async function readAnimations(url: string): Promise { const cached = animationCache.get(url); if (cached) return cached; const result = (async () => { const handle = await open(path.join(publicRoot, url.replace(/^\/+/, "")), "r"); try { const header = Buffer.alloc(20); await handle.read(header, 0, header.length, 0); expect(header.toString("ascii", 0, 4), url).toBe("glTF"); expect(header.readUInt32LE(4), url).toBe(2); expect(header.readUInt32LE(16), url).toBe(0x4e4f534a); const jsonLength = header.readUInt32LE(12); const json = Buffer.alloc(jsonLength); await handle.read(json, 0, jsonLength, 20); const document = JSON.parse(json.toString("utf8").trimEnd()) as { readonly animations?: readonly { readonly name?: string }[]; }; return (document.animations ?? []).map((animation, index) => ({ name: animation.name ?? "animation-" + index, })); } finally { await handle.close(); } })(); animationCache.set(url, result); return result; } describe("WoW dungeon combat and animation coverage", () => { it("gives all 54 campaign dungeons runtime-spawnable combat populations", async () => { const dungeons = DUNGEON_DEFINITIONS.filter((definition) => campaignIds.has(definition.id)); expect(dungeons).toHaveLength(54); expect(basicOnlyBossExceptions.size).toBe(WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.length); expect(noTrashExceptions.size).toBe(WOW_DUNGEON_NO_TRASH_EXCEPTIONS.length); const actualBasicOnlyBosses = dungeons.flatMap((dungeon) => Object.entries(dungeon.entities) .filter(([, entity]) => ( entity.kind === "boss" && !(entity.combat?.attacks ?? []).some((attack) => attack.id !== "basic-attack") )) .map(([entityId]) => `${dungeon.id}:${entityId}`)) .sort(); expect(actualBasicOnlyBosses).toEqual([...basicOnlyBossExceptions].sort()); expect(dungeons .filter((dungeon) => !Object.values(dungeon.entities).some((entity) => entity.kind === "mob")) .map((dungeon) => dungeon.id) .sort()).toEqual([...noTrashExceptions].sort()); for (const dungeon of dungeons) { const entities = Object.values(dungeon.entities); const mobs = entities.filter((entity) => entity.kind === "mob"); const bosses = entities.filter((entity) => entity.kind === "boss"); expect(entities.length, dungeon.id + ": entities").toBeGreaterThan(0); expect(bosses.length, dungeon.id + ": bosses").toBeGreaterThan(0); if (!noTrashExceptions.has(dungeon.id)) { expect(mobs.length, dungeon.id + ": mobs").toBeGreaterThan(0); } expect( dungeon.staticSpawns.length + dungeon.roamingPacks.length + (dungeon.encounterSpawnGroups?.reduce((count, group) => count + group.spawns.length, 0) ?? 0), dungeon.id + ": population", ).toBeGreaterThan(0); for (const spawn of dungeon.staticSpawns) { expect( dungeon.entities[spawn.entityId], dungeon.id + ":" + spawn.id, ).toBeDefined(); } for (const pack of dungeon.roamingPacks) { for (const member of pack.members) { expect( dungeon.entities[member.entityId], dungeon.id + ":" + pack.id + ":" + member.id, ).toBeDefined(); } } for (const group of dungeon.encounterSpawnGroups ?? []) { for (const spawn of group.spawns) { expect( dungeon.entities[spawn.entityId], dungeon.id + ":" + group.id + ":" + spawn.id, ).toBeDefined(); } } for (const entity of entities) { const key = dungeon.id + ":" + entity.id; expect(entity.combat, key + ": combat").toBeDefined(); const attacks = entity.combat?.attacks ?? []; expect(attacks.length, key + ": attacks").toBeGreaterThan(0); for (const attack of attacks) { expect(["attack", "cast"], key + ":" + attack.id + ": animation").toContain( attack.animation, ); expect(["melee", "projectile", "area"], key + ":" + attack.id + ": delivery").toContain( attack.delivery, ); expect(attack.cooldownMs, key + ":" + attack.id + ": cooldown").toBeGreaterThan(0); expect(attack.range, key + ":" + attack.id + ": range").toBeGreaterThanOrEqual(0); } if (entity.kind === "boss" && !basicOnlyBossExceptions.has(key)) { expect( attacks.some((attack) => attack.id !== "basic-attack"), key + ": boss mechanic", ).toBe(true); } const model = entity.visual.model; if (model && model.animationMode !== "procedural") { const clips = await readAnimations(model.url); const phase = getMobAnimationPhase(key); expect(clips.length, key + ": native clips").toBeGreaterThan(0); for (const [state, moving] of [["idle", false], ["moving", true]] as const) { const resolution = resolveMobAnimationClip(clips, state); expect(resolution, key + ": " + state + " resolution").not.toBeNull(); if (!resolution?.exact) { const fallback = createMobAnimationTransform(); sampleMobAnimation(entity.visual.archetype, moving, 0.37, phase, fallback); expect( Object.values(fallback).every(Number.isFinite), key + ": procedural " + state + " fallback", ).toBe(true); } } const requestedCombatKinds = new Set<"attack" | "cast" | "wound">([ ...attacks.map((attack) => attack.animation), "wound", ]); for (const kind of requestedCombatKinds) { if (resolveMobCombatAnimationClip(clips, kind, 0)) continue; const fallback = createMobAnimationTransform(); sampleMobCombatFallback(kind === "wound" ? "wound" : "attacking", 0.37, fallback); expect( Object.values(fallback).every(Number.isFinite), key + ": procedural " + kind + " fallback", ).toBe(true); } const death = resolveMobAnimationClip(clips, "dead"); expect(death, key + ": death resolution").not.toBeNull(); if (!death?.exact) { const fallback = createMobAnimationTransform(); sampleMobCombatFallback("dead", 0.72, fallback); expect( Object.values(fallback).every(Number.isFinite), key + ": procedural death fallback", ).toBe(true); } } else { const phase = getMobAnimationPhase(key); for (const moving of [false, true]) { const locomotion = createMobAnimationTransform(); sampleMobAnimation(entity.visual.archetype, moving, 0.37, phase, locomotion); expect( Object.values(locomotion).every(Number.isFinite), key + ": procedural locomotion", ).toBe(true); } for (const state of ["attacking", "wound", "dead"] as const) { const combat = createMobAnimationTransform(); sampleMobCombatFallback(state, 0.37, combat); expect( Object.values(combat).every(Number.isFinite), key + ": procedural " + state, ).toBe(true); } } } } }, 120_000); });