forked from phenom/healer-man
220 lines
11 KiB
TypeScript
220 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore";
|
|
import { applyAura } from "./combatAuras";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
|
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
|
import { deriveCharacterStats, hastedDuration } from "./wotlkStats";
|
|
import { abilityAtLevel, abilitiesForCharacter } from "./abilityCatalog";
|
|
import { nativeDamageCasterAmount, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
|
|
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
|
|
|
|
const TARGET_HP = 10_000_000;
|
|
|
|
function registerTarget(id: string, x = 2): void {
|
|
useCombatStore.getState().registerMob(id, { name: id, level: 1, maxHealth: TARGET_HP, xpReward: 0 });
|
|
setMobPosition(id, [x, 0, 0]);
|
|
}
|
|
|
|
function giveSemanticHaste(targetId: string, stat: "melee-haste" | "ranged-haste", value = 50): void {
|
|
const combat = useCombatStore.getState();
|
|
const result = applyAura(combat.auras, {
|
|
definition: {
|
|
id: `rom-auto-test-${stat}`,
|
|
name: "Test source haste",
|
|
disposition: "buff",
|
|
durationMs: null,
|
|
modifiers: [{ kind: "stat", stat, operation: "flat", value }],
|
|
},
|
|
sourceId: targetId,
|
|
targetId,
|
|
now: 1_000,
|
|
});
|
|
useCombatStore.setState({ auras: result.auras });
|
|
}
|
|
|
|
function playerNativeAuto(slot: "main-hand" | "off-hand" | "ranged") {
|
|
const state = useCombatStore.getState();
|
|
const stats = deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId,
|
|
{}, state.secondaryClassId, state.gender, state.statRuleset);
|
|
const profile = nativeTrainingAutoAttackProfile(stats.romNative, slot);
|
|
if (!profile) throw new Error(`Missing native ${slot} training profile: ${JSON.stringify(stats.romNative?.sourceInputs)}`);
|
|
return profile;
|
|
}
|
|
|
|
function partyNativeAuto(member: ReturnType<typeof createConfiguredPartyMember>, slot: "main-hand" | "off-hand" | "ranged") {
|
|
const stats = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId,
|
|
{}, member.secondaryClassId, member.gender, member.statRuleset);
|
|
const profile = nativeTrainingAutoAttackProfile(stats.romNative, slot);
|
|
if (!profile) throw new Error(`Missing native ${slot} companion training profile.`);
|
|
return profile;
|
|
}
|
|
|
|
function setSingleAttackingPartyMember(member: ReturnType<typeof createConfiguredPartyMember>, targetId: string, seed: string): void {
|
|
usePartyStore.getState().initializeParty(seed, member.level, useCombatStore.getState().health, "damage", 2);
|
|
usePartyStore.setState({
|
|
members: [{ ...member, status: "attacking", nextActionAt: 100_000 }],
|
|
command: "attack",
|
|
activeMobId: targetId,
|
|
});
|
|
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
|
useCombatStore.getState().synchronizeActors();
|
|
useCombatStore.getState().damageMob(targetId, 1, 100, { actorId: PLAYER_AURA_ENTITY_ID, role: "damage" });
|
|
updatePartyRuntimeTargetVisibility(member.id, targetId, true);
|
|
}
|
|
|
|
function autoDamageEvents(sourceActorId: string, abilityId: string) {
|
|
return useCombatStore.getState().combatEvents.filter((event) => (
|
|
event.kind === "damage" && event.sourceActorId === sourceActorId && event.abilityId === abilityId
|
|
));
|
|
}
|
|
|
|
function playerAutoEvents(targetId: string) {
|
|
return useCombatStore.getState().combatEvents.filter((event) => (
|
|
event.kind === "damage" && event.sourceActorId === PLAYER_AURA_ENTITY_ID && event.targetActorId === targetId
|
|
));
|
|
}
|
|
|
|
describe("native RoM auto attack integration", () => {
|
|
beforeEach(() => {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-knight", secondaryClassId: "rom-scout",
|
|
raceId: "rom-human", role: "tank", statRuleset: "rom", level: 70 });
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
usePartyStore.getState().initializeParty("rom-native-auto", 70, useCombatStore.getState().health, "damage", 2);
|
|
});
|
|
|
|
it("uses Main_DMG for player Knight hits and applies semantic melee haste to the source interval", () => {
|
|
const targetId = "native-player-knight-auto-target";
|
|
registerTarget(targetId);
|
|
giveSemanticHaste(PLAYER_AURA_ENTITY_ID, "melee-haste", 50);
|
|
const profile = playerNativeAuto("main-hand");
|
|
const expectedInterval = Math.max(500, Math.round(hastedDuration(profile.intervalMs, 50)));
|
|
useCombatStore.setState({ autoAttackTargetId: targetId, nextAutoAttackAt: 1_000 });
|
|
useCombatStore.getState().tick(0.1, 1_000);
|
|
const firstAutoAt = 1_000;
|
|
expect(playerAutoEvents(targetId)).toEqual([
|
|
expect.objectContaining({ occurredAt: firstAutoAt, rawAmount: Math.round(profile.damage) }),
|
|
]);
|
|
expect(useCombatStore.getState().nextAutoAttackAt).toBe(firstAutoAt + expectedInterval);
|
|
|
|
useCombatStore.getState().tick(0.1, firstAutoAt + expectedInterval - 1);
|
|
expect(playerAutoEvents(targetId)).toHaveLength(1);
|
|
useCombatStore.getState().tick(0.1, firstAutoAt + expectedInterval);
|
|
expect(playerAutoEvents(targetId).map((event) => event.occurredAt))
|
|
.toEqual([firstAutoAt, firstAutoAt + expectedInterval]);
|
|
});
|
|
|
|
it("starts physical auto-attacks after source Holy Strike without changing its holy direct hit", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-knight", secondaryClassId: null,
|
|
raceId: "rom-human", role: "tank", statRuleset: "rom", level: 1 });
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
const targetId = "native-player-knight-holy-strike-auto-target";
|
|
registerTarget(targetId);
|
|
useCombatStore.getState().selectMob(targetId);
|
|
|
|
const strike = useCombatStore.getState().abilities.find((ability) => ability.id === "rom-knight-general-1");
|
|
expect(strike).toMatchObject({ sourceAttackKind: "melee", source: "runewaker" });
|
|
const cast = useCombatStore.getState().castAbility(strike!.id,
|
|
{ targetActorId: targetId, origin: [0, 0, 0], now: 1_000 });
|
|
|
|
expect(cast).toMatchObject({ ok: true, damage: 16 });
|
|
expect(playerAutoEvents(targetId)).toContainEqual(expect.objectContaining({
|
|
abilityId: strike!.id,
|
|
rawAmount: 16,
|
|
school: "holy",
|
|
}));
|
|
expect(useCombatStore.getState().autoAttackTargetId).toBe(targetId);
|
|
|
|
useCombatStore.getState().tick(0.1, 1_000);
|
|
const postCastHits = playerAutoEvents(targetId);
|
|
expect(postCastHits).toHaveLength(2);
|
|
expect(postCastHits).toContainEqual(expect.objectContaining({
|
|
rawAmount: expect.any(Number),
|
|
school: "physical",
|
|
}));
|
|
expect(postCastHits.find((event) => event.school === "physical")?.rawAmount).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("uses source ranged damage and interval for a native Scout companion", () => {
|
|
const targetId = "native-scout-companion-auto-target";
|
|
registerTarget(targetId);
|
|
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === "rom-scout")!;
|
|
const scout = createConfiguredPartyMember({ ...identity, secondaryClassId: "rom-rogue", statRuleset: "rom",
|
|
specialization: "Primary", talentTreeId: "" }, 70, 0, "native-scout-auto-integration");
|
|
setSingleAttackingPartyMember(scout, targetId, "native-scout-auto-integration");
|
|
giveSemanticHaste(scout.id, "ranged-haste", 50);
|
|
const profile = partyNativeAuto(usePartyStore.getState().members[0]!, "ranged");
|
|
const expectedInterval = Math.max(500, Math.round(hastedDuration(profile.intervalMs, 50)));
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
expect(autoDamageEvents(scout.id, "party-auto-attack")).toContainEqual(expect.objectContaining({
|
|
occurredAt: 1_000,
|
|
rawAmount: Math.round(profile.damage),
|
|
}));
|
|
advanceDungeonPartyCombat(1_000 + expectedInterval - 1);
|
|
expect(autoDamageEvents(scout.id, "party-auto-attack")).toHaveLength(1);
|
|
advanceDungeonPartyCombat(1_000 + expectedInterval);
|
|
expect(autoDamageEvents(scout.id, "party-auto-attack").map((event) => event.occurredAt))
|
|
.toEqual([1_000, 1_000 + expectedInterval]);
|
|
});
|
|
|
|
it("uses the RoM Rogue Sec_DMG profile for companion off-hand swings", () => {
|
|
const targetId = "native-rogue-offhand-auto-target";
|
|
registerTarget(targetId);
|
|
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === "rom-rogue")!;
|
|
const rogue = createConfiguredPartyMember({ ...identity, secondaryClassId: "rom-warrior", statRuleset: "rom",
|
|
specialization: "Primary", talentTreeId: "" }, 70, 0, "native-rogue-offhand-auto-integration");
|
|
setSingleAttackingPartyMember(rogue, targetId, "native-rogue-offhand-auto-integration");
|
|
giveSemanticHaste(rogue.id, "melee-haste", 50);
|
|
const profile = partyNativeAuto(usePartyStore.getState().members[0]!, "off-hand");
|
|
const expectedInterval = Math.max(500, Math.round(hastedDuration(profile.intervalMs, 50)));
|
|
|
|
advanceDungeonPartyCombat(1_000);
|
|
expect(autoDamageEvents(rogue.id, "party-auto-attack-offhand")).toHaveLength(1);
|
|
const first = autoDamageEvents(rogue.id, "party-auto-attack-offhand")[0]!;
|
|
expect(first.rawAmount).toBeGreaterThanOrEqual(Math.round(profile.damage));
|
|
expect(first.rawAmount).toBeLessThanOrEqual(Math.round(profile.damage * 1.3));
|
|
advanceDungeonPartyCombat(1_000 + expectedInterval - 1);
|
|
expect(autoDamageEvents(rogue.id, "party-auto-attack-offhand")).toHaveLength(1);
|
|
advanceDungeonPartyCombat(1_000 + expectedInterval);
|
|
expect(autoDamageEvents(rogue.id, "party-auto-attack-offhand").map((event) => event.occurredAt))
|
|
.toEqual([1_000, 1_000 + expectedInterval]);
|
|
});
|
|
|
|
it("keeps player Scout Shot tied to the native ranged-weapon source binding", () => {
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-scout", secondaryClassId: "rom-rogue",
|
|
raceId: "rom-human", statRuleset: "rom", level: 70 });
|
|
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
|
const targetId = "native-player-scout-shot-target";
|
|
registerTarget(targetId, 10);
|
|
const scoutAbilities = abilitiesForCharacter("rom-scout", "rom-rogue");
|
|
const baseShot = scoutAbilities
|
|
.find((ability) => ability.effects.some((effect) => effect.kind === "damage" && effect.romNativeSource?.skillId === 491128));
|
|
expect(baseShot).toBeDefined();
|
|
const shot = abilityAtLevel(baseShot!, 70);
|
|
const effect = shot.effects.find((candidate) => candidate.kind === "damage");
|
|
expect(effect?.kind).toBe("damage");
|
|
if (effect?.kind !== "damage") return;
|
|
expect(effect.romNativeSource).toMatchObject({ skillId: 491128, kind: "damage" });
|
|
const state = useCombatStore.getState();
|
|
const stats = deriveCharacterStats(state.classId, state.level, state.gearStats, state.raceId,
|
|
{}, state.secondaryClassId, state.gender, state.statRuleset);
|
|
const sourceAmount = nativeDamageCasterAmount(effect, state.level, stats.romNative!);
|
|
expect(sourceAmount).not.toBeNull();
|
|
expect(sourceAmount).toBeGreaterThan(0);
|
|
|
|
const cast = useCombatStore.getState().castAbility(shot.id,
|
|
{ targetActorId: targetId, origin: [0, 0, 0], now: 1_000 });
|
|
expect(cast.ok).toBe(true);
|
|
const activeCast = useCombatStore.getState().activeCast;
|
|
if (activeCast) useCombatStore.getState().advanceMobCombat(activeCast.completesAt, [0, 0, 0]);
|
|
const shotHits = autoDamageEvents(PLAYER_AURA_ENTITY_ID, shot.id);
|
|
expect(shotHits).toHaveLength(1);
|
|
expect(shotHits[0]?.rawAmount).toBeGreaterThan(0);
|
|
});
|
|
});
|