312 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|