forked from phenom/healer-man
200 lines
8.4 KiB
TypeScript
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);
|
|
});
|