Files
healer-man/src/game/romChampionDarkEnergyStrike.integration.test.ts
T

312 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore, stableCombatUnit } from "./combatStore";
import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura } from "./combatAuras";
import {
abilitiesForCharacter,
abilityById,
learnedAbilityForBuild,
} from "./abilityCatalog";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import {
clearPartyRuntimeRegistry,
updatePartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
} from "./partyRuntimeRegistry";
const STRIKE_ID = "rom-champion-rom-warlock-elite-30";
const SIPHON_ID = "rom-champion-rom-warlock-elite-20";
const TARGET_ID = "dark-energy-strike-source-target";
function seededStrikeTime(actorId: string, suffix: "direct" | "special-crit"): number {
const strike = abilityById(STRIKE_ID)!;
for (let now = 10_000; now < 100_000; now += 1) {
const resetRoll = stableCombatUnit(
`${actorId}:${strike.id}:${TARGET_ID}:${now}:rom-champion-dark-energy-strike-rune-siphon`,
);
const outcomeRoll = stableCombatUnit(`${actorId}:${strike.id}:${TARGET_ID}:${now}:${suffix}:outcome`);
if (resetRoll < 0.15 && outcomeRoll >= 0.2 && outcomeRoll < 0.8) return now;
}
throw new Error("Could not find a deterministic landed Dark Energy Strike reset seed.");
}
function setupPlayerStrike(
armor: number,
shadowResistance: number,
now: number,
targetLevel = 1,
shadowImmune = false,
targetHealth = 1_000_000,
) {
useCombatStore.getState().initializeCharacter({
classId: "rom-champion",
secondaryClassId: "rom-warlock",
raceId: "rom-dwarf",
level: 30,
});
useCombatStore.getState().registerMob(TARGET_ID, {
name: TARGET_ID,
level: targetLevel,
maxHealth: targetHealth,
resistances: { shadow: shadowResistance },
...(shadowImmune ? { schoolImmunities: ["shadow"] } : {}),
});
setMobPosition(TARGET_ID, [3, 0, 0]);
useCombatStore.setState((state) => {
const target = state.mobs[TARGET_ID]!;
return {
mobs: { ...state.mobs, [TARGET_ID]: { ...target, armor } },
resource: 100,
resourcePools: { ...state.resourcePools, rage: 100 },
cooldowns: { ...state.cooldowns, [SIPHON_ID]: now + 60_000 },
};
});
}
function playerStrikeDamage(armor: number, shadowResistance: number): number {
const now = seededStrikeTime(PLAYER_AGGRO_ID, "direct");
setupPlayerStrike(armor, shadowResistance, now);
const result = useCombatStore.getState().castAbility(STRIKE_ID, {
targetActorId: TARGET_ID,
now,
});
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, affectedMobIds: [TARGET_ID] });
return result.damage;
}
describe("Dark Energy Strike ordered-group integration", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.setState({ playerPosition: [0, 0, 0] });
});
it("uses armor for the physical group and shadow resistance for the Darkness group", () => {
const noArmorNoShadowResistance = playerStrikeDamage(0, 0);
const armorNoShadowResistance = playerStrikeDamage(50_000, 0);
const armorHighShadowResistance = playerStrikeDamage(50_000, 1_000_000);
expect(noArmorNoShadowResistance).toBeGreaterThan(armorNoShadowResistance);
expect(armorNoShadowResistance).toBeGreaterThan(armorHighShadowResistance);
expect(armorHighShadowResistance).toBeGreaterThan(0);
});
it("resets the player's separately learned Rune Siphon after physical success even when Darkness is fully resisted", () => {
const now = seededStrikeTime(PLAYER_AGGRO_ID, "direct");
setupPlayerStrike(50_000, 0, now, 1, true);
const result = useCombatStore.getState().castAbility(STRIKE_ID, {
targetActorId: TARGET_ID,
now,
});
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, damage: expect.any(Number) });
expect(result.damage).toBeGreaterThan(0);
expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
&& event.abilityId === STRIKE_ID && event.school === "shadow")).toEqual([
expect.objectContaining({ effectiveAmount: 0 }),
]);
expect(useCombatStore.getState().cooldowns[SIPHON_ID]).toBe(0);
});
it("does not run the Darkness packet or reset after the player's physical packet misses", () => {
const now = seededStrikeTime(PLAYER_AGGRO_ID, "direct");
setupPlayerStrike(0, 0, now, 255);
const result = useCombatStore.getState().castAbility(STRIKE_ID, {
targetActorId: TARGET_ID,
now,
});
const strikeDamageEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === STRIKE_ID
));
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, damage: 0 });
expect(strikeDamageEvents).toHaveLength(1);
expect(strikeDamageEvents[0]).toMatchObject({ school: "physical", outcome: "miss" });
expect(useCombatStore.getState().cooldowns[SIPHON_ID]).toBe(now + 60_000);
});
it("does not run the Darkness packet or reset when an absorb reduces physical HP damage to zero", () => {
const now = seededStrikeTime(PLAYER_AGGRO_ID, "direct");
setupPlayerStrike(0, 0, now);
const shield = applyAura(useCombatStore.getState().auras, {
definition: {
id: "test:dark-energy-physical-absorb",
name: "Physical absorb fixture",
disposition: "buff",
durationMs: null,
absorbs: [{ id: "test:dark-energy-physical-absorb-pool", amount: 1_000_000, school: "physical" }],
},
sourceId: PLAYER_AGGRO_ID,
targetId: TARGET_ID,
now,
}).auras;
useCombatStore.setState({ auras: shield });
const result = useCombatStore.getState().castAbility(STRIKE_ID, {
targetActorId: TARGET_ID,
now,
});
const strikeDamageEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === STRIKE_ID
));
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, damage: 0 });
expect(strikeDamageEvents).toHaveLength(1);
expect(strikeDamageEvents[0]).toMatchObject({
school: "physical",
effectiveAmount: 0,
absorbed: expect.any(Number),
});
expect(strikeDamageEvents[0]!.absorbed).toBeGreaterThan(0);
expect(useCombatStore.getState().cooldowns[SIPHON_ID]).toBe(now + 60_000);
});
it("keeps the physical success eligible for reset when that packet kills its target", () => {
const now = seededStrikeTime(PLAYER_AGGRO_ID, "direct");
setupPlayerStrike(0, 0, now, 1, false, 1);
const result = useCombatStore.getState().castAbility(STRIKE_ID, {
targetActorId: TARGET_ID,
now,
});
const strikeDamageEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === STRIKE_ID
));
expect(result, JSON.stringify(result)).toMatchObject({ ok: true, damage: 1 });
expect(useCombatStore.getState().mobs[TARGET_ID]?.dead).toBe(true);
expect(strikeDamageEvents).toHaveLength(1);
expect(strikeDamageEvents[0]).toMatchObject({ school: "physical", effectiveAmount: 1 });
expect(useCombatStore.getState().cooldowns[SIPHON_ID]).toBe(0);
});
it("resets a companion's Rune Siphon in both owner and combat-actor cooldown mirrors", () => {
const seed = "dark-energy-strike-companion";
useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 30 });
const companion = createConfiguredPartyMember({
name: "Champion/Warlock",
classId: "rom-champion",
secondaryClassId: "rom-warlock",
raceId: "rom-dwarf",
specialization: "Primary",
specializationId: "rom-champion:rom-warlock",
talentTreeId: "rom-champion-rom-warlock",
role: "damage",
}, 30, 0, seed);
const strike = abilitiesForCharacter("rom-champion", "rom-warlock")
.find((ability) => ability.dbcSpellId === 498_599)!;
const siphon = abilitiesForCharacter("rom-champion", "rom-warlock")
.find((ability) => ability.dbcSpellId === 498_595)!;
const learnedStrike = learnedAbilityForBuild(strike, companion.level, companion.talentRanks)!;
const learnedSiphon = learnedAbilityForBuild(siphon, companion.level, companion.talentRanks)!;
const now = seededStrikeTime(companion.id, "special-crit");
usePartyStore.getState().initializeParty(seed, 30, useCombatStore.getState().health, "damage", 1, "rom");
useCombatStore.getState().registerMob(TARGET_ID, { name: TARGET_ID, level: 1, maxHealth: 1_000_000, xpReward: 0 });
setMobPosition(TARGET_ID, [2, 0, 0]);
useCombatStore.getState().damageMob(TARGET_ID, 1, now);
updatePartyRuntimePosition(companion.id, 0, 0, 0);
usePartyStore.setState({
command: "attack",
members: [{
...companion,
resource: 100,
resourcePools: { ...companion.resourcePools, rage: 100 },
cooldowns: { ...companion.cooldowns, [learnedSiphon.id]: now + 60_000 },
activeCast: {
abilityId: learnedStrike.id,
school: "physical",
mode: "cast",
startedAt: now,
completesAt: now,
originalDurationMs: 0,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: TARGET_ID,
},
activeCastTargetId: TARGET_ID,
status: "attacking",
}],
});
updatePartyRuntimeTargetVisibility(companion.id, TARGET_ID, true);
useCombatStore.getState().synchronizeActors();
advanceDungeonPartyCombat(now);
expect(usePartyStore.getState().members[0]?.cooldowns[learnedSiphon.id]).toBe(0);
expect(useCombatStore.getState().actors[companion.id]?.cooldowns[learnedSiphon.id]).toBe(0);
});
it("does not run the companion Darkness packet or reset after its physical packet misses", () => {
const seed = "dark-energy-strike-companion-miss";
useCombatStore.getState().initializeCharacter({ classId: "rom-champion", raceId: "rom-dwarf", level: 30 });
const companion = createConfiguredPartyMember({
name: "Champion/Warlock",
classId: "rom-champion",
secondaryClassId: "rom-warlock",
raceId: "rom-dwarf",
specialization: "Primary",
specializationId: "rom-champion:rom-warlock",
talentTreeId: "rom-champion-rom-warlock",
role: "damage",
}, 30, 0, seed);
const strike = abilitiesForCharacter("rom-champion", "rom-warlock")
.find((ability) => ability.dbcSpellId === 498_599)!;
const siphon = abilitiesForCharacter("rom-champion", "rom-warlock")
.find((ability) => ability.dbcSpellId === 498_595)!;
const learnedStrike = learnedAbilityForBuild(strike, companion.level, companion.talentRanks)!;
const learnedSiphon = learnedAbilityForBuild(siphon, companion.level, companion.talentRanks)!;
const now = seededStrikeTime(companion.id, "special-crit");
usePartyStore.getState().initializeParty(seed, 30, useCombatStore.getState().health, "damage", 1, "rom");
useCombatStore.getState().registerMob(TARGET_ID, {
name: TARGET_ID,
level: 255,
maxHealth: 1_000_000,
xpReward: 0,
});
setMobPosition(TARGET_ID, [2, 0, 0]);
useCombatStore.getState().damageMob(TARGET_ID, 1, now);
updatePartyRuntimePosition(companion.id, 0, 0, 0);
usePartyStore.setState({
command: "attack",
members: [{
...companion,
resource: 100,
resourcePools: { ...companion.resourcePools, rage: 100 },
cooldowns: { ...companion.cooldowns, [learnedSiphon.id]: now + 60_000 },
activeCast: {
abilityId: learnedStrike.id,
school: "physical",
mode: "cast",
startedAt: now,
completesAt: now,
originalDurationMs: 0,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: TARGET_ID,
},
activeCastTargetId: TARGET_ID,
status: "attacking",
}],
});
updatePartyRuntimeTargetVisibility(companion.id, TARGET_ID, true);
useCombatStore.getState().synchronizeActors();
advanceDungeonPartyCombat(now);
const strikeDamageEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === learnedStrike.id
));
expect(strikeDamageEvents).toHaveLength(1);
expect(strikeDamageEvents[0]).toMatchObject({ school: "physical", outcome: "miss" });
expect(usePartyStore.getState().members[0]?.cooldowns[learnedSiphon.id]).toBe(now + 60_000);
expect(useCombatStore.getState().actors[companion.id]?.cooldowns[learnedSiphon.id]).toBe(now + 60_000);
});
});