Files
healer-man/src/game/wowDungeonContent.test.ts
T
2026-09-09 11:21:33 -04:00

200 lines
8.4 KiB
TypeScript

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<string, Promise<readonly { readonly name: string }[]>>();
async function readAnimations(url: string): Promise<readonly { readonly name: string }[]> {
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);
});