import { afterAll, describe, expect, it } from "vitest"; import { mkdirSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { COA_CLASS_IDS, ROM_CLASS_IDS, PLAYABLE_RACES, classesForRace, type ClassId } from "../app/characterCatalog"; import { createDungeonParty, usePartyStore, type PartyMember } from "./partyStore"; import { useCombatStore } from "./combatStore"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry"; import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime"; import { subscribeCombatPresentation } from "./combatPresentation"; import { subscribeCombatEvents } from "./combatEvents"; import { abilitiesForClass, resourceProfileForClass } from "./abilityCatalog"; import { choosePartyAbility } from "./partyAbilityAi"; const auditReport: unknown[] = []; afterAll(() => { if (process.env.CLASS_DAMAGE_AUDIT_REPORT !== "1") return; const directory = resolve("artifacts/class-damage-audit"); mkdirSync(directory, { recursive: true }); writeFileSync(resolve(directory, "report.json"), `${JSON.stringify({ durationSeconds: 30, results: auditReport }, null, 2)}\n`); }); function initializeAudit(classId: ClassId, level: number, template?: PartyMember): PartyMember { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); const raceId = PLAYABLE_RACES.find((race) => classesForRace(race.id).some((entry) => entry.id === classId))!.id; useCombatStore.getState().initializeCharacter({ classId, raceId, level }); const combat = useCombatStore.getState(); const profile = resourceProfileForClass(classId); const base = createDungeonParty("expanded-damage-audit", level)[0]; const member: PartyMember = template ?? { ...base, id: `audit-${classId}`, name: classId, classId, raceId, level, role: "damage", specialization: "Primary", specializationId: null, talentTreeId: "", talentRanks: {}, health: combat.maxHealth, maxHealth: combat.maxHealth, gearStats: combat.gearStats, resourceType: profile.type, resourceName: combat.resourceName, resource: combat.resource, maxResource: combat.maxResource, resourcePools: combat.resourcePools, runes: combat.deathKnightRunes }; usePartyStore.getState().initializeParty("expanded-damage-audit", level, combat.health); usePartyStore.setState({ members: [member] }); useCombatStore.getState().synchronizeActors(); registerPartyRuntimePosition(member.id, [2 - partyCombatRangeBand(member).preferred, 0, 0]); useCombatStore.getState().registerMob("audit", { name: "Training target", maxHealth: 10_000_000, xpReward: 0 }); setMobPosition("audit", [2, 0, 0]); useCombatStore.getState().damageMob("audit", 1, 100); updatePartyRuntimeTargetVisibility(member.id, "audit", true); return member; } describe("expanded class damage audit", () => { for (const control of ["NPC", "player"] as const) { it(`${control} Radiant Flame deals five pulses during the channel without repeating them on completion`, () => { const member = initializeAudit("sun-cleric", 20); const ability = abilitiesForClass(member.classId).find((candidate) => candidate.name === "Radiant Flame")!; const damageEvents: number[] = []; if (control === "NPC") { usePartyStore.setState({ members: [{ ...member, cooldowns: Object.fromEntries(abilitiesForClass(member.classId) .filter((candidate) => candidate.id !== ability.id).map((candidate) => [candidate.id, 60_000])) }] }); useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members); } else { usePartyStore.setState({ members: [], selectedMemberId: null, selfSelected: false }); useCombatStore.getState().synchronizeActors(); useCombatStore.getState().selectMob("audit"); } const stop = subscribeCombatEvents((event) => { if (event.kind === "damage" && event.targetActorId === "audit" && event.abilityId === ability.id) damageEvents.push(event.effectiveAmount); }); const advance = (now: number) => { useCombatStore.getState().tick(0.1, now); if (control === "NPC") advanceDungeonPartyCombat(now); }; if (control === "player") expect(useCombatStore.getState().castAbility(ability.id, undefined, 1_000).ok).toBe(true); advance(1_000); advance(1_700); expect(damageEvents).toHaveLength(0); advance(1_800); expect(damageEvents).toHaveLength(1); expect(damageEvents[0]).toBeGreaterThan(0); for (const now of [2_600, 3_400, 4_200, 5_000]) advance(now); expect(damageEvents).toHaveLength(5); usePartyStore.getState().setCommand("stop"); advance(5_100); advance(6_000); expect(damageEvents).toHaveLength(5); stop(); }); } for (const [mode, classes, levels] of [["CoA", COA_CLASS_IDS, [1, 2, 4, 9, 20, 80]], ["RoM", ROM_CLASS_IDS, [1, 20, 70]]] as const) { for (const level of levels) { it(`${mode} NPCs execute damaging abilities at level ${level}`, () => { const report: unknown[] = []; const failures: string[] = []; for (const classId of classes) { const member = initializeAudit(classId, level); const releases = new Set(); let damage = 0; let abilityDamage = 0; const stopEvents = subscribeCombatEvents((event) => { const actor = useCombatStore.getState().actors[event.sourceActorId]; if (event.kind === "damage" && event.targetActorId === "audit" && (event.sourceActorId === member.id || actor?.ownerId === member.id)) { damage += event.effectiveAmount; if (event.abilityId !== "party-auto-attack") abilityDamage += event.effectiveAmount; } }); const stopPresentation = subscribeCombatPresentation((message) => { if (message.kind === "event" && message.event.sourceActorId === member.id && message.event.phase === "release") releases.add(message.event.name); }); for (let now = 1_000; now <= 31_000; now += 100) { useCombatStore.getState().tick(0.1, now); advanceDungeonPartyCombat(now); } stopEvents(); stopPresentation(); expect(useCombatStore.getState().mobs.audit.dead, `${classId} level ${level} must sustain the full audit`).toBe(false); report.push({ classId, damage, abilityDamage, releases: [...releases], resource: usePartyStore.getState().members[0].resource }); auditReport.push({ mode, control: "NPC", classId, level, damage, abilityDamage, abilities: [...releases] }); const hasDamageAbility = abilitiesForClass(classId).some((ability) => !ability.passive && ability.talentEntryId === undefined && ability.unlockLevel <= level && ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute", "summon"].includes(effect.kind))); if (damage <= 1 || (hasDamageAbility && abilityDamage <= 0)) failures.push(classId); } expect(failures, JSON.stringify(report)).toEqual([]); }); } for (const level of levels) { it(`${mode} players execute damaging abilities at level ${level}`, () => { const failures: unknown[] = []; for (const classId of classes) { const member = initializeAudit(classId, level); usePartyStore.setState({ members: [], selectedMemberId: null, selfSelected: false }); useCombatStore.getState().synchronizeActors(); useCombatStore.getState().setPlayerPosition([0, 0, 0]); setMobPosition("audit", [partyCombatRangeBand(member).preferred, 0, 0]); useCombatStore.getState().selectMob("audit"); const abilities = abilitiesForClass(classId); const harmful = (ability: (typeof abilities)[number]) => !ability.passive && ability.talentEntryId === undefined && ability.effects.some((effect) => ["damage", "dot", "finisher-damage", "execute", "summon"].includes(effect.kind)); const firstAttack = abilities.filter(harmful).sort((left, right) => left.unlockLevel - right.unlockLevel)[0]; let damage = 0; let abilityDamage = 0; const casts = new Set(); const reasons = new Set(); const stop = subscribeCombatEvents((event) => { if (event.kind === "damage" && event.targetActorId === "audit") { damage += event.effectiveAmount; if (event.abilityId && !event.abilityId.includes("auto-attack")) abilityDamage += event.effectiveAmount; } }); const kickoff = firstAttack ? useCombatStore.getState().castAbility(firstAttack.id, undefined, 1_000) : null; if (kickoff?.ok && firstAttack) casts.add(firstAttack.name); for (let now = 1_000; now <= 31_000; now += 100) { useCombatStore.getState().tick(0.1, now); const combat = useCombatStore.getState(); if (combat.activeCast) continue; const rejected = new Set(); const playerMember: PartyMember = { ...member, resource: combat.resource, resourcePools: combat.resourcePools, cooldowns: combat.cooldowns, globalCooldownEndsAt: combat.globalCooldownEndsAt, runes: combat.deathKnightRunes }; for (let attempts = 0; attempts < abilities.length; attempts += 1) { const ability = choosePartyAbility(playerMember, { now, target: combat.mobs.audit, canUseAbility: (candidate) => harmful(candidate) && !rejected.has(candidate.id) && !combat.effects.some((effect) => effect.sourceAbilityId === candidate.id && effect.remainingTicks > 0) && !candidate.effects.some((effect) => effect.kind === "apply-aura" && combat.auras.some((aura) => aura.definition.id === effect.aura.id && aura.targetId === "audit")) }); if (!ability) break; const result = useCombatStore.getState().castAbility(ability.id, undefined, now); if (result.ok) { casts.add(ability.name); break; } rejected.add(ability.id); if (result.reason) reasons.add(result.reason); } } stop(); expect(useCombatStore.getState().mobs.audit.dead, `${classId} level ${level} must sustain the full audit`).toBe(false); const hasAttack = abilities.some((ability) => harmful(ability) && ability.unlockLevel <= level); auditReport.push({ mode, control: "player", classId, level, damage, abilityDamage, abilities: [...casts], firstOffensiveAbilityLevel: firstAttack?.unlockLevel ?? null, failureReasons: [...reasons] }); if (hasAttack && abilityDamage <= 0) failures.push({ classId, damage, abilityDamage, casts: [...casts], reasons: [...reasons], kickoff, firstAttack: firstAttack?.name, autoAttackTargetId: useCombatStore.getState().autoAttackTargetId }); } expect(failures).toEqual([]); }); } } for (const level of [1, 20, 70]) { it(`actual RoM companion templates deal ability damage at level ${level}`, () => { const templates = new Map(); for (let seed = 0; seed < 100; seed += 1) { for (const role of ["healer", "damage"] as const) { for (const member of createDungeonParty(`rom-audit-${seed}`, level, role, 5, "rom")) { templates.set(`${member.classId}:${member.role}`, member); } } } expect(new Set([...templates.values()].map((member) => member.classId))).toEqual(new Set(ROM_CLASS_IDS)); for (const template of templates.values()) { const member = initializeAudit(template.classId, level, template); let damage = 0; let abilityDamage = 0; const abilities = new Set(); const stop = subscribeCombatEvents((event) => { const actor = useCombatStore.getState().actors[event.sourceActorId]; if (event.kind !== "damage" || event.targetActorId !== "audit" || (event.sourceActorId !== member.id && actor?.ownerId !== member.id)) return; damage += event.effectiveAmount; if (event.abilityId !== "party-auto-attack") { abilityDamage += event.effectiveAmount; if (event.abilityId) abilities.add(event.abilityId); } }); for (let now = 1_000; now <= 31_000; now += 100) { useCombatStore.getState().tick(0.1, now); advanceDungeonPartyCombat(now); } stop(); auditReport.push({ mode: "RoM", control: "shipped NPC", classId: member.classId, role: member.role, level, damage, abilityDamage, abilities: [...abilities] }); expect(abilityDamage, `${member.classId} ${member.role} level ${level}`).toBeGreaterThan(0); const finalMember = usePartyStore.getState().members[0]; expect(finalMember.resource).toBeGreaterThanOrEqual(0); expect(finalMember.resource).toBeLessThanOrEqual(finalMember.maxResource); } }); } });