196 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|