import { beforeEach, describe, expect, it } from "vitest"; import { abilitiesForClass, learnedAbilityForBuild, type AbilityDefinition } from "./abilityCatalog"; import { aiHintsForAbility } from "./abilityRules"; import { useCombatStore } from "./combatStore"; import { applyCoaGuardianParagonEvent, COA_GUARDIAN_PARAGON_TALENT_ENTRY_ID, projectCoaGuardianParagonAbility, } from "./coaGuardianParagonRuntime"; import { choosePartyAbility, partyAbilityIsReady } from "./partyAbilityAi"; import { evaluateCombatRotation } from "./combatRotation"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime"; import type { ActiveAura } from "./combatAuras"; const RANKS = { [`coa-entry-${COA_GUARDIAN_PARAGON_TALENT_ENTRY_ID}`]: 1, "coa-entry-31169": 1, "coa-entry-30057": 1, }; function readyAuras(actorId: string, talentRanks: typeof RANKS, now: number): readonly ActiveAura[] { const actor = { id: actorId, classId: "guardian" as const, talentRanks }; const heavyBlow = abilitiesForClass("guardian").find((ability) => ability.talentEntryId === 31169)!; let auras: readonly ActiveAura[] = []; for (let cast = 0; cast < 3; cast += 1) { auras = applyCoaGuardianParagonEvent(auras, actor, { type: "cast", ability: heavyBlow, result: "success", }, now - 3_000 + cast * 1_000).auras; } return applyCoaGuardianParagonEvent(auras, actor, { type: "block" }, now).auras; } function guardian() { const member = createConfiguredPartyMember({ name: "Paragon AI Guardian", classId: "guardian", specialization: "Vanguard", specializationId: "guardian:vanguard", talentTreeId: "coa-guardian-87", talentRanks: RANKS, role: "tank", }, 30, 0, "coa-paragon-ai"); return { ...member, talentRanks: { ...member.talentRanks, ...RANKS }, nextActionAt: 0 }; } function install(member: ReturnType, now: number) { useCombatStore.getState().initializeCharacter({ classId: "guardian", role: "tank", level: 30 }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); usePartyStore.getState().initializeParty("coa-paragon-ai", 30, useCombatStore.getState().health, "tank", 1, "coa"); usePartyStore.setState({ members: [member], command: "attack", activeMobId: "coa-paragon-ai-target" }); registerPartyRuntimePosition(member.id, [0, 0, 0]); useCombatStore.getState().synchronizeActors(); useCombatStore.setState({ auras: [...readyAuras(member.id, member.talentRanks, now)] }); useCombatStore.getState().registerMob("coa-paragon-ai-target", { name: "Paragon AI target", level: 30, maxHealth: 100_000, xpReward: 0, }); setMobPosition("coa-paragon-ai-target", [2, 0, 0]); } function target() { return useCombatStore.getState().mobs["coa-paragon-ai-target"]!; } beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); }); describe("CoA Guardian Paragon companion choice", () => { it("beats a ready routine Ballad upkeep action for a healthy Vanguard tank", () => { const member = guardian(); const now = 10_000; install(member, now); const ballad = abilitiesForClass("guardian") .map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks)) .find((ability) => ability?.name === "Ballad of the Conqueror"); expect(ballad, "Ballad of the Conqueror must be an ordinary learned candidate").toBeDefined(); expect(partyAbilityIsReady(member, ballad!, now)).toBe(true); const chosen = choosePartyAbility(member, { now, target: target(), friendlyTargets: [{ id: member.id, health: member.maxHealth, maxHealth: member.maxHealth, role: "tank" }], hostileCount: 1, }); expect(chosen?.name).toBe("Paragon Smash"); expect(chosen).toMatchObject({ coaGuardianParagonVariant: "paragon-smash" }); }); it("leaves low-health mitigation ahead of Smash", () => { const full = guardian(); const now = 10_000; install(full, now); // Prepare Tower normally so the scorer evaluates Brace against the tank's // active formation rather than choosing setup upkeep first. advanceDungeonPartyCombat(now); const prepared = usePartyStore.getState().members.find((member) => member.id === full.id)!; const injured = { ...prepared, health: Math.floor(prepared.maxHealth * 0.5), nextActionAt: now }; usePartyStore.setState({ members: [injured] }); useCombatStore.getState().commitCompanionActors([injured]); const liveAuras = useCombatStore.getState().auras.filter((aura) => ( aura.sourceId === injured.id && (aura.expiresAt === null || aura.expiresAt > now) )); const activeAuraExclusiveGroups = new Set(liveAuras .flatMap((aura) => aura.definition.exclusiveGroup ? [aura.definition.exclusiveGroup] : [])); const chosen = choosePartyAbility(injured, { now, target: target(), friendlyTargets: [{ id: injured.id, health: injured.health, maxHealth: injured.maxHealth, role: "tank" }], hostileCount: 1, activeAuraExclusiveGroups, }); expect(chosen).toBeDefined(); expect(chosen?.name).not.toBe("Paragon Smash"); expect(aiHintsForAbility(chosen!).categories).toContain("mitigation"); }); it("keeps urgent reactive taunt and interrupt ahead of the authored priority", () => { const member = guardian(); const now = 10_000; install(member, now); const heavyBlow = abilitiesForClass("guardian").find((ability) => ability.talentEntryId === 31169)!; const actor = { id: member.id, classId: member.classId, talentRanks: member.talentRanks, role: member.role, specializationId: member.specializationId, health: member.health, maxHealth: member.maxHealth, }; const smash = projectCoaGuardianParagonAbility( heavyBlow, actor, readyAuras(member.id, member.talentRanks, now), now, ); const synthetic = (name: string, kind: "taunt" | "interrupt"): AbilityDefinition => ({ ...heavyBlow, id: `test:paragon-priority-${kind}`, name, ai: { categories: [kind] }, effects: kind === "taunt" ? [{ kind: "taunt", durationMs: 3_000 }] : [{ kind: "interrupt", lockoutMs: 3_000 }], }); const base = { now, classId: "guardian" as const, specializationId: "guardian:vanguard", role: "tank" as const, level: member.level, health: member.health, maxHealth: member.maxHealth, selfId: member.id, resources: { energy: 100 }, maximumResources: { energy: 100 }, primaryResource: "energy" as const, comboPoints: 0, comboTargetId: null, runes: [], hostileCount: 1, canUseAbility: () => true, }; const taunt = synthetic("Taunt", "taunt"); const urgentTaunt = evaluateCombatRotation({ ...base, abilities: [smash, taunt], hostileTarget: { id: "loose-target", health: 1_000, maxHealth: 1_000 }, targetIsLoose: true, }); expect(urgentTaunt?.ability).toBe(taunt); const interrupt = synthetic("Interrupt", "interrupt"); const urgentInterrupt = evaluateCombatRotation({ ...base, abilities: [smash, interrupt], hostileTarget: { id: "casting-target", health: 1_000, maxHealth: 1_000, activeCast: { interruptible: true }, }, }); expect(urgentInterrupt?.ability).toBe(interrupt); }); });