import { describe, expect, it } from "vitest"; import { abilitiesForCharacter, abilityAtLevel, type AbilityAmountScaling } from "./abilityCatalog"; import { createConfiguredPartyMember } from "./partyStore"; import { resolvedEquipmentStats } from "./heirloomEquipment"; import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities"; import { applyRomNativeStatModifiers } from "./romNativeStats"; import { resolveRomNativeTrainingWeaponProfile } from "./romNativeDamage"; import { nativeDamageCasterAmount, nativeDisarmedMainHandAutoAttackProfile, nativePeriodicDamageCasterSnapshot, romNativeAmountBinding } from "./romNativeCombatAmount"; import { romNativePeriodicDamageBinding } from "./romNativePeriodicDamage"; import { deriveCharacterStats } from "./wotlkStats"; describe("native RoM unarmed Disarm profile", () => { it("uses the source Level*2 main-hand base and 30-tenths cadence for every active-five player and party NPC", () => { const level = 30; for (const [index, identity] of ROM_FIVE_IDENTITIES.entries()) { const playerGear = resolvedEquipmentStats({}, [], { ownerId: "native-disarm-player", ownerKind: "player", classId: identity.classId, secondaryClassId: identity.secondaryClassId, specializationId: null, role: identity.role, level, }); const player = deriveCharacterStats(identity.classId, level, playerGear, identity.raceId, {}, identity.secondaryClassId, "male", "rom").romNative; const member = createConfiguredPartyMember({ ...identity, statRuleset: "rom", specialization: "Primary", talentTreeId: "", }, level, index, "native-disarm-active-five"); const partyNpc = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId, {}, member.secondaryClassId, member.gender, member.statRuleset).romNative; for (const [owner, stats] of [["player", player], ["party-npc", partyNpc]] as const) { const profile = nativeDisarmedMainHandAutoAttackProfile(stats); expect(profile, `${owner}:${identity.classId}`).toBeDefined(); if (!profile || !stats) continue; const training = resolveRomNativeTrainingWeaponProfile(stats, { trainingGearProfileMatches: true }); expect(training.status, `${owner}:${identity.classId}`).toBe("supported"); if (training.status !== "supported") continue; expect(profile).toMatchObject({ slot: "main-hand", sourceDamageChannel: "Main_DMG", attackSpeed: 30, intervalMs: 3_000, sourceItemIds: [], sourceSemantics: "runewaker-roleattr-unarmed-main-hand", unmodeledRateChannels: [ "Main_DMG_Per", "DMG_AllWeapon", "DMG_Unarmed", "AtkSpeed", "Main_ATKSpeed_Per", "AttackSpeed_AllWeapon", "AttackSpeed_Unarmed", ], }); expect(profile.damage).toBeCloseTo(Math.max(1, (training.value.residualFlatDamage + level * 2) * stats.rateFactors.physicalDamage)); expect(profile.physicalAttack).toBe(stats.physicalAttack); } } }); it("retains the native nonweapon DMG remainder and tracked DMG_Per factor", () => { const identity = ROM_FIVE_IDENTITIES.find((candidate) => candidate.classId === "rom-knight")!; const level = 30; const gear = resolvedEquipmentStats({}, [], { ownerId: "native-disarm-rate", ownerKind: "player", classId: identity.classId, secondaryClassId: identity.secondaryClassId, specializationId: null, role: identity.role, level, }); const native = deriveCharacterStats(identity.classId, level, gear, identity.raceId, {}, identity.secondaryClassId, "male", "rom").romNative!; const modified = applyRomNativeStatModifiers(native, { channels: { physicalDamage: 100 }, rates: { physicalDamage: 1.5 }, }); const training = resolveRomNativeTrainingWeaponProfile(modified, { trainingGearProfileMatches: true }); const profile = nativeDisarmedMainHandAutoAttackProfile(modified); expect(training.status).toBe("supported"); expect(profile).toBeDefined(); if (training.status !== "supported" || !profile) return; expect(training.value.residualFlatDamage).toBeGreaterThan(0); expect(profile.damage).toBeCloseTo(Math.max(1, (training.value.residualFlatDamage + level * 2) * modified.rateFactors.physicalDamage)); expect(profile.damage).toBeGreaterThan(nativeDisarmedMainHandAutoAttackProfile(native)!.damage); }); it("fails closed when native training gear provenance is not verified", () => { const identity = ROM_FIVE_IDENTITIES.find((candidate) => candidate.classId === "rom-knight")!; const gear = resolvedEquipmentStats({}, [], { ownerId: "native-disarm-custom", ownerKind: "player", classId: identity.classId, secondaryClassId: identity.secondaryClassId, specializationId: null, role: identity.role, level: 30, }); const customGear = { ...gear, romNative: { ...gear.romNative, physicalDamage: (gear.romNative?.physicalDamage ?? 0) + 1 }, }; const custom = deriveCharacterStats(identity.classId, 30, customGear, identity.raceId, {}, identity.secondaryClassId, "male", "rom").romNative; expect(nativeDisarmedMainHandAutoAttackProfile(undefined)).toBeUndefined(); expect(nativeDisarmedMainHandAutoAttackProfile(custom)).toBeUndefined(); }); it("substitutes source unarmed inputs only in native ability branches that read Main_DMG/Main_ATKSpeed", () => { const basicAttacks = ["Holy Strike", "Rising Tide", "Shadowstab", "Shot", "Flame"]; let mainHandConsumers = 0; let unchangedRangedConsumers = 0; const mainHandConsumerClasses: string[] = []; for (const [index, identity] of ROM_FIVE_IDENTITIES.entries()) { const level = 30; const gear = resolvedEquipmentStats({}, [], { ownerId: "native-disarm-direct", ownerKind: "player", classId: identity.classId, secondaryClassId: identity.secondaryClassId, specializationId: null, role: identity.role, level }); const stats = deriveCharacterStats(identity.classId, level, gear, identity.raceId, {}, identity.secondaryClassId, "male", "rom").romNative!; const ability = abilityAtLevel(abilitiesForCharacter(identity.classId, identity.secondaryClassId) .find((candidate) => candidate.name === basicAttacks[index])!, level); const effect = ability.effects.find((candidate) => candidate.kind === "damage"); expect(effect?.kind, identity.classId).toBe("damage"); if (effect?.kind !== "damage" || !effect.romNativeSource) continue; const binding = romNativeAmountBinding(effect.romNativeSource); const profile = nativeDisarmedMainHandAutoAttackProfile(stats); expect(binding, identity.classId).not.toBeNull(); expect(profile, identity.classId).toBeDefined(); if (!binding || !profile) continue; const consumesMainHand = binding.component.calculationType === 0 || binding.component.calculationType === 9 || (binding.component.fixedValue !== 0 && binding.component.fixedType === 2); const trainingAmount = nativeDamageCasterAmount(effect, level, stats); const unarmedAmount = nativeDamageCasterAmount(effect, level, stats, undefined, profile); expect(trainingAmount, identity.classId).not.toBeNull(); expect(unarmedAmount, identity.classId).not.toBeNull(); if (consumesMainHand) { mainHandConsumers += 1; mainHandConsumerClasses.push(identity.classId); expect(unarmedAmount, identity.classId).not.toBe(trainingAmount); expect(nativeDamageCasterAmount(effect, level, stats, undefined, { ...profile, damage: profile.damage + 1 }), identity.classId).toBeNull(); } else { unchangedRangedConsumers += 1; expect(unarmedAmount, identity.classId).toBe(trainingAmount); } } expect(mainHandConsumers).toBeGreaterThanOrEqual(2); expect(mainHandConsumerClasses).toEqual(expect.arrayContaining(["rom-knight", "rom-rogue"])); expect(unchangedRangedConsumers).toBeGreaterThanOrEqual(1); }); it("uses unarmed Main_DMG and attack speed for native physical DotMagic rank correction", () => { const identity = ROM_FIVE_IDENTITIES.find((candidate) => candidate.classId === "rom-rogue")!; const level = 30; const gear = resolvedEquipmentStats({}, [], { ownerId: "native-disarm-dot", ownerKind: "player", classId: identity.classId, secondaryClassId: identity.secondaryClassId, specializationId: null, role: identity.role, level }); const caster = deriveCharacterStats(identity.classId, level, gear, identity.raceId, {}, identity.secondaryClassId, "male", "rom").romNative!; const target = { attributes: { wisdom: caster.attributes.wisdom }, health: { max: 10_000 }, physicalAttack: caster.physicalAttack, physicalDamage: caster.physicalDamage, physicalDefense: caster.physicalDefense, magicalAttack: caster.magicalAttack, magicalDamage: caster.magicalDamage, magicalDefense: caster.magicalDefense, healing: { ...caster.healing, targetHealAbsorbRate: 0, targetHealAbsorb: 0 }, sourceClassId: caster.sourceInputs.classId, }; const source = romNativePeriodicDamageBinding({ skillId: 490306, componentId: 620313, kind: "dot" }); expect(source?.component).toMatchObject({ effectType: 1, dotType: 0, isFixDotPower: false, dotSkillLevelArg: 45 }); const profile = nativeDisarmedMainHandAutoAttackProfile(caster); expect(profile).toBeDefined(); if (!profile) return; const scaling = { romNativeSource: { kind: "dot", skillId: 490306, componentId: 620313 } } as AbilityAmountScaling; const trainingSnapshot = nativePeriodicDamageCasterSnapshot(scaling, level, caster, target, level); const unarmedSnapshot = nativePeriodicDamageCasterSnapshot(scaling, level, caster, target, level, undefined, undefined, profile); expect(trainingSnapshot).toBeDefined(); expect(unarmedSnapshot).toBeDefined(); expect(unarmedSnapshot?.magicLevelAdjustment).not.toBe(trainingSnapshot?.magicLevelAdjustment); expect(nativePeriodicDamageCasterSnapshot(scaling, level, caster, target, level, undefined, undefined, { ...profile, attackSpeed: profile.attackSpeed + 1 })).toBeUndefined(); }); });