import { beforeEach, describe, expect, it } from "vitest"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { useCombatStore, stableCombatUnit } from "./combatStore"; import { PLAYER_AGGRO_ID } from "./aggro"; import { applyAura } from "./combatAuras"; import { abilitiesForCharacter, abilityById, learnedAbilityForBuild, } from "./abilityCatalog"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { clearPartyRuntimeRegistry, updatePartyRuntimePosition, updatePartyRuntimeTargetVisibility, } from "./partyRuntimeRegistry"; const STRIKE_ID = "rom-champion-rom-warlock-elite-30"; const SIPHON_ID = "rom-champion-rom-warlock-elite-20"; const TARGET_ID = "dark-energy-strike-source-target"; function seededStrikeTime(actorId: string, suffix: "direct" | "special-crit"): number { const strike = abilityById(STRIKE_ID)!; for (let now = 10_000; now < 100_000; now += 1) { const resetRoll = stableCombatUnit( `${actorId}:${strike.id}:${TARGET_ID}:${now}:rom-champion-dark-energy-strike-rune-siphon`, ); const outcomeRoll = stableCombatUnit(`${actorId}:${strike.id}:${TARGET_ID}:${now}:${suffix}:outcome`); if (resetRoll < 0.15 && outcomeRoll >= 0.2 && outcomeRoll < 0.8) return now; } throw new Error("Could not find a deterministic landed Dark Energy Strike reset seed."); } function setupPlayerStrike( armor: number, shadowResistance: number, now: number, targetLevel = 1, shadowImmune = false, targetHealth = 1_000_000, ) { useCombatStore.getState().initializeCharacter({ classId: "rom-champion", secondaryClassId: "rom-warlock", raceId: "rom-dwarf", level: 30, }); useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, level: targetLevel, maxHealth: targetHealth, resistances: { shadow: shadowResistance }, ...(shadowImmune ? { schoolImmunities: ["shadow"] } : {}), }); setMobPosition(TARGET_ID, [3, 0, 0]); useCombatStore.setState((state) => { const target = state.mobs[TARGET_ID]!; return { mobs: { ...state.mobs, [TARGET_ID]: { ...target, armor } }, resource: 100, resourcePools: { ...state.resourcePools, rage: 100 }, cooldowns: { ...state.cooldowns, [SIPHON_ID]: now + 60_000 }, }; }); } function playerStrikeDamage(armor: number, shadowResistance: number): number { const now = seededStrikeTime(PLAYER_AGGRO_ID, "direct"); setupPlayerStrike(armor, shadowResistance, now); const result = useCombatStore.getState().castAbility(STRIKE_ID, { targetActorId: TARGET_ID, now, }); expect(result, JSON.stringify(result)).toMatchObject({ ok: true, affectedMobIds: [TARGET_ID] }); return result.damage; } describe("Dark Energy Strike ordered-group integration", () => { beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); useCombatStore.setState({ playerPosition: [0, 0, 0] }); }); it("uses armor for the physical group and shadow resistance for the Darkness group", () => { const noArmorNoShadowResistance = playerStrikeDamage(0, 0); const armorNoShadowResistance = playerStrikeDamage(50_000, 0); const armorHighShadowResistance = playerStrikeDamage(50_000, 1_000_000); expect(noArmorNoShadowResistance).toBeGreaterThan(armorNoShadowResistance); expect(armorNoShadowResistance).toBeGreaterThan(armorHighShadowResistance); expect(armorHighShadowResistance).toBeGreaterThan(0); }); it("resets the player's separately learned Rune Siphon after physical success even when Darkness is fully resisted", () => { const now = seededStrikeTime(PLAYER_AGGRO_ID, "direct"); setupPlayerStrike(50_000, 0, now, 1, true); const result = useCombatStore.getState().castAbility(STRIKE_ID, { targetActorId: TARGET_ID, now, }); expect(result, JSON.stringify(result)).toMatchObject({ ok: true, damage: expect.any(Number) }); expect(result.damage).toBeGreaterThan(0); expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage" && event.abilityId === STRIKE_ID && event.school === "shadow")).toEqual([ expect.objectContaining({ effectiveAmount: 0 }), ]); expect(useCombatStore.getState().cooldowns[SIPHON_ID]).toBe(0); }); it("does not run the Darkness packet or reset after the player's physical packet misses", () => { const now = seededStrikeTime(PLAYER_AGGRO_ID, "direct"); setupPlayerStrike(0, 0, now, 255); const result = useCombatStore.getState().castAbility(STRIKE_ID, { targetActorId: TARGET_ID, now, }); const strikeDamageEvents = useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "damage" && event.abilityId === STRIKE_ID )); expect(result, JSON.stringify(result)).toMatchObject({ ok: true, damage: 0 }); expect(strikeDamageEvents).toHaveLength(1); expect(strikeDamageEvents[0]).toMatchObject({ school: "physical", outcome: "miss" }); expect(useCombatStore.getState().cooldowns[SIPHON_ID]).toBe(now + 60_000); }); it("does not run the Darkness packet or reset when an absorb reduces physical HP damage to zero", () => { const now = seededStrikeTime(PLAYER_AGGRO_ID, "direct"); setupPlayerStrike(0, 0, now); const shield = applyAura(useCombatStore.getState().auras, { definition: { id: "test:dark-energy-physical-absorb", name: "Physical absorb fixture", disposition: "buff", durationMs: null, absorbs: [{ id: "test:dark-energy-physical-absorb-pool", amount: 1_000_000, school: "physical" }], }, sourceId: PLAYER_AGGRO_ID, targetId: TARGET_ID, now, }).auras; useCombatStore.setState({ auras: shield }); const result = useCombatStore.getState().castAbility(STRIKE_ID, { targetActorId: TARGET_ID, now, }); const strikeDamageEvents = useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "damage" && event.abilityId === STRIKE_ID )); expect(result, JSON.stringify(result)).toMatchObject({ ok: true, damage: 0 }); expect(strikeDamageEvents).toHaveLength(1); expect(strikeDamageEvents[0]).toMatchObject({ school: "physical", effectiveAmount: 0, absorbed: expect.any(Number), }); expect(strikeDamageEvents[0]!.absorbed).toBeGreaterThan(0); expect(useCombatStore.getState().cooldowns[SIPHON_ID]).toBe(now + 60_000); }); it("keeps the physical success eligible for reset when that packet kills its target", () => { const now = seededStrikeTime(PLAYER_AGGRO_ID, "direct"); setupPlayerStrike(0, 0, now, 1, false, 1); const result = useCombatStore.getState().castAbility(STRIKE_ID, { targetActorId: TARGET_ID, now, }); const strikeDamageEvents = useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "damage" && event.abilityId === STRIKE_ID )); expect(result, JSON.stringify(result)).toMatchObject({ ok: true, damage: 1 }); expect(useCombatStore.getState().mobs[TARGET_ID]?.dead).toBe(true); expect(strikeDamageEvents).toHaveLength(1); expect(strikeDamageEvents[0]).toMatchObject({ school: "physical", effectiveAmount: 1 }); expect(useCombatStore.getState().cooldowns[SIPHON_ID]).toBe(0); }); it("resets a companion's Rune Siphon in both owner and combat-actor cooldown mirrors", () => { const seed = "dark-energy-strike-companion"; useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 30 }); const companion = createConfiguredPartyMember({ name: "Champion/Warlock", classId: "rom-champion", secondaryClassId: "rom-warlock", raceId: "rom-dwarf", specialization: "Primary", specializationId: "rom-champion:rom-warlock", talentTreeId: "rom-champion-rom-warlock", role: "damage", }, 30, 0, seed); const strike = abilitiesForCharacter("rom-champion", "rom-warlock") .find((ability) => ability.dbcSpellId === 498_599)!; const siphon = abilitiesForCharacter("rom-champion", "rom-warlock") .find((ability) => ability.dbcSpellId === 498_595)!; const learnedStrike = learnedAbilityForBuild(strike, companion.level, companion.talentRanks)!; const learnedSiphon = learnedAbilityForBuild(siphon, companion.level, companion.talentRanks)!; const now = seededStrikeTime(companion.id, "special-crit"); usePartyStore.getState().initializeParty(seed, 30, useCombatStore.getState().health, "damage", 1, "rom"); useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, level: 1, maxHealth: 1_000_000, xpReward: 0 }); setMobPosition(TARGET_ID, [2, 0, 0]); useCombatStore.getState().damageMob(TARGET_ID, 1, now); updatePartyRuntimePosition(companion.id, 0, 0, 0); usePartyStore.setState({ command: "attack", members: [{ ...companion, resource: 100, resourcePools: { ...companion.resourcePools, rage: 100 }, cooldowns: { ...companion.cooldowns, [learnedSiphon.id]: now + 60_000 }, activeCast: { abilityId: learnedStrike.id, school: "physical", mode: "cast", startedAt: now, completesAt: now, originalDurationMs: 0, pushbackHits: 0, origin: [0, 0, 0], targetId: TARGET_ID, }, activeCastTargetId: TARGET_ID, status: "attacking", }], }); updatePartyRuntimeTargetVisibility(companion.id, TARGET_ID, true); useCombatStore.getState().synchronizeActors(); advanceDungeonPartyCombat(now); expect(usePartyStore.getState().members[0]?.cooldowns[learnedSiphon.id]).toBe(0); expect(useCombatStore.getState().actors[companion.id]?.cooldowns[learnedSiphon.id]).toBe(0); }); it("does not run the companion Darkness packet or reset after its physical packet misses", () => { const seed = "dark-energy-strike-companion-miss"; useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 30 }); const companion = createConfiguredPartyMember({ name: "Champion/Warlock", classId: "rom-champion", secondaryClassId: "rom-warlock", raceId: "rom-dwarf", specialization: "Primary", specializationId: "rom-champion:rom-warlock", talentTreeId: "rom-champion-rom-warlock", role: "damage", }, 30, 0, seed); const strike = abilitiesForCharacter("rom-champion", "rom-warlock") .find((ability) => ability.dbcSpellId === 498_599)!; const siphon = abilitiesForCharacter("rom-champion", "rom-warlock") .find((ability) => ability.dbcSpellId === 498_595)!; const learnedStrike = learnedAbilityForBuild(strike, companion.level, companion.talentRanks)!; const learnedSiphon = learnedAbilityForBuild(siphon, companion.level, companion.talentRanks)!; const now = seededStrikeTime(companion.id, "special-crit"); usePartyStore.getState().initializeParty(seed, 30, useCombatStore.getState().health, "damage", 1, "rom"); useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, level: 255, maxHealth: 1_000_000, xpReward: 0, }); setMobPosition(TARGET_ID, [2, 0, 0]); useCombatStore.getState().damageMob(TARGET_ID, 1, now); updatePartyRuntimePosition(companion.id, 0, 0, 0); usePartyStore.setState({ command: "attack", members: [{ ...companion, resource: 100, resourcePools: { ...companion.resourcePools, rage: 100 }, cooldowns: { ...companion.cooldowns, [learnedSiphon.id]: now + 60_000 }, activeCast: { abilityId: learnedStrike.id, school: "physical", mode: "cast", startedAt: now, completesAt: now, originalDurationMs: 0, pushbackHits: 0, origin: [0, 0, 0], targetId: TARGET_ID, }, activeCastTargetId: TARGET_ID, status: "attacking", }], }); updatePartyRuntimeTargetVisibility(companion.id, TARGET_ID, true); useCombatStore.getState().synchronizeActors(); advanceDungeonPartyCombat(now); const strikeDamageEvents = useCombatStore.getState().combatEvents.filter((event) => ( event.kind === "damage" && event.abilityId === learnedStrike.id )); expect(strikeDamageEvents).toHaveLength(1); expect(strikeDamageEvents[0]).toMatchObject({ school: "physical", outcome: "miss" }); expect(usePartyStore.getState().members[0]?.cooldowns[learnedSiphon.id]).toBe(now + 60_000); expect(useCombatStore.getState().actors[companion.id]?.cooldowns[learnedSiphon.id]).toBe(now + 60_000); }); });