import { beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AURA_ENTITY_ID } from "./combatStore"; import { useCombatStore } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility, } from "./partyRuntimeRegistry"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { abilitiesForClass } from "./abilityCatalog"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { requireDungeonDefinition } from "./dungeonRegistry"; import { applyAura, type AuraDefinition } from "./combatAuras"; import type { EnemyAttackDefinition } from "./enemyAttacks"; import { COA_GUARDIAN_VANGUARD_AURA_ID } from "./coaGuardianVanguardRuntime"; const HAMHOCK_CHAIN_LIGHTNING = requireDungeonDefinition("stormwind-stockade") .entities["entry-1717"]! .combat! .attacks.find((attack) => attack.spellId === 421)!; const VANGUARD_RANKS = { "coa-entry-34417": 1 }; const GUARANTEED_MELEE_BLOCK_FIXTURE: AuraDefinition = { id: "test:vanguard-guaranteed-melee-block", name: "Vanguard integration block fixture", disposition: "buff", durationMs: null, stackBehavior: "replace", modifiers: [ { kind: "stat", stat: "block", operation: "flat", value: 1_000 }, { kind: "stat", stat: "block-value", operation: "flat", value: 20 }, { kind: "stat", stat: "dodge", operation: "flat", value: -1_000 }, { kind: "stat", stat: "parry", operation: "flat", value: -1_000 }, { kind: "stat", stat: "incoming-melee-hit", operation: "flat", value: 100 }, { kind: "stat", stat: "incoming-spell-hit", operation: "flat", value: 100 }, ], }; const MELEE_BLOCK_FIXTURE_ATTACK: EnemyAttackDefinition = { id: "vanguard-integration-melee", name: "Vanguard integration melee", delivery: "melee", target: "primary", school: "physical", attackKind: "melee", animation: "attack", range: 4.5, cooldownMs: 60_000, initialCooldownMs: [0, 0], repeatCooldownMs: [60_000, 60_000], damageMultiplier: 1, }; function vanguardAuras(actorId: string) { return useCombatStore.getState().auras.filter((aura) => ( aura.definition.id === COA_GUARDIAN_VANGUARD_AURA_ID && aura.sourceId === actorId && aura.targetId === actorId )); } function registerHamhock(id: string): void { useCombatStore.getState().registerMob(id, { name: "Hamhock (Vanguard integration fixture)", serverEntry: 1717, level: 30, maxHealth: 100_000, xpReward: 0, attacks: [{ ...HAMHOCK_CHAIN_LIGHTNING, initialCooldownMs: [0, 0], repeatCooldownMs: [60_000, 60_000], }], }); setMobPosition(id, [2, 0, 0]); useCombatStore.getState().selectMob(id); } function applyDeterministicBlockFixture(actorId: string, now: number): void { const auras = applyAura(useCombatStore.getState().auras, { definition: GUARANTEED_MELEE_BLOCK_FIXTURE, sourceId: actorId, targetId: actorId, now, }).auras; useCombatStore.setState({ auras: [...auras] }); } function registerMeleeBlockFixture(id: string, targetActorId: string, now: number): void { useCombatStore.getState().registerMob(id, { name: "Vanguard melee block fixture", level: 30, maxHealth: 100_000, xpReward: 0, attacks: [MELEE_BLOCK_FIXTURE_ATTACK], }); setMobPosition(id, [2, 0, 0]); useCombatStore.getState().damageMob(id, 1, now, { actorId: targetActorId, role: "tank", }); } beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); // Match the imported level-30 Hamhock so its spell attack table is representative. useCombatStore.getState().initializeCharacter({ classId: "guardian", level: 30 }); useCombatStore.setState((state) => ({ talentRanks: { ...state.talentRanks, ...VANGUARD_RANKS }, gearStats: { ...state.gearStats, ...EMPTY_ITEM_STATS, hitRating: 1_000_000 }, })); useCombatStore.getState().setPlayerPosition([0, 0, 0]); usePartyStore.getState().initializeParty( "coa-guardian-vanguard-integration", 30, useCombatStore.getState().health, "tank", 1, "coa", ); usePartyStore.setState({ members: [], command: "stop", activeMobId: null }); }); describe("CoA Guardian Vanguard combat integration", () => { it("does not count Ram, then reduces Hamhock's imported Chain Lightning before consuming the auto-hit stack", () => { const mobId = "coa-vanguard-hamhock-player"; registerHamhock(mobId); const ram = abilitiesForClass("guardian").find((ability) => ability.dbcSpellId === 802284); expect(ram, "Guardian Ram must be present as a normal melee action").toBeDefined(); expect(useCombatStore.getState().castAbility(ram!.id, undefined, 1_000)).toMatchObject({ ok: true }); expect(vanguardAuras(PLAYER_AURA_ENTITY_ID)).toHaveLength(0); // Ram starts the player's normal weapon swings. Tick the actual player combat loop. useCombatStore.getState().tick(0.1, 1_001); useCombatStore.getState().tick(2.5, 3_501); const afterAutoAttacks = vanguardAuras(PLAYER_AURA_ENTITY_ID); expect(afterAutoAttacks).toHaveLength(1); expect(afterAutoAttacks[0]?.stacks).toBe(2); expect(HAMHOCK_CHAIN_LIGHTNING).toMatchObject({ spellId: 421, school: "nature", chain: { targetCount: 3 } }); const playerHealthBeforeSpell = useCombatStore.getState().health; useCombatStore.getState().advanceMobCombat(5_000); useCombatStore.getState().advanceMobCombat(8_000); const hit = useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.sourceActorId === mobId && event.targetActorId === PLAYER_AURA_ENTITY_ID && event.abilityId === HAMHOCK_CHAIN_LIGHTNING.id && event.school === "nature" )); expect(hit).toBeDefined(); const criticalMultiplier = hit!.critical ? 2 : 1; expect(hit!.effectiveAmount).toBeCloseTo(hit!.rawAmount * criticalMultiplier * 0.96, 8); expect(playerHealthBeforeSpell - useCombatStore.getState().health).toBeCloseTo(hit!.effectiveAmount, 8); expect(vanguardAuras(PLAYER_AURA_ENTITY_ID)).toHaveLength(1); expect(vanguardAuras(PLAYER_AURA_ENTITY_ID)[0]?.stacks).toBe(1); }); it("adds a companion-owned stack on its real normal auto attack", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", level: 60 }); const partyId = "coa-vanguard-companion-party"; const targetId = "coa-vanguard-companion-target"; usePartyStore.getState().initializeParty( partyId, 60, useCombatStore.getState().health, "damage", 1, "coa", ); const template = createConfiguredPartyMember({ name: "Vanguard Guardian Companion", classId: "guardian", specialization: "Vanguard", specializationId: "guardian:vanguard", talentTreeId: "coa-guardian-87", talentRanks: VANGUARD_RANKS, role: "tank", }, 60, 0, partyId); const guardian = { ...template, talentRanks: { ...template.talentRanks, ...VANGUARD_RANKS }, nextActionAt: 100_000, }; usePartyStore.setState({ members: [guardian], command: "attack", activeMobId: targetId }); registerPartyRuntimePosition(guardian.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); useCombatStore.getState().registerMob(targetId, { name: "Guardian auto-attack target", level: 60, maxHealth: 100_000, xpReward: 0, }); setMobPosition(targetId, [2, 0, 0]); useCombatStore.getState().damageMob(targetId, 1, 1_000); updatePartyRuntimeTargetVisibility(guardian.id, targetId, true); advanceDungeonPartyCombat(2_000); const companionAura = vanguardAuras(guardian.id); expect(companionAura).toHaveLength(1); expect(companionAura[0]).toMatchObject({ sourceId: guardian.id, targetId: guardian.id, stacks: 1, }); expect(vanguardAuras(PLAYER_AURA_ENTITY_ID)).toHaveLength(0); }); it("adds a player-owned stack after an actual deterministic melee block", () => { const mobId = "coa-vanguard-player-blocker"; applyDeterministicBlockFixture(PLAYER_AURA_ENTITY_ID, 900); registerMeleeBlockFixture(mobId, PLAYER_AURA_ENTITY_ID, 1_000); useCombatStore.getState().advanceMobCombat(1_000); useCombatStore.getState().advanceMobCombat(2_000); const blockedHit = useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.sourceActorId === mobId && event.targetActorId === PLAYER_AURA_ENTITY_ID && event.abilityId === MELEE_BLOCK_FIXTURE_ATTACK.id )); expect(blockedHit).toMatchObject({ outcome: "block" }); expect(useCombatStore.getState().lastCombatResult?.blocked).toBe(true); expect(vanguardAuras(PLAYER_AURA_ENTITY_ID)).toHaveLength(1); expect(vanguardAuras(PLAYER_AURA_ENTITY_ID)[0]?.stacks).toBe(1); }); it("lets a companion Guardian block, then mitigates Hamhock's spell before consuming one stack", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", level: 30 }); const partyId = "coa-vanguard-incoming-companion-party"; const autoTargetId = "coa-vanguard-incoming-companion-auto-target"; usePartyStore.getState().initializeParty( partyId, 30, useCombatStore.getState().health, "damage", 1, "coa", ); const template = createConfiguredPartyMember({ name: "Vanguard Guardian Companion", classId: "guardian", specialization: "Vanguard", specializationId: "guardian:vanguard", talentTreeId: "coa-guardian-87", talentRanks: VANGUARD_RANKS, role: "tank", }, 30, 0, partyId); const guardian = { ...template, talentRanks: { ...template.talentRanks, ...VANGUARD_RANKS }, nextActionAt: 100_000, }; usePartyStore.setState({ members: [guardian], command: "attack", activeMobId: autoTargetId }); registerPartyRuntimePosition(guardian.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); useCombatStore.getState().registerMob(autoTargetId, { name: "Companion auto-attack target", level: 30, maxHealth: 100_000, xpReward: 0, }); setMobPosition(autoTargetId, [2, 0, 0]); useCombatStore.getState().damageMob(autoTargetId, 1, 1_000); updatePartyRuntimeTargetVisibility(guardian.id, autoTargetId, true); advanceDungeonPartyCombat(2_000); expect(vanguardAuras(guardian.id)).toHaveLength(1); expect(vanguardAuras(guardian.id)[0]?.stacks).toBe(1); useCombatStore.getState().damageMob(autoTargetId, 100_000, 2_500, { actorId: guardian.id, role: guardian.role }); applyDeterministicBlockFixture(guardian.id, 2_500); const blockerId = "coa-vanguard-companion-blocker"; registerMeleeBlockFixture(blockerId, guardian.id, 3_000); useCombatStore.getState().advanceMobCombat(3_000); useCombatStore.getState().advanceMobCombat(4_000); const blockedHit = useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.sourceActorId === blockerId && event.targetActorId === guardian.id && event.abilityId === MELEE_BLOCK_FIXTURE_ATTACK.id )); expect(blockedHit).toMatchObject({ outcome: "block" }); expect(vanguardAuras(guardian.id)).toHaveLength(1); expect(vanguardAuras(guardian.id)[0]?.stacks).toBe(2); const hamhockId = "coa-vanguard-hamhock-companion"; registerHamhock(hamhockId); useCombatStore.getState().damageMob(hamhockId, 1, 5_000, { actorId: guardian.id, role: guardian.role }); const companionHealthBeforeSpell = useCombatStore.getState().actors[guardian.id]?.health; expect(companionHealthBeforeSpell).toBeDefined(); useCombatStore.getState().advanceMobCombat(8_000); useCombatStore.getState().advanceMobCombat(10_000); const hit = useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.sourceActorId === hamhockId && event.targetActorId === guardian.id && event.abilityId === HAMHOCK_CHAIN_LIGHTNING.id && event.school === "nature" )); expect(hit).toBeDefined(); const criticalMultiplier = hit!.critical ? 2 : 1; expect(hit!.effectiveAmount).toBe(Math.round(hit!.rawAmount * criticalMultiplier * 0.96)); expect(companionHealthBeforeSpell! - useCombatStore.getState().actors[guardian.id]!.health) .toBeCloseTo(hit!.effectiveAmount, 8); expect(vanguardAuras(guardian.id)).toHaveLength(1); expect(vanguardAuras(guardian.id)[0]?.stacks).toBe(1); expect(vanguardAuras(PLAYER_AURA_ENTITY_ID)).toHaveLength(0); }); });