import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { Euler, Quaternion, Vector3 } from "three"; import { CLASSES } from "../app/characterCatalog"; import type { InventoryItem } from "../game/lootTypes"; import { resolveRunewakerEquipment } from "./runewakerEquipment"; import { equipmentWeaponModelRotation, equipmentWeaponSocketRotation } from "./equipmentAttachments"; import { avatarWeaponSocketRotation } from "./starterWeapons"; import type { AvatarManifest } from "./characterManifest"; describe("original RoM equipment", () => { it("aligns the original bow to the basic body's grip without reversing its native facing", () => { const native = (JSON.parse(readFileSync("public/assets/characters/runewaker-v1/manifest.json", "utf8")) as AvatarManifest).entries["rom-human:male"]; const basic = { ...native, rendererStrategy: undefined }; const bow = resolveRunewakerEquipment("rom-scout", []).models[0]; expect(equipmentWeaponModelRotation(native, bow)).toEqual([0, 0, 0]); const axis = new Vector3(0, 1, 0) .applyEuler(new Euler(...equipmentWeaponModelRotation(basic, bow))) .applyEuler(new Euler(...equipmentWeaponSocketRotation(basic, bow))); expect(axis.distanceTo(new Vector3(1, 0, 0))).toBeLessThan(1e-6); const stringSide = new Vector3(0, 0, -1).applyEuler(new Euler(...equipmentWeaponModelRotation(basic, bow))); expect(stringSide.y).toBeCloseTo(1); for (const model of resolveRunewakerEquipment("rom-knight", []).models) { expect(equipmentWeaponModelRotation(native, model)).toEqual([0, 0, 0]); expect(equipmentWeaponModelRotation(basic, model)).toEqual([0, 0, 0]); } }); it("converts native props back to the basic body's socket frame when its pack is absent", () => { const native = (JSON.parse(readFileSync("public/assets/characters/runewaker-v1/manifest.json", "utf8")) as AvatarManifest).entries["rom-human:male"]; const basic = { ...native, rendererStrategy: undefined }; for (const classId of ["rom-knight", "rom-mage", "rom-scout", "rom-rogue"] as const) { for (const model of resolveRunewakerEquipment(classId, []).models) { expect(equipmentWeaponSocketRotation(native, model)).toEqual([0, 0, 0]); const nativeRotation = new Quaternion().setFromEuler(new Euler(...avatarWeaponSocketRotation(native, model.fallbackKind!, model.weaponStance!))); const fallbackRotation = new Quaternion().setFromEuler(new Euler(...equipmentWeaponSocketRotation(basic, model))); expect(nativeRotation.multiply(fallbackRotation).angleTo(new Quaternion())).toBeLessThan(1e-6); } } }); it("equips every RoM class with source-client props in its native socket frame", () => { for (const characterClass of CLASSES.filter((entry) => entry.id.startsWith("rom-"))) { const resolved = resolveRunewakerEquipment(characterClass.id, []); expect(resolved.models.length).toBeGreaterThan(0); expect(resolved.bodyLayers).toEqual([]); for (const model of resolved.models) { expect(model.sourceFrame).toBe("runewaker"); expect(model.url).toContain("/equipment/runewaker-v1/"); expect(existsSync(resolve("public", model.url.slice(1)))).toBe(true); } } }); it("shows the equipped weapon type and avoids applying WoW armor to native RoM UVs", () => { const resolved = resolveRunewakerEquipment("rom-priest", [ { itemId: 123, inventorySlot: "main-hand", weaponType: "mace" } as InventoryItem, { itemId: 6465, inventorySlot: "chest", armorType: "cloth" } as InventoryItem, ]); expect(resolved.models).toEqual([expect.objectContaining({ itemId: 123, url: expect.stringContaining("starter-mace.glb"), attachment: "handRight", })]); expect(resolved.bodyLayers).toEqual([]); }); it("preserves original ROS provenance for all six packaged weapon kinds", () => { const imported = JSON.parse(readFileSync("public/assets/equipment/runewaker-v1/import-manifest.json", "utf8")); expect(imported.assets).toHaveLength(6); expect(imported.proceduralFallbacks).toEqual([]); for (const asset of imported.assets) { expect(asset.sourceModel).toMatch(/^model\/item\/weapon\/.+\.ros$/); expect(asset.sourceModelSha256).toMatch(/^[a-f0-9]{64}$/); } }); });