Files
healer-man/src/game/runewakerDungeonAnimation.test.ts
T
2026-08-18 12:06:04 -04:00

344 lines
14 KiB
TypeScript

import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
createMobAnimationTransform,
getMobAnimationPhase,
resolveMobCombatAnimationClip,
sampleMobAnimation,
sampleMobCombatFallback,
} from "./mobAnimation";
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const publicRoot = path.join(projectRoot, "public");
const populationRecipeRoot = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes");
interface GlbAnimation {
readonly name?: string;
readonly channels?: readonly unknown[];
}
interface GlbJson {
readonly animations?: readonly GlbAnimation[];
readonly skins?: readonly unknown[];
readonly nodes?: readonly { readonly skin?: number }[];
readonly meshes?: readonly {
readonly primitives?: readonly {
readonly attributes?: Readonly<Record<string, number>>;
}[];
}[];
}
interface PopulationActorRecipe {
readonly id: string;
readonly sourceModel: string;
readonly animationException?: {
readonly kind: "pose-only";
readonly reason: string;
};
}
interface PopulationRecipe {
readonly dungeonId: string;
readonly actors: readonly PopulationActorRecipe[];
readonly templates: readonly {
readonly classification: string;
readonly actor?: string;
}[];
readonly files: {
readonly actorManifest: string;
};
}
interface ActorManifestEntry {
readonly id: string;
readonly baseActorId?: string;
readonly sourceImageId?: number;
readonly sourceModel: string;
readonly url: string;
readonly animations?: readonly string[];
}
interface ActorManifest {
readonly status: string;
readonly assets: readonly ActorManifestEntry[];
readonly proceduralFallbacks: readonly {
readonly id: string;
readonly sourceModel: string;
readonly reason: string;
}[];
}
async function readGlbJson(url: string): Promise<GlbJson> {
const file = path.join(publicRoot, url.replace(/^\/+/, ""));
const glb = await readFile(file);
expect(glb.toString("ascii", 0, 4), url).toBe("glTF");
expect(glb.readUInt32LE(4), url).toBe(2);
const jsonLength = glb.readUInt32LE(12);
expect(glb.readUInt32LE(16), url).toBe(0x4e4f534a);
return JSON.parse(glb.toString("utf8", 20, 20 + jsonLength)) as GlbJson;
}
async function readGlbAnimationNames(url: string): Promise<readonly string[]> {
const json = await readGlbJson(url);
return (json.animations ?? []).map((animation, index) => animation.name ?? "animation-" + index);
}
describe("RuneWaker dungeon creature animation coverage", () => {
it("gives every creature a working native or procedural state path", async () => {
const dungeons = DUNGEON_DEFINITIONS.filter((definition) => definition.expansion === "runewaker");
expect(dungeons).toHaveLength(30);
for (const dungeon of dungeons) {
if (dungeon.staticSpawns.length === 0) {
expect(Object.keys(dungeon.entities), dungeon.id + " empty population").toHaveLength(0);
}
for (const spawn of dungeon.staticSpawns) {
expect(dungeon.entities[spawn.entityId], dungeon.id + ":" + spawn.id).toBeDefined();
}
for (const entity of Object.values(dungeon.entities)) {
const combat = entity.combat;
expect(combat, dungeon.id + ":" + entity.id + " combat definition").toBeDefined();
if (!combat) throw new Error(dungeon.id + ":" + entity.id + " has no combat definition");
expect(combat.attacks.length, dungeon.id + ":" + entity.id).toBeGreaterThan(0);
for (const attack of combat.attacks) {
expect(["attack", "cast"], dungeon.id + ":" + entity.id + ":" + attack.id).toContain(attack.animation);
}
const model = entity.visual.model;
if (model) {
const clips = await readGlbAnimationNames(model.url);
if (model.animationMode === "procedural") {
expect(clips.length, dungeon.id + ":" + entity.id).toBe(0);
} else {
expect(clips.length, dungeon.id + ":" + entity.id + " native clips").toBeGreaterThan(0);
expect(
resolveMobCombatAnimationClip(clips.map((name) => ({ name })), "attack", 0),
dungeon.id + ":" + entity.id + " native attack clip",
).not.toBeNull();
}
}
if (!model || model.animationMode === "procedural") {
const phase = getMobAnimationPhase(dungeon.id + ":" + entity.id);
const idle = createMobAnimationTransform();
const moving = createMobAnimationTransform();
const attack = createMobAnimationTransform();
const wound = createMobAnimationTransform();
const dead = createMobAnimationTransform();
sampleMobAnimation(entity.visual.archetype, false, 0.37, phase, idle);
sampleMobAnimation(entity.visual.archetype, true, 0.37, phase, moving);
sampleMobCombatFallback("attacking", 0.25, attack);
sampleMobCombatFallback("wound", 0.18, wound);
sampleMobCombatFallback("dead", 0.72, dead);
for (const [state, transform] of Object.entries({ idle, moving, attack, wound, dead })) {
expect(
Object.values(transform).every(Number.isFinite),
dungeon.id + ":" + entity.id + ":" + state,
).toBe(true);
}
expect(moving, dungeon.id + ":" + entity.id + ":moving").not.toEqual(idle);
expect(attack.offsetZ, dungeon.id + ":" + entity.id + ":attack").toBeGreaterThan(0);
expect(wound.offsetZ, dungeon.id + ":" + entity.id + ":wound").toBeLessThan(-0.1);
expect(dead.rotationZ, dungeon.id + ":" + entity.id + ":death").toBeLessThan(-1.3);
}
}
}
}, 120_000);
it("ships every configured directly-convertible actor as native except documented pose/proxy cases", async () => {
const recipes = await Promise.all(
(await readdir(populationRecipeRoot))
.filter((file) => file.endsWith("-population.json"))
.sort()
.map(async (file) => JSON.parse(
await readFile(path.join(populationRecipeRoot, file), "utf8"),
) as PopulationRecipe),
);
const dungeons = new Map(
DUNGEON_DEFINITIONS
.filter((definition) => definition.expansion === "runewaker")
.map((definition) => [definition.id, definition]),
);
const counts = {
configured: 0,
native: 0,
poseOnly: 0,
dynamicProxy: 0,
};
for (const recipe of recipes) {
const dungeon = dungeons.get(recipe.dungeonId);
expect(dungeon, recipe.dungeonId + " runtime definition").toBeDefined();
if (!dungeon) throw new Error(recipe.dungeonId + " is not registered.");
const manifest = JSON.parse(
await readFile(path.resolve(projectRoot, recipe.files.actorManifest), "utf8"),
) as ActorManifest;
expect(manifest.status, recipe.dungeonId + " manifest status").toBe("green");
const actors = new Map(recipe.actors.map((actor) => [actor.id, actor]));
const assetGroups = new Map<string, ActorManifestEntry[]>();
for (const asset of manifest.assets) {
const baseActorId = asset.baseActorId ?? asset.id;
const group = assetGroups.get(baseActorId) ?? [];
group.push(asset);
assetGroups.set(baseActorId, group);
}
const fallbacks = new Map(
manifest.proceduralFallbacks.map((fallback) => [fallback.id, fallback]),
);
const configuredIds = new Set(
recipe.templates
.filter((template) => ["combat", "boss"].includes(template.classification))
.map((template) => template.actor)
.filter((id): id is string => typeof id === "string"),
);
counts.configured += configuredIds.size;
for (const actorId of configuredIds) {
const actor = actors.get(actorId);
expect(actor, recipe.dungeonId + ":" + actorId + " recipe actor").toBeDefined();
if (!actor) continue;
const asset = assetGroups.get(actorId)?.[0];
const fallback = fallbacks.get(actorId);
expect(
Number(Boolean(asset)) + Number(Boolean(fallback)),
recipe.dungeonId + ":" + actorId + " package accounting",
).toBe(1);
if (fallback) {
counts.dynamicProxy += 1;
expect(fallback.sourceModel, recipe.dungeonId + ":" + actorId + " proxy source").toBe(
actor.sourceModel,
);
expect(
fallback.reason,
recipe.dungeonId + ":" + actorId + " proxy documentation",
).toMatch(/runtime\/dynamic display container.*no static triangle/i);
continue;
}
if (!asset) continue;
expect(asset.sourceModel, recipe.dungeonId + ":" + actorId + " asset source").toBe(
actor.sourceModel,
);
const json = await readGlbJson(asset.url);
const clips = json.animations ?? [];
const clipNames = clips.map((clip, index) => clip.name ?? "animation-" + index);
expect(
[...(asset.animations ?? [])].sort(),
recipe.dungeonId + ":" + actorId + " manifest clips",
).toEqual([...clipNames].sort());
const runtimeEntities = Object.values(dungeon.entities).filter(
(entity) => entity.visual.model?.url === asset.url,
);
expect(
runtimeEntities.length,
recipe.dungeonId + ":" + actorId + " runtime users",
).toBeGreaterThan(0);
if (actor.animationException?.kind === "pose-only") {
counts.poseOnly += 1;
expect(actor.animationException.reason.trim().length).toBeGreaterThan(0);
expect(clips, recipe.dungeonId + ":" + actorId + " pose-only clips").toHaveLength(0);
for (const entity of runtimeEntities) {
expect(entity.visual.model?.animationMode, entity.id + " pose mode").toBe("procedural");
}
continue;
}
counts.native += 1;
expect(clips.length, recipe.dungeonId + ":" + actorId + " native clips").toBeGreaterThan(0);
expect(json.skins?.length, recipe.dungeonId + ":" + actorId + " skins").toBeGreaterThan(0);
expect(
json.nodes?.some((node) => node.skin !== undefined),
recipe.dungeonId + ":" + actorId + " skinned node",
).toBe(true);
expect(
json.meshes?.some((mesh) => mesh.primitives?.some((primitive) => (
primitive.attributes?.JOINTS_0 !== undefined
&& primitive.attributes?.WEIGHTS_0 !== undefined
))),
recipe.dungeonId + ":" + actorId + " skin attributes",
).toBe(true);
expect(
clips.every((clip) => (clip.channels?.length ?? 0) > 0),
recipe.dungeonId + ":" + actorId + " animated channels",
).toBe(true);
for (const semantic of ["Attack", "Wound", "Death"]) {
expect(
clipNames.some((name) => name.startsWith(semantic + " - ")),
recipe.dungeonId + ":" + actorId + " " + semantic,
).toBe(true);
}
expect(
clipNames.some((name) => /^(Stand|Idle) - /.test(name)),
recipe.dungeonId + ":" + actorId + " stand/idle",
).toBe(true);
for (const entity of runtimeEntities) {
expect(entity.visual.model?.animationMode, entity.id + " native mode").toBe("native");
}
}
}
expect(counts).toEqual({
configured: 268,
native: 267,
poseOnly: 1,
dynamicProxy: 0,
});
const runtimeCounts = { native: 0, poseOnly: 0, proxy: 0 };
const runtimeProxyIds: string[] = [];
for (const dungeon of dungeons.values()) {
for (const entity of Object.values(dungeon.entities)) {
if (!entity.visual.model) {
runtimeCounts.proxy += 1;
runtimeProxyIds.push(dungeon.id + ":" + entity.id);
}
else if (entity.visual.model.animationMode === "native") runtimeCounts.native += 1;
else runtimeCounts.poseOnly += 1;
}
}
expect(runtimeCounts, "runtime proxies: " + runtimeProxyIds.join(", ")).toEqual({
native: 435,
poseOnly: 1,
proxy: 0,
});
}, 60_000);
it("packages every Pasper actor with a skin and original combat/locomotion clips", async () => {
const pasper = DUNGEON_DEFINITIONS.find((definition) => definition.id === "paspers-shrine");
expect(pasper).toBeDefined();
if (!pasper) throw new Error("Pasper's Shrine is not registered.");
const modelUrls = new Set<string>();
for (const entity of Object.values(pasper.entities)) {
const model = entity.visual.model;
expect(model, entity.id + " model").toBeDefined();
expect(model?.animationMode, entity.id + " animation mode").toBe("native");
if (model) modelUrls.add(model.url);
}
expect(modelUrls.size).toBe(9);
for (const url of modelUrls) {
const json = await readGlbJson(url);
const names = (json.animations ?? []).map((clip) => clip.name ?? "");
expect(json.skins?.length, url + " skins").toBeGreaterThan(0);
expect(json.nodes?.some((node) => node.skin !== undefined), url + " skinned node").toBe(true);
expect(
json.meshes?.some((mesh) => mesh.primitives?.some((primitive) => (
primitive.attributes?.JOINTS_0 !== undefined
&& primitive.attributes?.WEIGHTS_0 !== undefined
))),
url + " skin attributes",
).toBe(true);
for (const semantic of ["Stand", "Walk", "Run", "Attack", "Cast", "Wound", "Death"]) {
const clip = (json.animations ?? []).find((animation) => animation.name?.startsWith(semantic + " - "));
expect(clip, url + " " + semantic).toBeDefined();
expect(clip?.channels?.length, url + " " + semantic + " animated channels").toBeGreaterThan(0);
}
}
}, 30_000);
});