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>; }[]; }[]; } 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 { 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 { 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(); 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(); 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); });