import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import type { OnlineSessionPlayer } from "../app/onlineSessionTypes"; import { installCombatClock } from "./combatClock"; import { createDeathKnightRunes } from "./combatActors"; import { useCombatStore, PLAYER_AURA_ENTITY_ID, type CombatSummon } from "./combatStore"; import { usePartyStore } from "./partyStore"; import { configureOnlineSessionRuntime, onlinePlayerActorId } from "./onlineSessionRuntime"; import { WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG, WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID, } from "./wow335DeathKnightMarkOfBloodRuntime"; let now = 1_000; let restoreClock: (() => void) | undefined; function markAbility() { const ability = useCombatStore.getState().abilities.find((candidate) => candidate.dbcSpellId === 49005); if (!ability) throw new Error("Missing generated Death Knight Mark of Blood spell."); return ability; } function registerAttacker(id: string) { useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0, attacks: [{ id: `${id}-strike`, name: "Strike", delivery: "melee", target: "primary", school: "physical", animation: "attack", range: 5, cooldownMs: 1_000, damageMultiplier: 0.01, }], }); setMobPosition(id, [2, 0, 0]); } function castMark() { const ability = markAbility(); const result = useCombatStore.getState().castAbility(ability.id, { now }); expect(result.ok).toBe(true); return ability; } function remoteVictim(health = 9_000): OnlineSessionPlayer { return { accountId: "mark-remote-victim", username: "Mark victim", character: { id: "mark-remote-character", name: "Mark victim", classId: "warrior", categoryId: "wow", raceId: "human", gender: "female", level: 80, }, role: "tank", position: [0, 0, 0], yaw: 0, grounded: true, health, maxHealth: 10_000, resource: 0, maxResource: 100, resourceName: "Rage", selectedTargetId: null, activeCast: null, animationEvent: null, equipment: [], updatedAt: now, }; } describe("WoW Death Knight Mark of Blood combat integration", () => { beforeEach(() => { now = 1_000; restoreClock = installCombatClock(() => now); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80, xp: 0 }); usePartyStore.getState().initializeParty( "wow335-death-knight-mark-of-blood", 80, useCombatStore.getState().health, "tank", 5, ); useCombatStore.setState((state) => ({ globalCooldownEndsAt: 0, deathKnightRunes: createDeathKnightRunes(now), talentRanks: { ...state.talentRanks, "talent-1949": 1 }, resource: 100, resourcePools: { "runic-power": 100 }, })); registerAttacker("mark-target"); useCombatStore.getState().selectMob("mark-target"); }); afterEach(() => { restoreClock?.(); restoreClock = undefined; configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null }); }); it("casts the generated talent spell with twenty charges and a twenty-second duration", () => { castMark(); const mark = useCombatStore.getState().auras.find((aura) => aura.targetId === "mark-target" && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG)); expect(mark).toMatchObject({ sourceId: PLAYER_AURA_ENTITY_ID, stacks: 20, expiresAt: now + 20_000, }); expect(mark?.definition.maxStacks).toBe(20); }); it("heals the player when the marked enemy lands an attack and consumes one charge", () => { castMark(); const maximumHealth = useCombatStore.getState().maxHealth; const healthBefore = maximumHealth - 5_000; useCombatStore.setState({ health: healthBefore }); useCombatStore.getState().damageMob("mark-target", 1, now); useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]); const state = useCombatStore.getState(); const damage = [...state.combatEvents].reverse().find((event) => event.kind === "damage" && event.sourceActorId === "mark-target" && event.targetActorId === PLAYER_AURA_ENTITY_ID); const healing = [...state.combatEvents].reverse().find((event) => event.kind === "healing" && event.abilityId === WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID && event.targetActorId === PLAYER_AURA_ENTITY_ID); const mark = state.auras.find((aura) => aura.targetId === "mark-target" && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG)); expect(damage?.effectiveAmount).toBeGreaterThan(0); expect(healing).toMatchObject({ rawAmount: Math.round(maximumHealth * 0.04) }); expect(healing?.effectiveAmount).toBe(healing?.rawAmount); expect(state.health).toBe(healthBefore - damage!.effectiveAmount + healing!.effectiveAmount); expect(mark?.stacks).toBe(19); }); it("heals a party companion when the marked enemy lands an attack on them", () => { const target = usePartyStore.getState().members[0]!; const maxHealth = target.maxHealth; const healthBefore = maxHealth - 1_000; usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === target.id ? { ...member, health: healthBefore } : member), })); registerPartyRuntimePosition(target.id, [0, 0, 0]); castMark(); useCombatStore.getState().damageMob("mark-target", 50_000, now, { actorId: target.id, role: target.role ?? "damage", }); useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]); const state = useCombatStore.getState(); const memberAfter = usePartyStore.getState().members.find((member) => member.id === target.id)!; const damage = [...state.combatEvents].reverse().find((event) => event.kind === "damage" && event.sourceActorId === "mark-target" && event.targetActorId === target.id); const healing = [...state.combatEvents].reverse().find((event) => event.kind === "healing" && event.abilityId === WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID && event.targetActorId === target.id); const mark = state.auras.find((aura) => aura.targetId === "mark-target" && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG)); expect(damage?.effectiveAmount).toBeGreaterThan(0); expect(healing).toMatchObject({ rawAmount: Math.round(maxHealth * 0.04) }); expect(healing?.effectiveAmount).toBe(healing?.rawAmount); expect(memberAfter.health).toBe(healthBefore - damage!.effectiveAmount + healing!.effectiveAmount); expect(mark?.stacks).toBe(19); }); it("heals a summoned companion from the marked enemy's damage event", () => { const summon: CombatSummon = { id: "mark-summon-victim", sourceAbilityId: "test-summon", creatureId: 0, targetId: "mark-target", expiresAt: now + 60_000, kind: "summon", name: "Companion", ownerActorId: PLAYER_AURA_ENTITY_ID, health: 9_000, maxHealth: 10_000, position: [0, 0, 0], nextActionAt: 0, pulseIntervalMs: 1_000, attackDamage: 0, abilityIds: [], resources: {}, cooldowns: {}, globalCooldownEndsAt: 0, cast: null, controlledUntil: 0, schoolLockedUntil: {}, }; useCombatStore.setState({ summons: [summon] }); castMark(); useCombatStore.getState().damageMob("mark-target", 50_000, now, { actorId: summon.id, role: "tank", }); useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]); const state = useCombatStore.getState(); const summonAfter = state.summons.find((candidate) => candidate.id === summon.id)!; const damage = [...state.combatEvents].reverse().find((event) => event.kind === "damage" && event.sourceActorId === "mark-target" && event.targetActorId === summon.id); const healing = [...state.combatEvents].reverse().find((event) => event.kind === "healing" && event.abilityId === WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID && event.targetActorId === summon.id); const mark = state.auras.find((aura) => aura.targetId === "mark-target" && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG)); expect(damage?.effectiveAmount).toBeGreaterThan(0); expect(healing).toMatchObject({ rawAmount: 400 }); expect(summonAfter.health).toBe(summon.health - damage!.effectiveAmount + healing!.effectiveAmount); expect(mark?.stacks).toBe(19); }); it("heals an online player from the marked enemy's damage event", () => { const victim = remoteVictim(); const victimId = onlinePlayerActorId(victim.accountId); configureOnlineSessionRuntime({ active: true, authority: true, localAccountId: "mark-local", players: [victim], }); registerPartyRuntimePosition(victimId, victim.position); useCombatStore.getState().synchronizeActors(); castMark(); useCombatStore.getState().damageMob("mark-target", 50_000, now, { actorId: victimId, role: victim.role ?? "damage", }); useCombatStore.getState().advanceMobCombat(now + 450, [0, 0, 0]); const state = useCombatStore.getState(); const victimAfter = state.actors[victimId]!; const damage = [...state.combatEvents].reverse().find((event) => event.kind === "damage" && event.sourceActorId === "mark-target" && event.targetActorId === victimId); const healing = [...state.combatEvents].reverse().find((event) => event.kind === "healing" && event.abilityId === WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_ABILITY_ID && event.targetActorId === victimId); const mark = state.auras.find((aura) => aura.targetId === "mark-target" && aura.definition.utilityTags?.includes(WOW335_DEATH_KNIGHT_MARK_OF_BLOOD_TAG)); expect(damage?.effectiveAmount).toBeGreaterThan(0); expect(healing).toMatchObject({ rawAmount: 400 }); expect(victimAfter.health).toBe(victim.health - damage!.effectiveAmount + healing!.effectiveAmount); expect(mark?.stacks).toBe(19); }); });