import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { useCombatStore } from "./combatStore"; import { resetCombatSpatialProvider, registerCombatSpatialProvider } from "./combatSpatial"; import { useDungeonSimulationStore } from "./dungeonSimulationStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; import { commitPartyAbilityUse, partyAbilityIsReady } from "./partyAbilityAi"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; import { ROM_SCOUT_PRIEST_SCREEN_ATTACK_AURA, ROM_SCOUT_PRIEST_SCREEN_ATTACK_ID, } from "./romScoutPriestScreenAttack"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; const SCREEN_ATTACK = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === ROM_SCOUT_PRIEST_SCREEN_ATTACK_ID); const SCOUT_PERIODIC_ABILITY = ROM_ALL_ABILITIES.find((ability) => ability.id === "rom-scout-primary-5"); const SCREEN_ATTACK_ID = "rom-scout-rom-priest-elite-70"; const DIRECT_TEST_ABILITY_ID = "rom-screen-attack-direct-fixture"; const PERIODIC_TEST_ABILITY_ID = "rom-screen-attack-periodic-fixture"; const DIRECT_TEST_TARGET = "screen-attack-direct-target"; const DOT_TEST_TARGET = "screen-attack-dot-target"; const EXPIRY_TEST_TARGET = "screen-attack-expiry-target"; function makePriest(name: string, seed: string, index: number, health?: number) { const member = createConfiguredPartyMember({ name, classId: "rom-priest", specialization: "Primary", role: "damage", }, 80, index, seed); return health === undefined ? member : { ...member, health }; } function configurePlayerAndParty(seed: string, includeCompanionCaster = false) { useCombatStore.getState().initializeCharacter({ classId: "rom-scout", secondaryClassId: "rom-priest", raceId: "rom-human", level: 80, role: "damage", }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); usePartyStore.getState().initializeParty( seed, 80, useCombatStore.getState().health, "damage", 5, "rom", ); const partyCaster = includeCompanionCaster ? createConfiguredPartyMember({ name: "Scout/Priest companion", classId: "rom-scout", secondaryClassId: "rom-priest", specialization: "Primary", role: "damage", }, 80, 0, seed) : null; const roster = [ ...(partyCaster ? [partyCaster] : []), makePriest("Inclusive-radius edge", seed, 1), makePriest("Outside-radius ally", seed, 2), makePriest("Fallen ally", seed, 3, 0), makePriest("Blocked ally", seed, 4), makePriest("Ally without position", seed, 5), ]; usePartyStore.setState({ members: roster }); for (const member of roster) { if (member.name === "Inclusive-radius edge") registerPartyRuntimePosition(member.id, [9, 12, 0]); if (member.name === "Outside-radius ally") registerPartyRuntimePosition(member.id, [15.01, 0, 0]); if (member.name === "Fallen ally") registerPartyRuntimePosition(member.id, [1, 0, 0]); if (member.name === "Blocked ally") registerPartyRuntimePosition(member.id, [3, 0, 0]); if (member.name === "Scout/Priest companion") registerPartyRuntimePosition(member.id, [0, 0, 0]); } registerCombatSpatialProvider({ actor: () => null, hasLineOfSight: (_source, target) => target[0] !== 3, projectGroundPoint: (position) => position, }); useCombatStore.getState().synchronizeActors(); for (const id of [DIRECT_TEST_TARGET, DOT_TEST_TARGET, EXPIRY_TEST_TARGET]) { useCombatStore.getState().registerMob(id, { name: id, level: 1, maxHealth: 1_000_000, xpReward: 0, }); setMobPosition(id, [2, 0, 0]); } return { partyCaster, edge: roster.find((member) => member.name === "Inclusive-radius edge")!, outside: roster.find((member) => member.name === "Outside-radius ally")!, fallen: roster.find((member) => member.name === "Fallen ally")!, blocked: roster.find((member) => member.name === "Blocked ally")!, unpositioned: roster.find((member) => member.name === "Ally without position")!, }; } function applyScreenAttackFromCompanion(casterId: string, now: number): void { const member = usePartyStore.getState().members.find((candidate) => candidate.id === casterId); if (!member || !SCREEN_ATTACK) throw new Error("Missing Scout/Priest companion or Screen Attack"); usePartyStore.setState((state) => ({ members: state.members.map((candidate) => candidate.id === casterId ? { ...candidate, activeCast: { abilityId: SCREEN_ATTACK_ID, school: "physical", mode: "cast", startedAt: now, completesAt: now, originalDurationMs: 0, pushbackHits: 0, origin: [0, 0, 0], targetId: casterId, }, activeCastTargetId: casterId, status: "attacking", } : candidate), })); useCombatStore.getState().synchronizeActors(); const syncedMember = usePartyStore.getState().members.find((candidate) => candidate.id === casterId); const committed = syncedMember ? commitPartyAbilityUse(syncedMember, SCREEN_ATTACK, now) : null; if (!committed) throw new Error("Companion could not commit Screen Attack before casting"); usePartyStore.setState((state) => ({ members: state.members.map((candidate) => candidate.id === casterId ? { ...committed, activeCast: candidate.activeCast, activeCastTargetId: candidate.activeCastTargetId, } : candidate), })); useCombatStore.getState().commitCompanionActors([committed]); advanceDungeonPartyCombat(now); } function directDamage(sourceActorId: string, targetId: string, now: number): number { return useCombatStore.getState().damageMob( targetId, 100, now, { actorId: sourceActorId, role: "damage", abilityId: DIRECT_TEST_ABILITY_ID }, "physical", { attackKind: "spell", seed: "rom-screen-attack-fixed-direct-hit", rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, }, ).amount; } function applyAndResolvePeriodicDamage(sourceActorId: string, targetId: string, now: number): number { if (!SCOUT_PERIODIC_ABILITY) throw new Error("Missing Scout Piercing Arrow test ability"); const testAbility = { ...SCOUT_PERIODIC_ABILITY, id: PERIODIC_TEST_ABILITY_ID }; const effect = { kind: "dot", coefficient: 0, basePoints: 100, school: "physical", ticks: 1, intervalMs: 1_000, tag: "screen-attack-integration-dot", } as const; if (!useCombatStore.getState().applyTimedActorEffect( sourceActorId, targetId, testAbility, effect, now, )) throw new Error("Could not apply the periodic damage test effect"); const pending = useCombatStore.getState().effects.find((candidate) => candidate.kind === "dot" && candidate.sourceAbilityId === PERIODIC_TEST_ABILITY_ID && candidate.targetId === targetId); if (!pending || pending.kind !== "dot") throw new Error("Periodic damage test effect was not scheduled"); const tickAt = pending.nextTickAt; const seconds = Math.max(0, (tickAt - now) / 1_000); useCombatStore.getState().tick(seconds, tickAt); const matchingEvents = useCombatStore.getState().combatEvents.filter((candidate) => candidate.kind === "damage" && candidate.sourceActorId === sourceActorId && candidate.targetActorId === targetId && candidate.abilityId === PERIODIC_TEST_ABILITY_ID); const event = matchingEvents[matchingEvents.length - 1]; return event?.kind === "damage" ? event.effectiveAmount : 0; } function screenAura(targetId: string) { return useCombatStore.getState().auras.find((aura) => aura.targetId === targetId && aura.definition.id === ROM_SCOUT_PRIEST_SCREEN_ATTACK_AURA.id); } describe("CoA Scout/Priest Screen Attack runtime integration", () => { beforeEach(() => { resetPartyRuntimeClock(); resetCombatSpatialProvider(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); useDungeonSimulationStore.setState({ sessionMode: "play" }); }); afterEach(() => resetCombatSpatialProvider()); it("activates for the player, selects only living nearby visible party actors, scales direct and periodic damage, expires, and observes cooldown", () => { if (!SCREEN_ATTACK) throw new Error("Screen Attack (499555) is missing from the generated catalog"); if (!SCOUT_PERIODIC_ABILITY) throw new Error("Scout periodic fixture ability is missing"); const recipients = configurePlayerAndParty("screen-attack-player-runtime"); const baselineDirect = directDamage(PLAYER_AGGRO_ID, DIRECT_TEST_TARGET, 1_000); const baselinePeriodic = applyAndResolvePeriodicDamage(PLAYER_AGGRO_ID, DOT_TEST_TARGET, 1_000); expect(baselineDirect).toBeGreaterThan(0); expect(baselinePeriodic).toBeGreaterThan(0); expect(useCombatStore.getState().castAbility(SCREEN_ATTACK.id, undefined, 3_000)).toMatchObject({ ok: true }); expect(screenAura(PLAYER_AGGRO_ID)?.expiresAt).toBe(23_000); expect(screenAura(recipients.edge.id)?.expiresAt).toBe(23_000); expect(screenAura(recipients.outside.id)).toBeUndefined(); expect(screenAura(recipients.fallen.id)).toBeUndefined(); expect(screenAura(recipients.blocked.id)).toBeUndefined(); expect(screenAura(recipients.unpositioned.id)).toBeUndefined(); const buffedDirect = directDamage(PLAYER_AGGRO_ID, DIRECT_TEST_TARGET, 4_000); const buffedPeriodic = applyAndResolvePeriodicDamage(PLAYER_AGGRO_ID, DOT_TEST_TARGET, 4_000); expect(buffedDirect / baselineDirect).toBeCloseTo(1 + 0.174, 1); expect(buffedPeriodic / baselinePeriodic).toBeCloseTo(1 + 0.174, 1); const beforeExpiry = directDamage(PLAYER_AGGRO_ID, EXPIRY_TEST_TARGET, 22_999); const atExpiry = directDamage(PLAYER_AGGRO_ID, EXPIRY_TEST_TARGET, 23_000); expect(beforeExpiry / baselineDirect).toBeCloseTo(1 + 0.174, 1); expect(atExpiry).toBe(baselineDirect); expect(useCombatStore.getState().cooldowns[SCREEN_ATTACK.id]).toBe(63_000); expect(useCombatStore.getState().castAbility(SCREEN_ATTACK.id, undefined, 62_999)).toMatchObject({ ok: false, reason: "cooldown", }); expect(useCombatStore.getState().castAbility(SCREEN_ATTACK.id, undefined, 63_000)).toMatchObject({ ok: true }); }); it("activates for a generated Scout/Priest companion and applies its own direct and periodic damage bonuses to eligible recipients", () => { if (!SCOUT_PERIODIC_ABILITY) throw new Error("Scout periodic fixture ability is missing"); const recipients = configurePlayerAndParty("screen-attack-companion-runtime", true); const casterId = recipients.partyCaster?.id; if (!casterId) throw new Error("Missing generated Scout/Priest caster"); const baselineDirect = directDamage(casterId, DIRECT_TEST_TARGET, 1_000); const baselinePeriodic = applyAndResolvePeriodicDamage(casterId, DOT_TEST_TARGET, 1_000); expect(baselineDirect).toBeGreaterThan(0); expect(baselinePeriodic).toBeGreaterThan(0); applyScreenAttackFromCompanion(casterId, 3_000); expect(screenAura(casterId)?.expiresAt).toBe(23_000); expect(screenAura(PLAYER_AGGRO_ID)?.expiresAt).toBe(23_000); expect(screenAura(recipients.edge.id)?.expiresAt).toBe(23_000); expect(screenAura(recipients.outside.id)).toBeUndefined(); expect(screenAura(recipients.fallen.id)).toBeUndefined(); expect(screenAura(recipients.blocked.id)).toBeUndefined(); expect(screenAura(recipients.unpositioned.id)).toBeUndefined(); const buffedDirect = directDamage(casterId, DIRECT_TEST_TARGET, 4_000); const buffedPeriodic = applyAndResolvePeriodicDamage(casterId, DOT_TEST_TARGET, 4_000); expect(buffedDirect / baselineDirect).toBeCloseTo(1 + 0.174, 1); expect(buffedPeriodic / baselinePeriodic).toBeCloseTo(1 + 0.174, 1); const beforeExpiry = directDamage(casterId, EXPIRY_TEST_TARGET, 22_999); const atExpiry = directDamage(casterId, EXPIRY_TEST_TARGET, 23_000); expect(beforeExpiry / baselineDirect).toBeCloseTo(1 + 0.174, 1); expect(atExpiry).toBe(baselineDirect); const committedCaster = usePartyStore.getState().members.find((member) => member.id === casterId); expect(committedCaster?.cooldowns[SCREEN_ATTACK_ID]).toBe(63_000); if (!committedCaster || !SCREEN_ATTACK) throw new Error("Screen Attack companion cooldown was not committed"); expect(partyAbilityIsReady(committedCaster, SCREEN_ATTACK, 62_999)).toBe(false); expect(commitPartyAbilityUse(committedCaster, SCREEN_ATTACK, 62_999)).toBeNull(); expect(partyAbilityIsReady(committedCaster, SCREEN_ATTACK, 63_000)).toBe(true); expect(commitPartyAbilityUse(committedCaster, SCREEN_ATTACK, 63_000)?.cooldowns[SCREEN_ATTACK_ID]) .toBe(123_000); }); });