import { beforeEach, describe, expect, it } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { stableCombatUnit, useCombatStore } from "./combatStore"; import { DEFAULT_ENEMY_ATTACK } from "./enemyAttacks"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { ROM_ALL_ABILITIES } from "./romAbilityCatalog"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore"; import { ROM_KNIGHT_PANTHERS_BLESSING_ABILITY_ID, ROM_KNIGHT_PANTHERS_BLESSING_SPELL_ID, } from "./romKnightPanthersBlessingRuntime"; const PANTHER = ROM_ALL_ABILITIES.find((ability) => ( ability.source === "runewaker" && ability.classId === "rom-knight" && ability.secondaryClassId === "rom-rogue" && ability.dbcSpellId === ROM_KNIGHT_PANTHERS_BLESSING_SPELL_ID ))!; const HIGH_DODGE_RATING = 1_000_000; function initializePlayer(level = 80): void { useCombatStore.getState().initializeCharacter({ classId: "rom-knight", secondaryClassId: "rom-rogue", raceId: "rom-human", level, }); useCombatStore.getState().setPlayerPosition([0, 0, 0]); usePartyStore.getState().initializeParty( `panther-player-${level}`, level, useCombatStore.getState().health, "damage", 1, "rom", ); } function createKnightRogueCompanion(level: number) { return createConfiguredPartyMember({ name: "Knight/Rogue Panther's Blessing test companion", classId: "rom-knight", secondaryClassId: "rom-rogue", raceId: "rom-human", specialization: "Knight/Rogue", specializationId: "rom-knight:rom-rogue", talentTreeId: "rom-knight:rom-rogue", role: "damage", }, level, 0, `panther-companion-${level}`); } function preparePlayerPools(healthMissing: number, manaMissing: number): { readonly maxHealth: number; readonly maxMana: number; readonly startHealth: number; readonly startMana: number; } { useCombatStore.setState((state) => { const maxMana = state.maxResourcePools.mana ?? 0; const startMana = Math.max(0, maxMana - manaMissing); return { gearStats: { ...state.gearStats, dodgeRating: HIGH_DODGE_RATING }, health: Math.max(1, state.maxHealth - healthMissing), resource: state.classId === "rom-knight" ? startMana : state.resource, resourcePools: { ...state.resourcePools, mana: startMana }, }; }); useCombatStore.getState().synchronizeActors(); const state = useCombatStore.getState(); return { maxHealth: state.maxHealth, maxMana: state.maxResourcePools.mana ?? 0, startHealth: state.health, startMana: state.resourcePools.mana ?? 0, }; } function prepareCompanionPools(memberId: string, healthMissing: number, manaMissing: number): { readonly maxHealth: number; readonly maxMana: number; readonly startHealth: number; readonly startMana: number; } { usePartyStore.setState((state) => ({ members: state.members.map((member) => { if (member.id !== memberId) return member; const maxMana = member.resourcePools.mana === undefined ? (member.resourceType === "mana" ? member.maxResource : 0) : member.resourceType === "mana" ? member.maxResource : 100; const startMana = Math.max(0, maxMana - manaMissing); const gearStats = { ...member.gearStats, dodgeRating: HIGH_DODGE_RATING }; return { ...member, gearStats, health: Math.max(1, member.maxHealth - healthMissing), resource: member.resourceType === "mana" ? startMana : member.resource, resourcePools: { ...member.resourcePools, mana: startMana }, }; }), })); useCombatStore.getState().synchronizeActors(); const member = usePartyStore.getState().members.find((candidate) => candidate.id === memberId)!; return { maxHealth: member.maxHealth, maxMana: member.resourcePools.mana === undefined ? (member.resourceType === "mana" ? member.maxResource : 0) : member.resourceType === "mana" ? member.maxResource : 100, startHealth: member.health, startMana: member.resourcePools.mana ?? 0, }; } function createIncomingAttack(attackId: string, rangedSpell = false) { return { ...DEFAULT_ENEMY_ATTACK, id: attackId, name: rangedSpell ? "Non-dodge spell probe" : "Dodge probe", ...(rangedSpell ? { delivery: "projectile" as const, school: "fire" as const, animation: "cast" as const } : {}), cooldownMs: 1_000, repeatCooldownMs: [1_000, 1_000] as const, initialCooldownMs: [450, 450] as const, }; } function engageMobAgainstActor( mobId: string, attackId: string, actorId: string, level: number, engageAt = 1_000, rangedSpell = false, ): void { useCombatStore.getState().registerMob(mobId, { name: mobId, level, maxHealth: 100_000, xpReward: 0, attacks: [createIncomingAttack(attackId, rangedSpell)], }); setMobPosition(mobId, [2, 0, 0]); useCombatStore.getState().damageMob(mobId, 1, engageAt, { actorId, role: "damage", abilityId: "panther-dodge-integration-probe", }, "physical", { attackKind: "melee", seed: `${mobId}:${actorId}:engage`, rules: { canMiss: false, canCrit: false, canDodge: false, canParry: false, canBlock: false }, }); } function incomingTimeForRoll( mobId: string, attackId: string, actorId: string, minimumRoll: number, firstReadyAt = 1_450, ): number { for (let now = firstReadyAt; now < 100_000; now += 1) { if (stableCombatUnit(`${mobId}:${attackId}:${actorId}:0:${now}:outcome`) >= minimumRoll) return now; } throw new Error("Could not find a deterministic incoming attack outcome for this test."); } function incomingEvent(mobId: string, actorId: string, at: number) { return useCombatStore.getState().combatEvents.find((event) => ( event.kind === "damage" && event.sourceActorId === mobId && event.targetActorId === actorId && event.occurredAt === at )); } function pantherEvents(actorId: string) { return useCombatStore.getState().combatEvents.filter((event) => ( event.abilityId === ROM_KNIGHT_PANTHERS_BLESSING_ABILITY_ID && event.sourceActorId === actorId && event.targetActorId === actorId )); } describe("CoA Knight/Rogue Panther's Blessing dodge integration", () => { beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); initializePlayer(); }); it("restores the player's capped 10% max HP and 1% max MP after a resolved dodge", () => { const maximum = useCombatStore.getState().maxHealth; const missingHealth = 50; const pools = preparePlayerPools(missingHealth, 1); expect(pools.maxMana).toBeGreaterThan(1); engageMobAgainstActor("panther-player-dodge", "panther-player-attack", PLAYER_AGGRO_ID, 80); const now = incomingTimeForRoll("panther-player-dodge", "panther-player-attack", PLAYER_AGGRO_ID, 0.05); useCombatStore.getState().advanceMobCombat(now); expect(incomingEvent("panther-player-dodge", PLAYER_AGGRO_ID, now)?.outcome).toBe("dodge"); expect(useCombatStore.getState().health).toBe(maximum); expect(useCombatStore.getState().resourcePools.mana).toBe(pools.maxMana); expect(useCombatStore.getState().actors[PLAYER_AGGRO_ID]).toMatchObject({ health: maximum, resources: { mana: { current: pools.maxMana, maximum: pools.maxMana } }, }); const healing = pantherEvents(PLAYER_AGGRO_ID).find((event) => event.kind === "healing"); const mana = pantherEvents(PLAYER_AGGRO_ID).find((event) => event.kind === "resource"); expect(healing).toMatchObject({ rawAmount: Math.round(maximum * 0.1), effectiveAmount: missingHealth, overheal: Math.round(maximum * 0.1) - missingHealth, }); expect(mana).toMatchObject({ rawAmount: Math.round(pools.maxMana * 0.01), effectiveAmount: 1, overheal: Math.round(pools.maxMana * 0.01) - 1, }); }); it("restores and mirrors a generated Knight/Rogue companion's own HP and mana pools", () => { const member = createKnightRogueCompanion(80); usePartyStore.setState({ members: [member] }); registerPartyRuntimePosition(member.id, [0, 0, 0]); const pools = prepareCompanionPools(member.id, 1_000, 10); expect(pools.maxMana).toBeGreaterThan(10); engageMobAgainstActor("panther-companion-dodge", "panther-companion-attack", member.id, 80); const now = incomingTimeForRoll("panther-companion-dodge", "panther-companion-attack", member.id, 0.05); useCombatStore.getState().advanceMobCombat(now); expect(incomingEvent("panther-companion-dodge", member.id, now)?.outcome).toBe("dodge"); const expectedHealth = Math.min(pools.maxHealth, pools.startHealth + Math.round(pools.maxHealth * 0.1)); const expectedMana = Math.min(pools.maxMana, pools.startMana + Math.round(pools.maxMana * 0.01)); expect(usePartyStore.getState().members[0]).toMatchObject({ health: expectedHealth, resourcePools: { mana: expectedMana }, resource: expectedMana, }); expect(useCombatStore.getState().actors[member.id]).toMatchObject({ health: expectedHealth, resources: { mana: { current: expectedMana, maximum: pools.maxMana } }, }); expect(pantherEvents(member.id)).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "healing", rawAmount: Math.round(pools.maxHealth * 0.1) }), expect.objectContaining({ kind: "resource", rawAmount: Math.round(pools.maxMana * 0.01) }), ])); }); it("keeps an exact-pair player below level 45 out of the proc", () => { initializePlayer(44); const pools = preparePlayerPools(500, 10); engageMobAgainstActor("panther-unlearned-player", "panther-unlearned-player-attack", PLAYER_AGGRO_ID, 44); const now = incomingTimeForRoll("panther-unlearned-player", "panther-unlearned-player-attack", PLAYER_AGGRO_ID, 0.05); useCombatStore.getState().advanceMobCombat(now); expect(incomingEvent("panther-unlearned-player", PLAYER_AGGRO_ID, now)?.outcome).toBe("dodge"); expect(useCombatStore.getState().health).toBe(pools.startHealth); expect(useCombatStore.getState().resourcePools.mana).toBe(pools.startMana); expect(pantherEvents(PLAYER_AGGRO_ID)).toEqual([]); }); it("keeps an exact-pair companion below level 45 out of the proc", () => { initializePlayer(44); const member = createKnightRogueCompanion(44); usePartyStore.setState({ members: [member] }); registerPartyRuntimePosition(member.id, [0, 0, 0]); const pools = prepareCompanionPools(member.id, 500, 10); engageMobAgainstActor("panther-unlearned-companion", "panther-unlearned-companion-attack", member.id, 44); const now = incomingTimeForRoll("panther-unlearned-companion", "panther-unlearned-companion-attack", member.id, 0.05); useCombatStore.getState().advanceMobCombat(now); expect(incomingEvent("panther-unlearned-companion", member.id, now)?.outcome).toBe("dodge"); expect(usePartyStore.getState().members[0]?.health).toBe(pools.startHealth); expect(usePartyStore.getState().members[0]?.resourcePools.mana).toBe(pools.startMana); expect(pantherEvents(member.id)).toEqual([]); }); it("does not invent a dodge for direct damage or a nondodgeable spell hit", () => { const pools = preparePlayerPools(1_000, 10); const directDamage = useCombatStore.getState().damagePlayer(1, "physical"); expect(useCombatStore.getState().health).toBe(pools.startHealth - directDamage); expect(useCombatStore.getState().resourcePools.mana).toBe(pools.startMana); expect(pantherEvents(PLAYER_AGGRO_ID)).toEqual([]); engageMobAgainstActor( "panther-spell-hit", "panther-spell-attack", PLAYER_AGGRO_ID, 80, 2_000, true, ); const now = incomingTimeForRoll("panther-spell-hit", "panther-spell-attack", PLAYER_AGGRO_ID, 0.1, 2_450); useCombatStore.getState().advanceMobCombat(now); expect(incomingEvent("panther-spell-hit", PLAYER_AGGRO_ID, now)?.outcome).not.toBe("dodge"); expect(pantherEvents(PLAYER_AGGRO_ID)).toEqual([]); expect(PANTHER).toMatchObject({ name: "Panther's Blessing", unlockLevel: 45, passive: true }); }); });