import { beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { applyAura } from "./combatAuras"; import { abilitiesForClass } from "./abilityCatalog"; import { useCombatStore, PLAYER_AURA_ENTITY_ID } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { advanceDungeonPartyCombat, partyAbilityTargetIsValid, partyAttackDamage, resetPartyRuntimeClock } from "./partyRuntime"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { enemyWeaponDebuffAuraDefinition, enemyWeaponDebuffContract } from "./enemyWeaponDebuffs"; import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities"; const START_AT = 1_000; function weaponItem( instanceId: string, weaponType: "dagger" | "sword", damage: number, speedMs: number, ) { return { instanceId, itemId: 1, itemKey: instanceId, name: `Source fixture ${weaponType}`, quality: "uncommon" as const, inventorySlot: "one-hand", armorType: null, weaponType, itemLevel: 1, sourceItemLevel: 1, stats: { ...EMPTY_ITEM_STATS, meleeWeapon: { damageMin: damage, damageMax: damage, damageSchool: "physical" as const, speedMs }, }, quantity: 1, sourceBossKey: "source-disarm-fixture", sourceBossName: "Source Disarm Fixture", acquiredAt: START_AT, }; } function rangedItem(instanceId: string) { return { ...weaponItem(instanceId, "sword", 500, 2_500), inventorySlot: "ranged", weaponType: "bow", stats: { ...EMPTY_ITEM_STATS, rangedWeapon: { damageMin: 400, damageMax: 400, damageSchool: "physical" as const, speedMs: 2_500 }, }, }; } function applySourceDisarm(targetId: string, now = START_AT): void { const definition = enemyWeaponDebuffAuraDefinition(enemyWeaponDebuffContract(6713, "ascension")!); useCombatStore.setState((state) => ({ auras: [...applyAura(state.auras, { definition, sourceId: "source-disarm-caster", targetId, now }).auras], })); } function registerTarget(id: string): void { useCombatStore.getState().registerMob(id, { name: "Source Disarm runtime target", level: 1, maxHealth: 100_000, xpReward: 0, }); setMobPosition(id, [2, 0, 0]); useCombatStore.getState().selectMob(id); } beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); useCombatStore.getState().initializeCharacter({ classId: "warrior", level: 30 }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); usePartyStore.getState().initializeParty("enemy-disarm-runtime", 30, useCombatStore.getState().health, "damage", 1, "wow"); }); describe("source Disarm character execution", () => { it("blocks an equipped-weapon cast and keeps the player's main-hand auto as a 1–2 / 2s punch", () => { const dagger = weaponItem("player-disarm-dagger", "dagger", 500, 4_000); useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80, inventory: [dagger], equipment: { player: { "main-hand": dagger.instanceId } }, }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); const targetId = "player-disarm-runtime-target"; registerTarget(targetId); applySourceDisarm(PLAYER_AURA_ENTITY_ID); const sinisterStrike = abilitiesForClass("rogue").find((ability) => ability.name === "Sinister Strike")!; expect(sinisterStrike.wowWeaponRequirements?.equippedItemClass).toBe(2); expect(useCombatStore.getState().castAbility(sinisterStrike.id, [0, 0, 0], START_AT)).toMatchObject({ ok: false, reason: "condition-not-met", }); useCombatStore.setState((state) => ({ autoAttackTargetId: targetId, nextAutoAttackAt: START_AT, nextOffhandAutoAttackAt: 0, auras: [...state.auras], })); useCombatStore.getState().tick(0.05, START_AT); const state = useCombatStore.getState(); expect(state.nextAutoAttackAt).toBe(START_AT + 2_000); const auto = state.combatEvents.find((event) => event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID && event.targetActorId === targetId); expect(auto).toBeDefined(); expect(auto?.rawAmount).toBeLessThan(500); expect(auto?.attackHand).toBe("mainhand"); }); it("gates the companion's source weapon cast while retaining the ordinary unarmed party auto", () => { const targetId = "party-disarm-runtime-target"; registerTarget(targetId); const configured = createConfiguredPartyMember({ name: "Source fixture Protection Warrior", classId: "warrior", raceId: "human", specialization: "Protection", specializationId: "warrior:protection", talentTreeId: "warrior-protection", role: "tank", }, 30, 0, "party-source-disarm-runtime"); const sword = weaponItem("party-disarm-sword", "sword", 500, 4_000); const member = { ...configured, gearStats: sword.stats, nextActionAt: 10_000, }; usePartyStore.setState({ members: [member], command: "attack", activeMobId: targetId }); useCombatStore.setState((state) => ({ inventory: [...state.inventory, sword], equipment: { ...state.equipment, [member.id]: { "main-hand": sword.instanceId } }, })); registerPartyRuntimePosition(member.id, [0, 0, 0]); updatePartyRuntimeTargetVisibility(member.id, targetId, true); useCombatStore.getState().synchronizeActors(); applySourceDisarm(member.id); const heroicStrike = abilitiesForClass("warrior").find((ability) => ability.name === "Heroic Strike")!; expect(heroicStrike.wowWeaponRequirements?.equippedItemClass).toBe(2); expect(partyAbilityTargetIsValid(member, heroicStrike, targetId, [0, 0, 0], START_AT)).toBe(false); const permittedPhysicalMeleeAbility = abilitiesForClass("warrior") .find((ability) => ability.name === "Devastate")!; expect(permittedPhysicalMeleeAbility.wowSpellDefense?.damageClass).toBe(2); expect(permittedPhysicalMeleeAbility.wowWeaponRequirements).toBeUndefined(); expect(partyAbilityTargetIsValid( member, permittedPhysicalMeleeAbility!, targetId, [0, 0, 0], START_AT, )).toBe(true); const expectedUnarmedAmount = partyAttackDamage(member, START_AT, { damageMin: 1, damageMax: 2, damageSchool: "physical", speedMs: 2_000, }); advanceDungeonPartyCombat(START_AT); const auto = useCombatStore.getState().combatEvents.find((event) => event.kind === "damage" && event.sourceActorId === member.id && event.abilityId === "party-auto-attack" && event.targetActorId === targetId); expect(auto).toBeDefined(); expect(auto?.rawAmount).toBe(expectedUnarmedAmount); expect(auto?.attackHand).toBe("mainhand"); }); it("uses only the unarmed main-hand contribution for a disarmed dual-wielding party shaman", () => { const targetId = "party-disarm-shaman-target"; registerTarget(targetId); const configured = createConfiguredPartyMember({ name: "Source fixture Enhancement Shaman", classId: "shaman", raceId: "orc", specialization: "Enhancement", specializationId: "shaman:enhancement", talentTreeId: "shaman-enhancement", role: "damage", }, 80, 0, "party-source-disarm-shaman"); const mainHand = weaponItem("party-disarm-shaman-main", "sword", 500, 4_000); const offHand = { ...weaponItem("party-disarm-shaman-off", "sword", 500, 4_000), inventorySlot: "one-hand" }; const member = { ...configured, gearStats: mainHand.stats, nextActionAt: START_AT + 100_000 }; usePartyStore.setState({ members: [member], command: "attack", activeMobId: targetId }); useCombatStore.setState((state) => ({ inventory: [...state.inventory, mainHand, offHand], equipment: { ...state.equipment, [member.id]: { "main-hand": mainHand.instanceId, "off-hand": offHand.instanceId }, }, })); registerPartyRuntimePosition(member.id, [0, 0, 0]); updatePartyRuntimeTargetVisibility(member.id, targetId, true); useCombatStore.getState().synchronizeActors(); applySourceDisarm(member.id); advanceDungeonPartyCombat(START_AT); const events = useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "damage" && event.sourceActorId === member.id && event.targetActorId === targetId )); const mainHandHit = events.find((event) => event.abilityId === "party-auto-attack"); const offHandHit = events.find((event) => event.abilityId === "party-auto-attack-offhand"); expect(mainHandHit).toBeDefined(); expect(offHandHit).toBeDefined(); expect(mainHandHit!.rawAmount).toBeLessThan(200); expect(offHandHit!.rawAmount).toBeGreaterThan(mainHandHit!.rawAmount + 10); expect(mainHandHit!.attackHand).toBe("mainhand"); expect(offHandHit!.attackHand).toBe("offhand"); }); it("uses the verified 3-second unarmed profile for a native party main-hand auto", () => { const targetId = "party-disarm-native-target"; registerTarget(targetId); const identity = ROM_FIVE_IDENTITIES.find((candidate) => candidate.classId === "rom-knight")!; const configured = createConfiguredPartyMember({ ...identity, statRuleset: "rom", specialization: "Primary", talentTreeId: "", }, 30, 0, "party-native-disarm-runtime"); const member = { ...configured, nextActionAt: START_AT + 100_000 }; usePartyStore.setState({ members: [member], command: "attack", activeMobId: targetId }); registerPartyRuntimePosition(member.id, [0, 0, 0]); updatePartyRuntimeTargetVisibility(member.id, targetId, true); useCombatStore.getState().synchronizeActors(); applySourceDisarm(member.id); advanceDungeonPartyCombat(START_AT); const autoEvents = () => useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "damage" && event.sourceActorId === member.id && event.abilityId === "party-auto-attack" && event.targetActorId === targetId )); expect(autoEvents()).toHaveLength(1); expect(autoEvents()[0].rawAmount).toBe(partyAttackDamage(member, START_AT, undefined, true)); expect(autoEvents()[0].attackHand).toBe("mainhand"); advanceDungeonPartyCombat(START_AT + 2_999); expect(autoEvents()).toHaveLength(1); advanceDungeonPartyCombat(START_AT + 3_000); expect(autoEvents()).toHaveLength(2); }); it("rechecks the Ascension ranged channel for a running Scout Auto Shot and resumes after expiry", () => { const bow = rangedItem("scout-disarm-bow"); useCombatStore.getState().initializeCharacter({ classId: "rom-scout", secondaryClassId: "rom-rogue", level: 80, statRuleset: "rom", inventory: [bow], equipment: { player: { ranged: bow.instanceId } }, }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); const targetId = "scout-disarm-ranged-target"; useCombatStore.getState().registerMob(targetId, { name: "Scout ranged Disarm target", level: 1, maxHealth: 100_000, xpReward: 0, }); setMobPosition(targetId, [4, 0, 0]); applySourceDisarm(PLAYER_AURA_ENTITY_ID); useCombatStore.setState({ autoShotTargetId: targetId, nextAutoShotAt: START_AT }); useCombatStore.getState().tick(0.05, START_AT); expect(useCombatStore.getState().mobs[targetId]?.health).toBe(100_000); expect(useCombatStore.getState().autoShotTargetId).toBe(targetId); useCombatStore.getState().tick(0.05, START_AT + 5_000); expect(useCombatStore.getState().mobs[targetId]?.health).toBeLessThan(100_000); expect(useCombatStore.getState().combatEvents.find((event) => event.sourceActorId === PLAYER_AGGRO_ID && event.targetActorId === targetId && event.kind === "damage")?.attackHand).toBe("ranged"); }); });