Release Healer Man 0.1.9

This commit is contained in:
phenom committed 2026-08-30 14:16:56 -04:00
1 parent c245a199b6
commit d060296ade
74 files changed
+5901 -334

No files matched your search

+127 -3
View File
@@ -1,18 +1,26 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Group, Object3D } from "three";
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 {
@@ -93,6 +101,89 @@ describe("starter weapon loadouts", () => {
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", () => {
@@ -123,6 +214,37 @@ describe("avatar hand-bone discovery", () => {
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);
@@ -143,7 +265,7 @@ describe("avatar hand-bone discovery", () => {
});
it("resolves both hands in every shipped race/gender character rig", () => {
const manifestPaths = ["wow335a-v1", "ascension-v1"].map((version) => path.join(
const manifestPaths = ["wow335a-v1", "ascension-v1", "runewaker-v1"].map((version) => path.join(
projectDirectory,
"public",
"assets",
@@ -154,7 +276,7 @@ describe("avatar hand-bone discovery", () => {
const entries = manifestPaths.flatMap((manifestPath) => (
Object.values((JSON.parse(readFileSync(manifestPath, "utf8")) as CharacterManifest).entries)
));
expect(entries).toHaveLength(45);
expect(entries).toHaveLength(50);
const resolvedLeftNames = new Set<string>();
const resolvedRightNames = new Set<string>();
const unresolved: string[] = [];
@@ -172,6 +294,8 @@ describe("avatar hand-bone discovery", () => {
// 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);
});