import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Euler, Group, Object3D, Vector3 } from "three"; import { describe, expect, it } from "vitest"; import { CLASSES, type ClassId } from "../app/characterCatalog"; import { STARTER_WEAPON_LOADOUTS, approximateStarterWeaponGripRotation, avatarWeaponSocketRotation, characterWeaponStance, estimateAvatarWeaponScale, findAvatarHandBone, findAvatarHandBones, findAvatarShieldBone, sampleStarterWeaponStrike, starterWeaponItemsForAvailableHands, starterWeaponLoadout, starterWeaponModelRotation, starterWeaponStance, } from "./starterWeapons"; import type { AvatarManifestEntry } from "./characterManifest"; import { resolveAvatarAttachmentPoint } from "./attachmentPoints"; import { equipmentAttachmentHand } from "./equipmentAttachments"; interface RawGlbNode { readonly children?: readonly number[]; readonly name?: string; } interface RawGlbDocument { readonly nodes?: readonly RawGlbNode[]; readonly scenes?: readonly { readonly nodes?: readonly number[] }[]; readonly scene?: number; } interface CharacterManifest { readonly entries: Readonly>; } const projectDirectory = fileURLToPath(new URL("../..", import.meta.url)); function loadGlbObjectTree(modelUrl: string): Object3D { const filePath = path.join(projectDirectory, "public", modelUrl.replace(/^\//, "")); const glb = readFileSync(filePath); expect(glb.readUInt32LE(0)).toBe(0x46546c67); const jsonLength = glb.readUInt32LE(12); const document = JSON.parse( glb.subarray(20, 20 + jsonLength).toString("utf8").trimEnd(), ) as RawGlbDocument; const sourceNodes = document.nodes ?? []; const objects = sourceNodes.map((node) => { const object = new Group(); object.name = node.name ?? ""; return object; }); sourceNodes.forEach((node, index) => { node.children?.forEach((child) => objects[index]?.add(objects[child])); }); const root = new Group(); const scene = document.scenes?.[document.scene ?? 0]; scene?.nodes?.forEach((node) => root.add(objects[node])); return root; } function fingerPivot(name: string): Object3D { const node = new Group(); node.name = name; return node; } describe("starter weapon loadouts", () => { it("provides visible, class-appropriate gear for every playable class", () => { const classIds = CLASSES.map((entry) => entry.id); expect(Object.keys(STARTER_WEAPON_LOADOUTS).sort()).toEqual([...classIds].sort()); for (const classId of classIds) { const loadout = starterWeaponLoadout(classId); expect(loadout.classId).toBe(classId); expect(loadout.items.length).toBeGreaterThan(0); expect(new Set(loadout.items.map((equipped) => equipped.id)).size).toBe(loadout.items.length); } expect(starterWeaponLoadout("warrior").items.map((equipped) => equipped.kind)).toEqual(["sword", "shield"]); expect(starterWeaponLoadout("paladin").items.map((equipped) => equipped.kind)).toEqual(["mace", "shield"]); expect(starterWeaponLoadout("hunter").items.map((equipped) => equipped.kind)).toEqual(["bow"]); expect(starterWeaponLoadout("rogue").items.map((equipped) => equipped.kind)).toEqual(["dagger", "dagger"]); expect(starterWeaponLoadout("priest").items.map((equipped) => equipped.kind)).toEqual(["staff"]); expect(starterWeaponLoadout("death-knight").items.map((equipped) => equipped.kind)).toEqual(["sword"]); expect(starterWeaponLoadout("shaman").items.map((equipped) => equipped.kind)).toEqual(["mace", "shield"]); for (const classId of ["mage", "warlock", "druid"] as const satisfies readonly ClassId[]) { expect(starterWeaponLoadout(classId).items.map((equipped) => equipped.kind)).toEqual(["staff"]); } }); it("keeps starter gear only in hands without an authentic item model", () => { expect(starterWeaponItemsForAvailableHands("warrior", ["right"]).map((item) => item.kind)) .toEqual(["shield"]); expect(starterWeaponItemsForAvailableHands("warrior", ["left", "right"])).toEqual([]); expect(equipmentAttachmentHand("handRight")).toBe("right"); expect(equipmentAttachmentHand("handLeft")).toBe("left"); expect(equipmentAttachmentHand("shield")).toBe("left"); expect(equipmentAttachmentHand("shoulderLeft")).toBeNull(); }); it("selects the native ready stance that matches each loadout", () => { expect(starterWeaponStance("warrior")).toBe("one-handed"); expect(starterWeaponStance("rogue")).toBe("one-handed"); expect(starterWeaponStance("hunter")).toBe("bow"); expect(starterWeaponStance("priest")).toBe("two-handed-left"); expect(starterWeaponStance("death-knight")).toBe("two-handed"); expect(starterWeaponStance("rom-priest")).toBe("two-handed-left"); }); it("lets visible equipped weapons override the class starter stance", () => { expect(characterWeaponStance("priest", [ { inventorySlot: "one-hand", itemId: 6472, weaponType: "dagger" }, ], [6472])).toBe("one-handed"); expect(characterWeaponStance("warrior", [ { inventorySlot: "two-hand", itemId: 6631, weaponType: "staff" }, ], [6631])).toBe("two-handed-left"); expect(characterWeaponStance("warrior", [ { inventorySlot: "ranged", itemId: 6469, weaponType: "bow" }, ], [6469])).toBe("bow"); expect(characterWeaponStance("priest", [ { inventorySlot: "one-hand", itemId: 9999, weaponType: "dagger" }, ], [])).toBe("two-handed-left"); }); it("normalizes procedural equipment to the native WoW item axes", () => { const rotated = ( kind: Parameters[0], direction: readonly [number, number, number], ) => new Vector3(...direction).applyEuler(new Euler(...starterWeaponModelRotation(kind))); expect(rotated("sword", [0, -1, 0]).distanceTo(new Vector3(1, 0, 0))) .toBeLessThan(1e-6); expect(rotated("dagger", [0, -1, 0]).distanceTo(new Vector3(1, 0, 0))) .toBeLessThan(1e-6); expect(rotated("mace", [0, -1, 0]).distanceTo(new Vector3(1, 0, 0))) .toBeLessThan(1e-6); expect(rotated("staff", [0, 1, 0]).distanceTo(new Vector3(1, 0, 0))) .toBeLessThan(1e-6); expect(rotated("bow", [0, 1, 0]).distanceTo(new Vector3(1, 0, 0))) .toBeLessThan(1e-6); expect(rotated("bow", [0, 0, -1]).distanceTo(new Vector3(0, 1, 0))) .toBeLessThan(1e-6); expect(starterWeaponModelRotation("shield")).toEqual([0, 0, 0]); const fallbackDirection = ( kind: Parameters[0], direction: readonly [number, number, number], ) => rotated(kind, direction).applyEuler( new Euler(...approximateStarterWeaponGripRotation(kind)), ); expect(fallbackDirection("sword", [0, -1, 0]).distanceTo(new Vector3(0, -1, 0))) .toBeLessThan(1e-6); expect(fallbackDirection("staff", [0, 1, 0]).distanceTo(new Vector3(0, 1, 0))) .toBeLessThan(1e-6); expect(fallbackDirection("bow", [0, 1, 0]).distanceTo(new Vector3(0, 1, 0))) .toBeLessThan(1e-6); }); it("adapts canonical weapons to RuneWaker prop-socket axes", () => { const entry = { rendererStrategy: { kind: "runewaker-modular" }, } as AvatarManifestEntry; const socketDirection = ( kind: Parameters[1], stance: Parameters[2], ) => new Vector3(1, 0, 0).applyEuler( new Euler(...avatarWeaponSocketRotation(entry, kind, stance)), ); expect(socketDirection("sword", "one-handed").distanceTo(new Vector3(0, 0, 1))) .toBeLessThan(1e-6); expect(socketDirection("bow", "bow").distanceTo(new Vector3(0, 0, 1))) .toBeLessThan(1e-6); expect(socketDirection("staff", "two-handed-left").distanceTo(new Vector3(0, 1, 0))) .toBeLessThan(1e-6); expect(socketDirection("sword", "two-handed").distanceTo(new Vector3(0, 1, 0))) .toBeLessThan(1e-6); expect(socketDirection("shield", "one-handed").distanceTo(new Vector3(1, 0, 0))) .toBeLessThan(1e-6); expect(avatarWeaponSocketRotation(undefined, "sword", "one-handed")) .toEqual([0, 0, 0]); }); }); describe("avatar hand-bone discovery", () => { it("uses the consensus finger parent instead of a numeric bone id", () => { const root = new Group(); const arbitraryRightHand = new Group(); arbitraryRightHand.name = "bone_9876"; const noisyParent = new Group(); noisyParent.name = "bone_noise"; root.add(arbitraryRightHand, noisyParent); arbitraryRightHand.add( fingerPivot("bone_IndexFingerR_p"), fingerPivot("bone_MiddleFingerR_p"), fingerPivot("bone_RingFingerR_p"), fingerPivot("bone_PinkyFingerR_p"), ); noisyParent.add(fingerPivot("bone_IndexFingerR_p")); expect(findAvatarHandBone(root, "right")).toBe(arbitraryRightHand); }); it("finds named-hand fallbacks and fails closed when no hand exists", () => { const root = new Group(); const leftHand = new Group(); leftHand.name = "rig_LeftHand"; root.add(leftHand); expect(findAvatarHandBone(root, "left")).toBe(leftHand); expect(findAvatarHandBone(root, "right")).toBeNull(); }); it("prioritizes explicit RuneWaker equipment locators over generic hand bones", () => { const root = new Group(); const genericLeft = new Group(); genericLeft.name = "Bip01 L Hand"; const genericRight = new Group(); genericRight.name = "Bip01 R Hand"; const leftPivot = new Group(); leftPivot.name = "p_left_hand"; const rightPivot = new Group(); rightPivot.name = "p_right_hand"; const shieldPivot = new Group(); shieldPivot.name = "p_hand_shield"; root.add(genericLeft, genericRight, leftPivot, rightPivot, shieldPivot); expect(findAvatarHandBone(root, "left")).toBe(leftPivot); expect(findAvatarHandBone(root, "right")).toBe(rightPivot); expect(findAvatarShieldBone(root)).toBe(shieldPivot); }); it("cancels inherited locator scale for portal children", () => { const root = new Group(); const scaledRig = new Group(); scaledRig.scale.setScalar(0.1); const pivot = new Group(); root.add(scaledRig); scaledRig.add(pivot); const transform = resolveAvatarAttachmentPoint(root, undefined, pivot); expect(transform?.scale.toArray()).toEqual([10, 10, 10]); }); it("estimates equipment scale in avatar-local space and ignores parent transforms", () => { const actor = new Group(); actor.position.set(20, 8, -12); actor.scale.setScalar(1.7); const root = new Group(); actor.add(root); const leftHand = new Group(); const rightHand = new Group(); leftHand.name = "any-left-hand-id"; rightHand.name = "any-right-hand-id"; leftHand.position.set(0, 1, -0.45); rightHand.position.set(0, 1, 0.45); leftHand.add(fingerPivot("bone_IndexFingerL_p"), fingerPivot("bone_MiddleFingerL_p")); rightHand.add(fingerPivot("bone_IndexFingerR_p"), fingerPivot("bone_MiddleFingerR_p")); root.add(leftHand, rightHand); expect(estimateAvatarWeaponScale(root)).toBeCloseTo(1.05); }); it("resolves both hands in every shipped race/gender character rig", () => { const manifestPaths = ["wow335a-v1", "ascension-v1", "runewaker-v1"].map((version) => path.join( projectDirectory, "public", "assets", "characters", version, "manifest.json", )); const entries = manifestPaths.flatMap((manifestPath) => ( Object.values((JSON.parse(readFileSync(manifestPath, "utf8")) as CharacterManifest).entries) )); expect(entries).toHaveLength(50); const resolvedLeftNames = new Set(); const resolvedRightNames = new Set(); const unresolved: string[] = []; for (const entry of entries) { const hands = findAvatarHandBones(loadGlbObjectTree(entry.model)); if (!hands.left) unresolved.push(`${entry.model} left hand`); if (!hands.right) unresolved.push(`${entry.model} right hand`); resolvedLeftNames.add(hands.left?.name ?? ""); resolvedRightNames.add(hands.right?.name ?? ""); } expect(unresolved).toEqual([]); // The exporter assigns different numeric ids per skeleton. A resolver that // accidentally hardcodes one id cannot satisfy this diversity check. expect(resolvedLeftNames.size).toBeGreaterThan(8); expect(resolvedRightNames.size).toBeGreaterThan(8); expect(resolvedLeftNames).toContain("p_left_hand"); expect(resolvedRightNames).toContain("p_right_hand"); }, 20_000); }); describe("starter weapon strike animation", () => { it("returns to its exact rest pose and never animates a shield", () => { expect(sampleStarterWeaponStrike("sword", "right", -1)).toEqual([0, 0, 0]); expect(sampleStarterWeaponStrike("sword", "right", 1)).toEqual([0, 0, 0]); expect(sampleStarterWeaponStrike("shield", "left", 0.25)).toEqual([0, 0, 0]); }); it("mirrors left and right strikes and gives bows a restrained recoil", () => { const right = sampleStarterWeaponStrike("dagger", "right", 0.24); const left = sampleStarterWeaponStrike("dagger", "left", 0.24); expect(right[2]).toBeGreaterThan(0); expect(left[2]).toBeCloseTo(-right[2]); const bow = sampleStarterWeaponStrike("bow", "left", 0.27); expect(Math.abs(bow[1])).toBeGreaterThan(0); expect(Math.abs(bow[1])).toBeLessThan(0.2); }); });