226 lines
13 KiB
TypeScript
226 lines
13 KiB
TypeScript
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<string>();
|
|
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<string>();
|
|
const reasons = new Set<string>();
|
|
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<string>();
|
|
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<string, PartyMember>();
|
|
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<string>();
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
});
|