import { describe, expect, it } from "vitest"; import type { MobArchetype } from "./mobPopulation"; import { createMobAnimationTransform, getMobAnimationPhase, hashMobAnimationId, resolveMobAnimationClip, resolveMobCombatAnimationClip, resolveMobAnimationState, sampleMobCombatFallback, sampleMobAnimation, } from "./mobAnimation"; const ARCHETYPES: readonly MobArchetype[] = [ "humanoid", "raptor", "crocolisk", "ooze", "plant", "serpent", "turtle", "lizard", "murloc", "winged", ]; describe("mob procedural animation sampling", () => { it("creates an identity transform for caller-owned reuse", () => { expect(createMobAnimationTransform()).toEqual({ offsetY: 0, offsetZ: 0, rotationX: 0, rotationY: 0, rotationZ: 0, scaleX: 1, scaleY: 1, scaleZ: 1, }); }); it("keeps locomotion continuous while combat one-shots are event driven", () => { expect(resolveMobAnimationState({ dead: true, engaged: true, combatPhase: "attacking", moving: true, })).toBe("dead"); expect(resolveMobAnimationState({ dead: false, engaged: true, combatPhase: "attacking", moving: true, })).toBe("moving"); expect(resolveMobAnimationState({ dead: false, engaged: true, combatPhase: "attacking", moving: false, feared: true, })).toBe("moving"); expect(resolveMobAnimationState({ dead: false, engaged: true, combatPhase: "attacking", moving: false, feared: true, movementLocked: true, })).toBe("idle"); expect(resolveMobAnimationState({ dead: false, engaged: true, combatPhase: "chasing", moving: false, })).toBe("moving"); expect(resolveMobAnimationState({ dead: false, engaged: false, combatPhase: "idle", moving: false, })).toBe("idle"); }); it("selects attack and wound clips independently and rotates native variations", () => { const clips = [ { name: "Stand (ID 0 variation 0)" }, { name: "AttackUnarmed (ID 16 variation 0)" }, { name: "AttackUnarmed (ID 16 variation 1)" }, { name: "CombatWound (ID 9 variation 0)" }, ]; expect(resolveMobCombatAnimationClip(clips, "attack", 0)).toBe(clips[1]); expect(resolveMobCombatAnimationClip(clips, "attack", 1)).toBe(clips[2]); expect(resolveMobCombatAnimationClip(clips, "attack", 2)).toBe(clips[1]); expect(resolveMobCombatAnimationClip(clips, "wound", 1)).toBe(clips[3]); }); it("selects semantic RuneWaker combat clips when numeric WoW ids are absent", () => { const clips = [ { name: "Attack - 1h_attack01" }, { name: "Attack - unarmed_attack02" }, { name: "Cast - casting01" }, { name: "Wound - hurt" }, { name: "Death - death" }, ]; expect(resolveMobCombatAnimationClip(clips, "attack", 0)).toBe(clips[0]); expect(resolveMobCombatAnimationClip(clips, "attack", 1)).toBe(clips[1]); expect(resolveMobCombatAnimationClip(clips, "cast", 0)).toBe(clips[2]); expect(resolveMobCombatAnimationClip(clips, "wound", 0)).toBe(clips[3]); expect(resolveMobCombatAnimationClip(clips, "death", 0)).toBe(clips[4]); }); it("selects extracted Attack and Death clips before fallback clips", () => { const clips = [ { name: "Stand (ID 0 variation 0)" }, { name: "Walk (ID 4 variation 0)" }, { name: "AttackUnarmed (ID 16 variation 0)" }, { name: "Death (ID 1 variation 0)" }, ]; expect(resolveMobAnimationClip(clips, "attacking")).toMatchObject({ clip: clips[2], resolvedState: "attacking", exact: true, }); expect(resolveMobAnimationClip(clips, "dead")).toMatchObject({ clip: clips[3], resolvedState: "dead", exact: true, }); }); it("reports Stand as an inexact fallback when combat clips are absent", () => { const clips = [ { name: "Stand (ID 0 variation 0)" }, { name: "Walk (ID 4 variation 0)" }, ]; expect(resolveMobAnimationClip(clips, "attacking")).toMatchObject({ clip: clips[0], resolvedState: "idle", exact: false, }); expect(resolveMobAnimationClip(clips, "dead")).toMatchObject({ clip: clips[0], resolvedState: "idle", exact: false, }); }); it("clamps procedural death once and keeps attack fallback bounded", () => { const transform = createMobAnimationTransform(); sampleMobCombatFallback("dead", 0.72, transform); const terminal = { ...transform }; sampleMobCombatFallback("dead", 30, transform); expect(transform).toEqual(terminal); expect(transform.rotationZ).toBeLessThan(-1.3); for (const elapsed of [0, 0.1, 1, 10, Number.NaN]) { sampleMobCombatFallback("attacking", elapsed, transform); expect(Object.values(transform).every(Number.isFinite)).toBe(true); expect(Math.abs(transform.offsetZ)).toBeLessThanOrEqual(0.18); expect(Math.abs(transform.rotationX)).toBeLessThanOrEqual(0.18); } sampleMobCombatFallback("wound", 0.18, transform); expect(transform.offsetZ).toBeLessThan(-0.1); expect(transform.rotationX).toBeGreaterThan(0.1); sampleMobCombatFallback("wound", 0.36, transform); expect(transform.offsetZ).toBeCloseTo(0); expect(Object.values(transform).every(Number.isFinite)).toBe(true); }); it("returns and overwrites the same transform object without drift", () => { const transform = createMobAnimationTransform(); const returned = sampleMobAnimation("raptor", true, 1.25, 0.7, transform); const first = { ...transform }; transform.offsetY = 999; transform.offsetZ = 999; transform.rotationX = 999; transform.rotationY = 999; transform.rotationZ = 999; transform.scaleX = 999; transform.scaleY = 999; transform.scaleZ = 999; sampleMobAnimation("raptor", true, 1.25, 0.7, transform); expect(returned).toBe(transform); expect(transform).toEqual(first); }); it("derives stable, bounded phases from instance ids", () => { const first = getMobAnimationPhase("entrance-ravager"); const repeated = getMobAnimationPhase("entrance-ravager"); const other = getMobAnimationPhase("entrance-guardian"); expect(hashMobAnimationId("entrance-ravager")).toBe(hashMobAnimationId("entrance-ravager")); expect(first).toBe(repeated); expect(first).toBeGreaterThanOrEqual(0); expect(first).toBeLessThan(Math.PI * 2); expect(other).not.toBe(first); }); it("staggered phases produce different poses at the same elapsed time", () => { const first = createMobAnimationTransform(); const second = createMobAnimationTransform(); sampleMobAnimation("humanoid", true, 0.5, getMobAnimationPhase("pack-a"), first); sampleMobAnimation("humanoid", true, 0.5, getMobAnimationPhase("pack-b"), second); expect(second).not.toEqual(first); }); it("gives ooze an inverse squash-and-stretch pose", () => { const transform = createMobAnimationTransform(); sampleMobAnimation("ooze", true, 0.125, 0.3, transform); expect(transform.scaleX).toBeCloseTo(transform.scaleZ); expect(Math.sign(transform.scaleX - 1)).toBe(-Math.sign(transform.scaleY - 1)); expect(Math.abs(transform.scaleX - 1)).toBeGreaterThan(0.02); }); it("keeps all shipped archetype poses finite and conservatively bounded", () => { const transform = createMobAnimationTransform(); for (const archetype of ARCHETYPES) { for (const moving of [false, true]) { for (const elapsed of [0, 0.1, 1, 20, 3_600]) { sampleMobAnimation(archetype, moving, elapsed, 1.234, transform); const values = Object.values(transform); expect(values.every(Number.isFinite)).toBe(true); expect(transform.offsetY).toBeGreaterThanOrEqual(0); expect(transform.offsetY).toBeLessThanOrEqual(0.2); expect(Math.abs(transform.rotationX)).toBeLessThanOrEqual(0.05); expect(Math.abs(transform.rotationY)).toBeLessThanOrEqual(0.09); expect(Math.abs(transform.rotationZ)).toBeLessThanOrEqual(0.07); expect(transform.scaleX).toBeGreaterThan(0.9); expect(transform.scaleX).toBeLessThan(1.1); expect(transform.scaleY).toBeGreaterThan(0.9); expect(transform.scaleY).toBeLessThan(1.1); expect(transform.scaleZ).toBeGreaterThan(0.9); expect(transform.scaleZ).toBeLessThan(1.1); } } } }); });