Files
healer-man/src/game/romNativeDisarm.test.ts
T
2026-10-06 22:25:34 -04:00

196 lines
10 KiB
TypeScript

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();
});
});