344 lines
14 KiB
TypeScript
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);
|
|
});
|