320 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|