Files
healer-man/src/avatar/starterWeapons.test.ts
T
2026-08-30 14:16:56 -04:00

320 lines
13 KiB
TypeScript

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<Record<string, { readonly model: string }>>;
}
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<typeof starterWeaponModelRotation>[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<typeof starterWeaponModelRotation>[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<typeof avatarWeaponSocketRotation>[1],
stance: Parameters<typeof avatarWeaponSocketRotation>[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<string>();
const resolvedRightNames = new Set<string>();
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);
});
});