forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
6cc1b9217f
commit
6114a2295f
67 files changed
+6241
-347
No files matched your search
@@ -13,6 +13,7 @@ import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras
|
||||
import { applyAbilityBalanceTuning, currentBalanceTuningPolicy } from "./balanceTuning";
|
||||
import { preloadRuntimeTalentCatalog } from "./talentRuntimeCatalog";
|
||||
import { applyGeneratedBeastWithinTalent } from "./wow335HunterRuntime";
|
||||
import { romBloodArrowAbilityForOwner } from "./romBloodArrow";
|
||||
|
||||
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
|
||||
|
||||
@@ -1101,7 +1102,17 @@ export function abilitiesForCharacter(
|
||||
ability.romSkillGroup === "general" || (ability.romSkillGroup === "passive" && ability.id.includes("-general-"))
|
||||
));
|
||||
const elites = romEliteAbilities.filter((ability) => ability.classId === classId && ability.secondaryClassId === secondaryClassId);
|
||||
const characterAbilities = [...primary, ...secondaryGeneral, ...elites];
|
||||
const characterAbilities = [...primary, ...secondaryGeneral, ...elites].map((ability) => {
|
||||
const bloodArrow = romBloodArrowAbilityForOwner(ability, { classId, secondaryClassId });
|
||||
return bloodArrow === ability ? ability : {
|
||||
...bloodArrow,
|
||||
description: classId === "rom-knight" && secondaryClassId === "rom-scout"
|
||||
? "Activates Blood Arrow and increases ranged weapon damage. Each offensive ranged weapon skill costs 2.0% HP and 1.0% MP. Right-click the buff icon or use the skill again to deactivate; the skill enters a 10-second cooldown after deactivation."
|
||||
: classId === "rom-scout" && secondaryClassId === "rom-rogue"
|
||||
? "Activates Blood Arrow and increases physical ranged weapon damage. Ranged attacks cost 2.0% HP each. Right-click the buff icon or use the skill again to deactivate; the skill enters a 10-second cooldown after deactivation."
|
||||
: "Activates Blood Arrow and increases physical ranged weapon damage. Right-click the buff icon or use the skill again to deactivate; the skill enters a 10-second cooldown after deactivation.",
|
||||
};
|
||||
});
|
||||
if (classId !== "rom-warlock" || secondaryClassId !== "rom-priest") return characterAbilities;
|
||||
return characterAbilities.map((ability) => {
|
||||
if (ability.classId === "rom-priest" && ability.dbcSpellId === 490256) {
|
||||
|
||||
+174
-13
@@ -565,6 +565,15 @@ import {
|
||||
ROM_KNIGHT_PANTHERS_BLESSING_ABILITY_ID,
|
||||
romKnightPanthersBlessingRestoreOnDodge,
|
||||
} from "./romKnightPanthersBlessingRuntime";
|
||||
import {
|
||||
hasOwnedRomScoutBloodArrow,
|
||||
isRomBloodArrowOwner,
|
||||
isRomScoutBloodArrowAbility,
|
||||
romBloodArrowCastCosts,
|
||||
ROM_SCOUT_BLOOD_ARROW_ABILITY_ID,
|
||||
ROM_SCOUT_BLOOD_ARROW_AURA_TAG,
|
||||
ROM_SCOUT_BLOOD_ARROW_DEACTIVATION_COOLDOWN_MS,
|
||||
} from "./romBloodArrow";
|
||||
import {
|
||||
ROM_COA_KNIGHT_ROGUE_GODS_SWORD_ID,
|
||||
romCoaKnightRogueGodsSwordBonusLightDamage,
|
||||
@@ -1562,6 +1571,7 @@ export interface CombatState {
|
||||
selectFriendlyActor: (id: string | null) => boolean;
|
||||
setPlayerPosition: (position: CombatPosition) => void;
|
||||
castAbility: (abilityId: string, requestOrOrigin?: CastRequest | CombatPosition, now?: number, completingCast?: ActiveCast) => CastResult;
|
||||
deactivateRomScoutBloodArrow: (ownerId: string, now?: number) => boolean;
|
||||
beginGroundTarget: (abilityId: string) => boolean;
|
||||
moveGroundTarget: (moveX: number, moveForward: number, yaw: number, deltaSeconds: number) => void;
|
||||
confirmGroundTarget: (position?: CombatPosition, now?: number) => CastResult;
|
||||
@@ -10958,13 +10968,26 @@ function damageCompanionActorInWork(work: MutableCastWork, id: string, amount: n
|
||||
if (!actor || actor.health <= 0 || requested <= 0) return 0;
|
||||
const applied = Math.min(actor.health, requested);
|
||||
const health = actor.health - applied;
|
||||
const next = Object.freeze({ ...actor, health, alive: health > 0, cast: health > 0 ? actor.cast : null });
|
||||
const next = Object.freeze({
|
||||
...actor,
|
||||
health,
|
||||
alive: health > 0,
|
||||
cast: health > 0 ? actor.cast : null,
|
||||
...(health <= 0 ? {
|
||||
cooldowns: Object.fromEntries(Object.entries(actor.cooldowns)
|
||||
.filter(([abilityId]) => abilityId !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)),
|
||||
} : {}),
|
||||
});
|
||||
work.actors[id] = next;
|
||||
mirrorCompanionActor(next);
|
||||
if (health <= 0) {
|
||||
work.auras = work.auras.filter((aura) => !(aura.targetId === id
|
||||
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)));
|
||||
usePartyStore.setState((party) => ({
|
||||
members: party.members.map((member) => member.id === id
|
||||
? { ...member, status: "down", activeCast: null, activeCastTargetId: null }
|
||||
? { ...member, status: "down", activeCast: null, activeCastTargetId: null,
|
||||
cooldowns: Object.fromEntries(Object.entries(member.cooldowns)
|
||||
.filter(([abilityId]) => abilityId !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)) }
|
||||
: member),
|
||||
}));
|
||||
}
|
||||
@@ -11985,13 +12008,38 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
cancelGroundTarget: () => set({ pendingGroundTarget: null }),
|
||||
|
||||
synchronizeActors: () => {
|
||||
const partyMembers = usePartyStore.getState().members;
|
||||
const livingCompanionIds = new Set(partyMembers.filter((member) => member.health > 0).map((member) => member.id));
|
||||
let actors: Readonly<Record<string, CombatActorState>> = {};
|
||||
set((state) => {
|
||||
actors = combatActorSnapshots(state);
|
||||
return { actors };
|
||||
const activeBloodArrowOwners = new Set(livingCompanionIds);
|
||||
if (state.health > 0) activeBloodArrowOwners.add(PLAYER_AURA_ENTITY_ID);
|
||||
actors = Object.freeze(Object.fromEntries(Object.entries(combatActorSnapshots(state)).map(([id, actor]) => (
|
||||
activeBloodArrowOwners.has(id) ? [id, actor] : [id, {
|
||||
...actor,
|
||||
cooldowns: Object.fromEntries(Object.entries(actor.cooldowns)
|
||||
.filter(([abilityId]) => abilityId !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)),
|
||||
}]
|
||||
))));
|
||||
const auras = state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)
|
||||
|| aura.sourceId !== aura.targetId
|
||||
|| (aura.sourceId === PLAYER_AURA_ENTITY_ID
|
||||
? state.health > 0
|
||||
: livingCompanionIds.has(aura.sourceId)));
|
||||
const cooldowns = state.health > 0
|
||||
? state.cooldowns
|
||||
: Object.fromEntries(Object.entries(state.cooldowns).filter(([id]) => id !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID));
|
||||
return { actors, auras, cooldowns };
|
||||
});
|
||||
usePartyStore.setState((party) => ({
|
||||
members: party.members.map((member) => memberFromCompanionActor(member, actors[member.id])),
|
||||
members: party.members.map((member) => {
|
||||
const runtime = memberFromCompanionActor(member, actors[member.id]);
|
||||
return member.health > 0 ? runtime : {
|
||||
...runtime,
|
||||
cooldowns: Object.fromEntries(Object.entries(runtime.cooldowns)
|
||||
.filter(([id]) => id !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
},
|
||||
applyDefaultPartyBuffs: (now = combatNow()) => {
|
||||
@@ -12226,6 +12274,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
get().synchronizeActors();
|
||||
},
|
||||
commitCompanionActors: (members) => {
|
||||
const livingCompanionIds = new Set(members.filter((member) => member.health > 0).map((member) => member.id));
|
||||
let actors: Readonly<Record<string, CombatActorState>> = {};
|
||||
set((state) => {
|
||||
const next = { ...state.actors };
|
||||
@@ -12234,15 +12283,40 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
member,
|
||||
entityAbsorbAmount(state.auras, member.id),
|
||||
);
|
||||
next[member.id] = companionActorWithMemberRuntime(existing, member);
|
||||
const runtime = companionActorWithMemberRuntime(existing, member);
|
||||
next[member.id] = member.health > 0 ? runtime : {
|
||||
...runtime,
|
||||
cooldowns: Object.fromEntries(Object.entries(runtime.cooldowns)
|
||||
.filter(([id]) => id !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)),
|
||||
};
|
||||
}
|
||||
const activeBloodArrowOwners = new Set(livingCompanionIds);
|
||||
if (state.health > 0) activeBloodArrowOwners.add(PLAYER_AURA_ENTITY_ID);
|
||||
for (const [id, actor] of Object.entries(next)) {
|
||||
if (activeBloodArrowOwners.has(id) || actor.cooldowns[ROM_SCOUT_BLOOD_ARROW_ABILITY_ID] === undefined) continue;
|
||||
next[id] = {
|
||||
...actor,
|
||||
cooldowns: Object.fromEntries(Object.entries(actor.cooldowns)
|
||||
.filter(([abilityId]) => abilityId !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)),
|
||||
};
|
||||
}
|
||||
actors = Object.freeze(next);
|
||||
return { actors };
|
||||
const auras = state.auras.filter((aura) => !aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)
|
||||
|| aura.sourceId !== aura.targetId
|
||||
|| (aura.sourceId === PLAYER_AURA_ENTITY_ID
|
||||
? state.health > 0
|
||||
: livingCompanionIds.has(aura.sourceId)));
|
||||
return { actors, auras };
|
||||
});
|
||||
usePartyStore.setState((party) => ({
|
||||
members: party.members.map((member) => {
|
||||
const runtime = members.find((candidate) => candidate.id === member.id) ?? member;
|
||||
return memberFromCompanionActor(runtime, actors[member.id]);
|
||||
const projected = memberFromCompanionActor(runtime, actors[member.id]);
|
||||
return projected.health > 0 ? projected : {
|
||||
...projected,
|
||||
cooldowns: Object.fromEntries(Object.entries(projected.cooldowns)
|
||||
.filter(([id]) => id !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
},
|
||||
@@ -12267,6 +12341,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const suddenDoomFreeDeathCoil = request[WOW335_DEATH_KNIGHT_SUDDEN_DOOM_FREE_CAST] === true
|
||||
&& ability?.id === WOW335_DEATH_KNIGHT_SUDDEN_DOOM_DEATH_COIL_ABILITY_ID;
|
||||
const activePlayerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
|
||||
if (!completingCast && ability && isRomScoutBloodArrowAbility(ability)
|
||||
&& isRomBloodArrowOwner({ classId: state.classId, secondaryClassId: state.secondaryClassId })
|
||||
&& state.abilities.some((entry) => entry.id === ability!.id)
|
||||
&& hasOwnedRomScoutBloodArrow(state.auras, PLAYER_AURA_ENTITY_ID, now)
|
||||
&& get().deactivateRomScoutBloodArrow(PLAYER_AURA_ENTITY_ID, now)) {
|
||||
return {
|
||||
ok: true,
|
||||
abilityId,
|
||||
affectedMobIds: [],
|
||||
damage: 0,
|
||||
healing: 0,
|
||||
levelsGained: 0,
|
||||
};
|
||||
}
|
||||
const druidMasterShapeshifterHealingModifiers = state.classId === "druid"
|
||||
? wow335DruidMasterShapeshifterModifiersForForm(
|
||||
state.talentRanks[WOW335_DRUID_MASTER_SHAPESHIFTER_TALENT_ID] ?? 0,
|
||||
@@ -12691,16 +12779,29 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: state.classId === "warrior" && ability?.name === "Execute"
|
||||
? Math.min(3, Math.max(0, Math.trunc(state.talentRanks["talent-1542"] ?? 0))) * 2
|
||||
: 0;
|
||||
const costs = ability && !consumesInnerFocus && !consumesDruidClearcasting && !consumesSurgeOfLight
|
||||
? resolvedAbilityCosts(ability, modifiers, { ...state, now }).map((cost) => {
|
||||
const costs = [
|
||||
...(ability && !consumesInnerFocus && !consumesDruidClearcasting && !consumesSurgeOfLight
|
||||
? resolvedAbilityCosts(ability, modifiers, { ...state, now }).map((cost) => {
|
||||
if (suddenDoomFreeDeathCoil && cost.resource === "runic-power") return { ...cost, amount: 0 };
|
||||
if (consumesHunterLockAndLoad && cost.resource === "mana") return { ...cost, amount: 0 };
|
||||
if (state.classId !== "warrior" || cost.resource !== "rage") return cost;
|
||||
return warriorFlatRageCostReduction > 0
|
||||
? { ...cost, amount: Math.max(0, cost.amount - warriorFlatRageCostReduction) }
|
||||
: cost;
|
||||
})
|
||||
: [];
|
||||
})
|
||||
: []),
|
||||
...(ability ? romBloodArrowCastCosts(
|
||||
ability,
|
||||
{ classId: state.classId, secondaryClassId: state.secondaryClassId },
|
||||
activePlayerAuras,
|
||||
PLAYER_AURA_ENTITY_ID,
|
||||
state.maxHealth,
|
||||
resourceProfileForClass(state.classId).type === "mana"
|
||||
? state.maxResource
|
||||
: state.maxResourcePools.mana ?? 0,
|
||||
now,
|
||||
) : []),
|
||||
];
|
||||
const resourceCost = ability && !suddenDoomFreeDeathCoil
|
||||
&& !consumesInnerFocus && !consumesDruidClearcasting && !consumesSurgeOfLight
|
||||
? Math.max(0, talentAdjustedResourceCost(ability, modifiers)
|
||||
@@ -17103,6 +17204,45 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return result;
|
||||
},
|
||||
|
||||
deactivateRomScoutBloodArrow: (ownerId, now = combatNow()) => {
|
||||
const state = get();
|
||||
const playerOwner = ownerId === PLAYER_AURA_ENTITY_ID;
|
||||
const member = playerOwner
|
||||
? null
|
||||
: usePartyStore.getState().members.find((candidate) => candidate.id === ownerId) ?? null;
|
||||
const ownerBuild = playerOwner ? state : member;
|
||||
if (!ownerBuild || !isRomBloodArrowOwner(ownerBuild)
|
||||
|| !hasOwnedRomScoutBloodArrow(state.auras, ownerId, now)) return false;
|
||||
if ((playerOwner && state.health <= 0) || (!playerOwner && (!member || member.health <= 0))) return false;
|
||||
|
||||
const readyAt = now + ROM_SCOUT_BLOOD_ARROW_DEACTIVATION_COOLDOWN_MS;
|
||||
const auras = state.auras.filter((aura) => !(aura.sourceId === ownerId
|
||||
&& aura.targetId === ownerId
|
||||
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)));
|
||||
const actors = state.actors[ownerId]
|
||||
? { ...state.actors, [ownerId]: {
|
||||
...state.actors[ownerId],
|
||||
cooldowns: { ...state.actors[ownerId].cooldowns, [ROM_SCOUT_BLOOD_ARROW_ABILITY_ID]: readyAt },
|
||||
} }
|
||||
: state.actors;
|
||||
if (playerOwner) {
|
||||
set({
|
||||
auras,
|
||||
actors,
|
||||
cooldowns: { ...state.cooldowns, [ROM_SCOUT_BLOOD_ARROW_ABILITY_ID]: readyAt },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
usePartyStore.setState((party) => ({
|
||||
members: party.members.map((candidate) => candidate.id === ownerId
|
||||
? { ...candidate, cooldowns: { ...candidate.cooldowns, [ROM_SCOUT_BLOOD_ARROW_ABILITY_ID]: readyAt } }
|
||||
: candidate),
|
||||
}));
|
||||
set({ auras, actors });
|
||||
return true;
|
||||
},
|
||||
|
||||
consumeActorAuraTag: (actorId, tag) => {
|
||||
set((state) => ({
|
||||
auras: state.auras.filter((aura) => !(aura.targetId === actorId
|
||||
@@ -18944,7 +19084,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
set({
|
||||
auras: health <= 0 ? nextAuras.filter((aura) => !(aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes("wow335-rogue-killing-spree"))) : nextAuras,
|
||||
&& (aura.definition.utilityTags?.includes("wow335-rogue-killing-spree")
|
||||
|| aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)))) : nextAuras,
|
||||
cooldowns: health <= 0
|
||||
? Object.fromEntries(Object.entries(state.cooldowns)
|
||||
.filter(([id]) => id !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID))
|
||||
: state.cooldowns,
|
||||
killingSpreeWindows: health <= 0
|
||||
? state.killingSpreeWindows.filter((window) => window.ownerActorId !== PLAYER_AURA_ENTITY_ID)
|
||||
: state.killingSpreeWindows,
|
||||
@@ -21036,6 +21181,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
combatResult.dodged ? "dodge" : combatResult.landed && combatResult.rawDamage > 0 ? "hit" : "other",
|
||||
now,
|
||||
)];
|
||||
if (procWork.health <= 0) {
|
||||
procWork.auras = procWork.auras.filter((aura) => !(aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)));
|
||||
delete procWork.cooldowns[ROM_SCOUT_BLOOD_ARROW_ABILITY_ID];
|
||||
}
|
||||
const playerPushbackReduction = healthDamage > 0
|
||||
? paladinConcentrationAuraPushbackReductionForTarget(
|
||||
procWork.auras,
|
||||
@@ -22078,6 +22228,17 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
combatResult.dodged ? "dodge" : combatResult.landed && combatResult.rawDamage > 0 ? "hit" : "other",
|
||||
now,
|
||||
)];
|
||||
if (nextActor.health <= 0) {
|
||||
aurasAfterProcs = aurasAfterProcs.filter((aura) => !(aura.targetId === member.id
|
||||
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)));
|
||||
nextActor = {
|
||||
...nextActor,
|
||||
cooldowns: Object.fromEntries(Object.entries(nextActor.cooldowns)
|
||||
.filter(([abilityId]) => abilityId !== ROM_SCOUT_BLOOD_ARROW_ABILITY_ID)),
|
||||
};
|
||||
actorsAfterHit = { ...actorsAfterHit, [nextActor.id]: nextActor };
|
||||
mirrorCompanionActor(nextActor);
|
||||
}
|
||||
set({
|
||||
actors: actorsAfterHit,
|
||||
summons: nextSummons,
|
||||
|
||||
@@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||
import campaign from "../../dungeon-pipeline/dungeon-campaign.json";
|
||||
import epochInstances from "../../dungeon-pipeline/epoch-five-player-instances.json";
|
||||
import gameObjectAssetManifest from "../../dungeon-pipeline/WOW_GAMEOBJECT_ASSET_MANIFEST.json";
|
||||
import stockadeRecipe from "../../dungeon-pipeline/recipes/stormwind-stockade.json";
|
||||
import theNexusRecipe from "../../dungeon-pipeline/recipes/the-nexus.json";
|
||||
import worldAi from "./generated/azerothCoreWorldAi.json";
|
||||
import generated from "./generated/dungeonCampaignCatalog.json";
|
||||
import manastormPackages from "./generated/manastormAssetPackages.json";
|
||||
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
|
||||
@@ -72,7 +75,7 @@ const WDT_PLACEMENT_DUNGEON_IDS = [
|
||||
describe("generated dungeon campaign catalog", () => {
|
||||
it("covers only reviewed five-player instances with loadable assets and combat content", () => {
|
||||
expect(generated.definitions).toHaveLength(54);
|
||||
expect(DUNGEON_DEFINITIONS).toHaveLength(92);
|
||||
expect(DUNGEON_DEFINITIONS).toHaveLength(98);
|
||||
expect(generated.definitions.map((dungeon) => dungeon.id))
|
||||
.toEqual(campaign.dungeons.map((dungeon) => dungeon.slug));
|
||||
for (const dungeon of generated.definitions) {
|
||||
@@ -86,6 +89,25 @@ describe("generated dungeon campaign catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the six newer source-backed RuneWaker dungeons in the registry", () => {
|
||||
const newerSourceDungeons = [
|
||||
["kawaks-tomb", "scripts/runewaker-pipeline/snapshots/kawaks-tomb-zone-134.json"],
|
||||
["grafu-castle", "scripts/runewaker-pipeline/snapshots/grafu-castle-zone-137.json"],
|
||||
["sardo-castle", "scripts/runewaker-pipeline/snapshots/sardo-castle-zone-139.json"],
|
||||
["tomb-seven-heroes", "scripts/runewaker-pipeline/snapshots/tomb-seven-heroes-zone-142.json"],
|
||||
["aeternal-circle", "scripts/runewaker-pipeline/snapshots/aeternal-circle-zone-144.json"],
|
||||
["kulech-bones-nest", "scripts/runewaker-pipeline/snapshots/kulech-bones-nest-zone-147.json"],
|
||||
] as const;
|
||||
|
||||
for (const [id, sourceSnapshot] of newerSourceDungeons) {
|
||||
const definition = DUNGEON_DEFINITIONS.find((candidate) => candidate.id === id);
|
||||
expect(definition, id).toMatchObject({
|
||||
expansion: "runewaker",
|
||||
provenance: { sourceSnapshot },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("never exposes raw database gameobject rows to the live world-object renderer", () => {
|
||||
for (const dungeon of DUNGEON_DEFINITIONS) {
|
||||
for (const object of dungeon.serverGameObjects) {
|
||||
@@ -857,6 +879,163 @@ describe("generated dungeon campaign catalog", () => {
|
||||
expect(targorr?.position).toEqual([-159.582, -25.6062, 1.2531]);
|
||||
});
|
||||
|
||||
it("omits only the source-confirmed utility-only Stockade casts and preserves real damage", () => {
|
||||
const stockades = requireDungeonDefinition("stormwind-stockade");
|
||||
const blackrockDepths = requireDungeonDefinition("blackrock-depths");
|
||||
const attacksFor = (dungeon: typeof stockades, entry: number) => (
|
||||
dungeon.entities[`entry-${entry}`]?.combat?.attacks ?? []
|
||||
);
|
||||
const hasSpell = (dungeon: typeof stockades, entry: number, spellId: number) => (
|
||||
attacksFor(dungeon, entry).some((attack) => attack.spellId === spellId)
|
||||
);
|
||||
const sourceSmartAction = (
|
||||
entry: "1663" | "1666" | "1706" | "1708" | "1711" | "1715" | "1716" | "1717" | "1696",
|
||||
spellId: number,
|
||||
) => (
|
||||
worldAi.smartScriptsByEntry[entry]?.find((row) => (
|
||||
row.action_type === 11 && row.action_param1 === spellId
|
||||
))
|
||||
);
|
||||
|
||||
expect(sourceSmartAction("1666", 7164)).toMatchObject({ event_type: 0, target_type: 1 });
|
||||
expect(sourceSmartAction("1708", 7165)).toMatchObject({ event_type: 1, target_type: 1 });
|
||||
expect(sourceSmartAction("1715", 13730)).toMatchObject({ event_type: 0, target_type: 1 });
|
||||
expect(worldAi.creatureSpellsByEntry["1711"]?.map((spell) => spell.spellId)).toContain(16244);
|
||||
|
||||
expect([1666, 1708, 1711, 1715].flatMap((entry) => (
|
||||
attacksFor(stockades, entry).filter((attack) => [7164, 7165, 16244, 13730].includes(attack.spellId ?? 0))
|
||||
))).toEqual([]);
|
||||
|
||||
const kamAttacks = attacksFor(stockades, 1666);
|
||||
const shieldSlam = kamAttacks.find((attack) => attack.spellId === 8242);
|
||||
expect(shieldSlam?.effect).toMatchObject({ kind: "control", damageMultiplier: 1.35 });
|
||||
expect(kamAttacks.map((attack) => attack.spellId)).not.toContain(3419);
|
||||
expect(kamAttacks.some((attack) => attack.id.endsWith("-surge"))).toBe(false);
|
||||
|
||||
const inmateAttacks = attacksFor(stockades, 1708);
|
||||
expect(hasSpell(stockades, 1708, 6547)).toBe(true);
|
||||
expect(hasSpell(stockades, 1708, 6253)).toBe(true);
|
||||
expect(hasSpell(stockades, 1711, 16509)).toBe(true);
|
||||
|
||||
const grebmarAttacks = attacksFor(blackrockDepths, 9319);
|
||||
expect(hasSpell(blackrockDepths, 9319, 21049)).toBe(true);
|
||||
expect(hasSpell(blackrockDepths, 9319, 15615)).toBe(true);
|
||||
expect(grebmarAttacks.some((attack) => attack.id.endsWith("-surge"))).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves pinned Stockade hostile targeting, utility omissions, and supported damage", () => {
|
||||
const stockades = requireDungeonDefinition("stormwind-stockade");
|
||||
const theNexus = requireDungeonDefinition("the-nexus");
|
||||
const attacksFor = (entry: number) => (
|
||||
stockades.entities[`entry-${entry}`]?.combat?.attacks ?? []
|
||||
);
|
||||
const attackFor = (entry: number, spellId: number) => (
|
||||
attacksFor(entry).find((attack) => attack.spellId === spellId)
|
||||
);
|
||||
const sourceSmartAction = (
|
||||
entry: "1663" | "1666" | "1706" | "1708" | "1711" | "1715" | "1716" | "1717" | "1696",
|
||||
spellId: number,
|
||||
) => (
|
||||
worldAi.smartScriptsByEntry[entry]?.find((row) => (
|
||||
row.action_type === 11 && row.action_param1 === spellId
|
||||
))
|
||||
);
|
||||
|
||||
expect(sourceSmartAction("1663", 11976)).toMatchObject({ target_type: 2 });
|
||||
expect(sourceSmartAction("1663", 19134)).toMatchObject({ target_type: 2 });
|
||||
expect(sourceSmartAction("1666", 8242)).toMatchObject({ target_type: 2 });
|
||||
expect(sourceSmartAction("1716", 7964)).toMatchObject({ target_type: 1 });
|
||||
expect(stockadeRecipe.clientBuild).toBe("3.3.5a-Ascension");
|
||||
expect(theNexusRecipe.clientBuild).toBe("3.3.5a-Epoch");
|
||||
|
||||
for (const [entry, spellId] of [
|
||||
[1666, 3419], [1708, 7165], [1711, 16244], [1715, 13730],
|
||||
[1706, 6713], [1707, 17230], [1663, 2457], [1717, 6742],
|
||||
[1716, 9128], [1716, 29651], [1696, 674], [1696, 3391],
|
||||
] as const) {
|
||||
expect(attackFor(entry, spellId), `entry ${entry}, spell ${spellId}`).toBeUndefined();
|
||||
}
|
||||
|
||||
expect(attackFor(1666, 8242)).toMatchObject({
|
||||
target: "primary",
|
||||
delivery: "melee",
|
||||
range: 5,
|
||||
effect: { kind: "control", mechanic: "stun", damageMultiplier: 1.35 },
|
||||
});
|
||||
expect(attackFor(1711, 8242)).toMatchObject({
|
||||
target: "primary",
|
||||
delivery: "melee",
|
||||
range: 5,
|
||||
effect: { kind: "control", mechanic: "stun", damageMultiplier: 1.12 },
|
||||
});
|
||||
expect(attackFor(1711, 6253)).toMatchObject({
|
||||
target: "primary",
|
||||
delivery: "melee",
|
||||
range: 5,
|
||||
effect: { kind: "control", mechanic: "stun", durationMs: 2_000 },
|
||||
});
|
||||
expect(attackFor(1708, 6547)).toBeDefined();
|
||||
expect(attackFor(1711, 16509)).toBeDefined();
|
||||
|
||||
expect(attackFor(1663, 11976)).toMatchObject({
|
||||
target: "primary",
|
||||
delivery: "melee",
|
||||
range: 5,
|
||||
});
|
||||
expect(attackFor(1707, 15657)).toMatchObject({
|
||||
target: "primary",
|
||||
delivery: "melee",
|
||||
range: 5,
|
||||
attackKind: "melee",
|
||||
});
|
||||
expect(attackFor(1706, 11978)).toMatchObject({
|
||||
target: "primary",
|
||||
delivery: "melee",
|
||||
range: 5,
|
||||
effect: { kind: "control", mechanic: "interrupt", durationMs: 4_000 },
|
||||
});
|
||||
expect(attackFor(1717, 421)).toMatchObject({
|
||||
target: "primary",
|
||||
delivery: "projectile",
|
||||
range: 30,
|
||||
attackKind: "spell",
|
||||
});
|
||||
expect(attackFor(1717, 421)?.damageMultiplier).toBeGreaterThan(0);
|
||||
|
||||
expect(attackFor(1663, 5246)).toMatchObject({
|
||||
target: "nearby-party",
|
||||
range: 8,
|
||||
radius: 8,
|
||||
effect: { kind: "control", mechanic: "fear" },
|
||||
});
|
||||
expect(attackFor(1663, 19134)).toMatchObject({
|
||||
target: "nearby-party",
|
||||
range: 10,
|
||||
radius: 8,
|
||||
effect: { kind: "control", mechanic: "fear", durationMs: 4_000 },
|
||||
});
|
||||
expect(attackFor(1716, 7964)).toMatchObject({
|
||||
target: "nearby-party",
|
||||
range: 5,
|
||||
radius: 5,
|
||||
effect: { kind: "control", mechanic: "stun", durationMs: 4_000 },
|
||||
});
|
||||
|
||||
const epochFrighteningShout = theNexus.entities["entry-26796"]?.combat?.attacks
|
||||
.find((attack) => attack.spellId === 19134);
|
||||
expect(epochFrighteningShout).toMatchObject({
|
||||
target: "nearby-party",
|
||||
delivery: "area",
|
||||
range: 10,
|
||||
radius: 8,
|
||||
effect: { kind: "control", mechanic: "fear", durationMs: 6_000 },
|
||||
});
|
||||
|
||||
const targorrAttacks = attacksFor(1696);
|
||||
expect(attackFor(1696, 8599)?.effect).toMatchObject({ kind: "aura" });
|
||||
expect(targorrAttacks.some((attack) => attack.id.endsWith("-surge"))).toBe(false);
|
||||
});
|
||||
|
||||
it("starts Ragefire Chasm in the dungeon instead of the Orgrimmar portal annex", () => {
|
||||
const ragefire = requireDungeonDefinition("ragefire-chasm");
|
||||
const firstSpawn = ragefire.staticSpawns.find(
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Generated by npm run dungeon:catalog. Do not edit.
|
||||
export default "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 truncated
|
||||
export default "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
Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
|
||||
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
|
||||
@@ -51,6 +51,7 @@ import { romPriestChampionCooldownOverride } from "./romPriestChampionOverrides"
|
||||
import { applyTalentModifierValue, talentModifiersForRanks } from "./talentModifiers";
|
||||
import { romWarlockMageSoulBrandStingHasWillpowerBladeState } from "./romWarlockMageSoulBrandSting";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { romBloodArrowCastCosts } from "./romBloodArrow";
|
||||
|
||||
export interface PartyAbilityContext {
|
||||
readonly now: number;
|
||||
@@ -281,7 +282,7 @@ export function partyAbilityCosts(member: PartyMember, ability: AbilityDefinitio
|
||||
const mageClearcasting = member.classId === "mage" && auras.some((aura) => (
|
||||
aura.definition.utilityTags?.includes(WOW335_MAGE_ARCANE_CONCENTRATION_TAG)
|
||||
));
|
||||
return (adjustedAbility.costs?.length ? adjustedAbility.costs : [adjustedAbility.cost]).map((cost) => {
|
||||
const resolved = (adjustedAbility.costs?.length ? adjustedAbility.costs : [adjustedAbility.cost]).map((cost) => {
|
||||
const baseMana = cost.percentageBasis === "base-mana"
|
||||
? adjustedAbility.source === "runewaker" && member.statRuleset === "rom"
|
||||
? deriveCharacterStats(
|
||||
@@ -307,6 +308,16 @@ export function partyAbilityCosts(member: PartyMember, ability: AbilityDefinitio
|
||||
: cost.resource !== "health" && mageClearcasting ? 0 : Math.max(0, Math.round(adjusted)),
|
||||
};
|
||||
});
|
||||
const bloodArrowCosts = romBloodArrowCastCosts(
|
||||
ability,
|
||||
member,
|
||||
auras,
|
||||
member.id,
|
||||
member.maxHealth,
|
||||
companionResourceMaximum(member, "mana"),
|
||||
now,
|
||||
);
|
||||
return [...resolved, ...bloodArrowCosts];
|
||||
}
|
||||
|
||||
export function partyAbilityIsReady(
|
||||
|
||||
@@ -112,6 +112,14 @@ import {
|
||||
romWarlockMageSoulBrandEffects,
|
||||
} from "./romWarlockMageSoulBrand";
|
||||
import { ROM_WARLOCK_PRIEST_SPREADING_PAIN_CRIT_TAG } from "./romWarlockPriestSpreadingPain";
|
||||
import {
|
||||
hasOwnedRomScoutBloodArrow,
|
||||
isRomBloodArrowOwner,
|
||||
isRomScoutRogueBloodArrowOwner,
|
||||
isRomScoutBloodArrowAbility,
|
||||
ROM_SCOUT_BLOOD_ARROW_AURA_TAG,
|
||||
romScoutRogueBloodArrowRangedAutoAttackCost,
|
||||
} from "./romBloodArrow";
|
||||
import {
|
||||
ROM_ROGUE_POISON_AURA_TAG,
|
||||
ROM_ROGUE_POISON_ATTACK_SPEED_TAG,
|
||||
@@ -2319,6 +2327,21 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu
|
||||
}
|
||||
if (now < (nextWeaponAttackAt.get(member.id) ?? 0)) return member;
|
||||
nextWeaponAttackAt.set(member.id, now + memberSwingMs(member, now));
|
||||
const scoutRogueRangedAutoAttack = isRomScoutRogueBloodArrowOwner(member)
|
||||
&& partyUsesRangedAttacks(member);
|
||||
const bloodArrowAutoAttackCost = scoutRogueRangedAutoAttack
|
||||
? romScoutRogueBloodArrowRangedAutoAttackCost(
|
||||
member,
|
||||
useCombatStore.getState().auras,
|
||||
member.id,
|
||||
member.maxHealth,
|
||||
now,
|
||||
)
|
||||
: 0;
|
||||
if (bloodArrowAutoAttackCost > 0 && member.health <= bloodArrowAutoAttackCost) return member;
|
||||
if (bloodArrowAutoAttackCost > 0) {
|
||||
member = { ...member, health: member.health - bloodArrowAutoAttackCost };
|
||||
}
|
||||
const abilityId = "party-auto-attack";
|
||||
const scentRank = member.classId === "death-knight"
|
||||
? member.talentRanks[WOW335_DEATH_KNIGHT_SCENT_OF_BLOOD_TALENT_ID] ?? 0
|
||||
@@ -2351,7 +2374,20 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu
|
||||
? "rom-rogue-dual-wield-main-hand"
|
||||
: forgeAutoAttackActive ? "rom-champion-forge-auto-attack" : "scent-of-blood-auto-attack"}`;
|
||||
useCombatStore.getState().notifyOwnerHostileAction(member.id, targetId);
|
||||
const ownerAttackDamage = partyAttackDamage(member, now);
|
||||
const baseOwnerAttackDamage = partyAttackDamage(member, now);
|
||||
const bloodArrowRangedAuras = scoutRogueRangedAutoAttack
|
||||
? activeAuras.filter((aura) => aura.sourceId === member.id
|
||||
&& aura.targetId === member.id
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG))
|
||||
: [];
|
||||
const ownerAttackDamage = bloodArrowRangedAuras.length > 0
|
||||
? Math.max(1, Math.round(resolveAuraDamageValue(baseOwnerAttackDamage, bloodArrowRangedAuras, {
|
||||
direction: "dealt",
|
||||
school: "physical",
|
||||
attackKind: "ranged",
|
||||
})))
|
||||
: baseOwnerAttackDamage;
|
||||
const result = useCombatStore.getState().damageMob(targetId, ownerAttackDamage, now,
|
||||
{ actorId: member.id, role: member.role, abilityId }, "physical", needsAttackRoll ? {
|
||||
attackKind: goForTheThroatRank > 0 ? "ranged" : "melee",
|
||||
@@ -5166,8 +5202,12 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
if (member.activeCast) {
|
||||
const activeCast = member.activeCast;
|
||||
const catalogAbility = abilityById(member.activeCast.abilityId);
|
||||
const castingAbility = catalogAbility
|
||||
? learnedAbilityForBuild(catalogAbility, member.level, member.talentRanks)
|
||||
const ownerAbility = catalogAbility && isRomScoutBloodArrowAbility(catalogAbility)
|
||||
? abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.find((ability) => ability.id === catalogAbility.id) ?? catalogAbility
|
||||
: catalogAbility;
|
||||
const castingAbility = ownerAbility
|
||||
? learnedAbilityForBuild(ownerAbility, member.level, member.talentRanks)
|
||||
: null;
|
||||
const castTargetId = member.activeCastTargetId;
|
||||
if (!castingAbility || !castTargetId) {
|
||||
@@ -5574,15 +5614,27 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
&& aura.definition.id === effect.aura.id && aura.targetId === abilityTargetId));
|
||||
},
|
||||
});
|
||||
const selectedAbility = selectedDecision?.ability ?? null;
|
||||
const bloodArrowAbility = isRomBloodArrowOwner(runtimeMember)
|
||||
? abilitiesForCharacter(runtimeMember.classId, runtimeMember.secondaryClassId)
|
||||
.find(isRomScoutBloodArrowAbility) ?? null
|
||||
: null;
|
||||
const shouldActivateBloodArrow = Boolean(bloodArrowAbility
|
||||
&& !hasOwnedRomScoutBloodArrow(currentCombat.auras, runtimeMember.id, now)
|
||||
&& partyAbilityIsReady(runtimeMember, bloodArrowAbility, now)
|
||||
&& partyAbilityTargetIsValid(runtimeMember, bloodArrowAbility, runtimeMember.id, undefined, now));
|
||||
const selectedAbility = shouldActivateBloodArrow
|
||||
? bloodArrowAbility
|
||||
: selectedDecision?.ability ?? null;
|
||||
if (!selectedAbility) {
|
||||
const inAttackRange = targetVisible && targetPosition
|
||||
&& distanceSquared(memberPosition, targetPosition) <= partyAttackRange(member) ** 2;
|
||||
nextMembers[index] = { ...runtimeMember, status: inAttackRange ? desiredStatus : "advancing", nextActionAt: now + 250 };
|
||||
continue;
|
||||
}
|
||||
const abilityTargetId = selectedDecision?.targetId
|
||||
?? (selectedAbility.target === "self" || selectedAbility.target === "friendly" ? member.id : memberTargetId);
|
||||
const abilityTargetId = shouldActivateBloodArrow
|
||||
? member.id
|
||||
: selectedDecision?.targetId
|
||||
?? (selectedAbility.target === "self" || selectedAbility.target === "friendly" ? member.id : memberTargetId);
|
||||
const inRange = (selectedAbility.target === "self" || targetVisible)
|
||||
&& partyAbilityTargetIsValid(runtimeMember, selectedAbility, abilityTargetId, undefined, now);
|
||||
if (!inRange) {
|
||||
|
||||
@@ -37,6 +37,7 @@ import { romScoutPriestScreenAttackCatalogOverride } from "./romScoutPriestScree
|
||||
import { romScoutPriestDrumsOfWarCatalogOverride } from "./romScoutPriestOverrides";
|
||||
import { romPriestScoutCuringShotCatalogOverride } from "./romPriestScoutCuringShot";
|
||||
import { romWarlockWarriorEnragedCoaAdditionalAbilities } from "./romWarlockWarriorEnragedCoaMechanics";
|
||||
import { ROM_SCOUT_BLOOD_ARROW_AURA_TAG } from "./romBloodArrow";
|
||||
import {
|
||||
ROM_WARLOCK_MAGE_SOUL_BRAND_STING_COST,
|
||||
ROM_WARLOCK_MAGE_SOUL_BRAND_STING_DESCRIPTION,
|
||||
@@ -1168,7 +1169,7 @@ function effects(row: GeneratedSemanticRow): readonly AbilityEffect[] {
|
||||
if (row.sourceSkillId === 490434) {
|
||||
return translated.map((effect) => effect.kind === "apply-aura" ? {
|
||||
...effect,
|
||||
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), "rom-self-health-drain"] },
|
||||
aura: { ...effect.aura, utilityTags: [...(effect.aura.utilityTags ?? []), ROM_SCOUT_BLOOD_ARROW_AURA_TAG] },
|
||||
} : effect);
|
||||
}
|
||||
if (row.sourceSkillId === 498770) {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { AbilityDefinition, ResourceType } from "./abilityCatalog";
|
||||
import type { ActiveAura } from "./combatAuras";
|
||||
|
||||
export const ROM_SCOUT_BLOOD_ARROW_ABILITY_ID = "rom-scout-general-4";
|
||||
export const ROM_SCOUT_BLOOD_ARROW_SOURCE_SKILL_ID = 490434;
|
||||
export const ROM_SCOUT_BLOOD_ARROW_AURA_TAG = "rom-self-health-drain";
|
||||
export const ROM_SCOUT_BLOOD_ARROW_DEACTIVATION_COOLDOWN_MS = 10_000;
|
||||
|
||||
export interface RomBloodArrowOwnerBuild {
|
||||
readonly classId: string;
|
||||
readonly secondaryClassId?: string | null;
|
||||
}
|
||||
|
||||
export function isRomScoutBloodArrowAbility(ability: AbilityDefinition): boolean {
|
||||
return ability.source === "runewaker"
|
||||
&& ability.id === ROM_SCOUT_BLOOD_ARROW_ABILITY_ID
|
||||
&& ability.classId === "rom-scout"
|
||||
&& ability.secondaryClassId === undefined
|
||||
&& ability.dbcSpellId === ROM_SCOUT_BLOOD_ARROW_SOURCE_SKILL_ID;
|
||||
}
|
||||
|
||||
export function isRomKnightScoutBloodArrowOwner(owner: RomBloodArrowOwnerBuild): boolean {
|
||||
return owner.classId === "rom-knight" && owner.secondaryClassId === "rom-scout";
|
||||
}
|
||||
|
||||
export function isRomScoutRogueBloodArrowOwner(owner: RomBloodArrowOwnerBuild): boolean {
|
||||
return owner.classId === "rom-scout" && owner.secondaryClassId === "rom-rogue";
|
||||
}
|
||||
|
||||
export function isRomBloodArrowOwner(owner: RomBloodArrowOwnerBuild): boolean {
|
||||
return isRomKnightScoutBloodArrowOwner(owner) || isRomScoutRogueBloodArrowOwner(owner);
|
||||
}
|
||||
|
||||
function bloodArrowDescriptionForOwner(owner: RomBloodArrowOwnerBuild): string {
|
||||
if (isRomKnightScoutBloodArrowOwner(owner)) {
|
||||
return "Activates Blood Arrow and increases ranged weapon damage. Each offensive ranged weapon skill costs 2.0% HP and 1.0% MP. Right-click the buff icon or use the skill again to deactivate; the skill enters a 10-second cooldown after deactivation.";
|
||||
}
|
||||
if (isRomScoutRogueBloodArrowOwner(owner)) {
|
||||
return "Activates Blood Arrow and increases physical ranged weapon damage. Ranged attacks cost 2.0% HP each. Right-click the buff icon or use the skill again to deactivate; the skill enters a 10-second cooldown after deactivation.";
|
||||
}
|
||||
return "Activates Blood Arrow and increases physical ranged weapon damage. Right-click the buff icon or use the skill again to deactivate; the skill enters a 10-second cooldown after deactivation.";
|
||||
}
|
||||
|
||||
function indefiniteBloodArrowEffects(effects: AbilityDefinition["effects"]): AbilityDefinition["effects"] {
|
||||
const auraEffect = effects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
|
||||
if (auraEffect?.kind !== "apply-aura") return effects;
|
||||
return [{
|
||||
...auraEffect,
|
||||
aura: {
|
||||
...auraEffect.aura,
|
||||
durationMs: null,
|
||||
utilityTags: auraEffect.aura.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG)
|
||||
? auraEffect.aura.utilityTags
|
||||
: [...(auraEffect.aura.utilityTags ?? []), ROM_SCOUT_BLOOD_ARROW_AURA_TAG],
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
export function romBloodArrowAbilityForOwner(
|
||||
ability: AbilityDefinition,
|
||||
owner: RomBloodArrowOwnerBuild,
|
||||
): AbilityDefinition {
|
||||
if (!isRomScoutBloodArrowAbility(ability) || !isRomBloodArrowOwner(owner)) return ability;
|
||||
const description = bloodArrowDescriptionForOwner(owner);
|
||||
return {
|
||||
...ability,
|
||||
description,
|
||||
cooldownMs: 0,
|
||||
effects: indefiniteBloodArrowEffects(ability.effects),
|
||||
ranks: ability.ranks?.map((rank) => ({
|
||||
...rank,
|
||||
description,
|
||||
cooldownMs: 0,
|
||||
effects: indefiniteBloodArrowEffects(rank.effects),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function isRomKnightScoutOffensiveRangedWeaponSkill(
|
||||
ability: AbilityDefinition,
|
||||
owner: RomBloodArrowOwnerBuild,
|
||||
): boolean {
|
||||
if (!isRomKnightScoutBloodArrowOwner(owner)
|
||||
|| ability.source !== "runewaker"
|
||||
|| ability.passive
|
||||
|| ability.dbcSpellId === ROM_SCOUT_BLOOD_ARROW_SOURCE_SKILL_ID
|
||||
|| ability.dbcSpellId === 492589 // Autoshot is an ongoing mode, not a cast trigger.
|
||||
|| ability.sourceAttackKind === "melee"
|
||||
|| !ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "dot")) return false;
|
||||
|
||||
const scoutSkill = ability.classId === "rom-scout"
|
||||
&& ability.secondaryClassId === undefined
|
||||
&& ability.romSkillGroup === "general";
|
||||
const knightScoutElite = ability.classId === "rom-knight" && ability.secondaryClassId === "rom-scout";
|
||||
return (scoutSkill || knightScoutElite)
|
||||
&& (/ranged weapon DPS/i.test(ability.description) || ability.dbcSpellId === 490420);
|
||||
}
|
||||
|
||||
export function isRomScoutRogueRangedAttackSkill(
|
||||
ability: AbilityDefinition,
|
||||
owner: RomBloodArrowOwnerBuild,
|
||||
): boolean {
|
||||
if (!isRomScoutRogueBloodArrowOwner(owner)
|
||||
|| ability.source !== "runewaker"
|
||||
|| ability.passive
|
||||
|| ability.dbcSpellId === ROM_SCOUT_BLOOD_ARROW_SOURCE_SKILL_ID
|
||||
|| ability.dbcSpellId === 492589 // Autoshot is an ongoing mode, not a cast trigger.
|
||||
|| ability.sourceAttackKind === "melee"
|
||||
|| !ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "dot")) return false;
|
||||
|
||||
const scoutGeneralSkill = ability.classId === "rom-scout"
|
||||
&& ability.secondaryClassId === undefined
|
||||
&& (ability.romSkillGroup === "general" || ability.romSkillGroup === "primary");
|
||||
return scoutGeneralSkill
|
||||
&& (/ranged weapon DPS/i.test(ability.description) || ability.dbcSpellId === 490420);
|
||||
}
|
||||
|
||||
export function romBloodArrowCastCosts(
|
||||
ability: AbilityDefinition,
|
||||
owner: RomBloodArrowOwnerBuild,
|
||||
auras: readonly ActiveAura[],
|
||||
ownerId: string,
|
||||
maxHealth: number,
|
||||
maxMana: number,
|
||||
now?: number,
|
||||
): readonly { readonly resource: ResourceType; readonly amount: number }[] {
|
||||
if (!isRomBloodArrowOwner(owner)
|
||||
|| !auras.some((aura) => aura.sourceId === ownerId
|
||||
&& aura.targetId === ownerId
|
||||
&& (now === undefined || aura.expiresAt === null || aura.expiresAt > now)
|
||||
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG))) return [];
|
||||
|
||||
if (isRomKnightScoutBloodArrowOwner(owner)
|
||||
&& isRomKnightScoutOffensiveRangedWeaponSkill(ability, owner)) {
|
||||
return [
|
||||
{ resource: "health", amount: Math.max(0, Math.round(maxHealth * 0.02)) },
|
||||
{ resource: "mana", amount: Math.max(0, Math.round(maxMana * 0.01)) },
|
||||
];
|
||||
}
|
||||
|
||||
if (isRomScoutRogueBloodArrowOwner(owner)
|
||||
&& isRomScoutRogueRangedAttackSkill(ability, owner)) {
|
||||
return [{ resource: "health", amount: Math.max(0, Math.round(maxHealth * 0.02)) }];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function romScoutRogueBloodArrowRangedAutoAttackCost(
|
||||
owner: RomBloodArrowOwnerBuild,
|
||||
auras: readonly ActiveAura[],
|
||||
ownerId: string,
|
||||
maxHealth: number,
|
||||
now: number,
|
||||
): number {
|
||||
return isRomScoutRogueBloodArrowOwner(owner)
|
||||
&& hasOwnedRomScoutBloodArrow(auras, ownerId, now)
|
||||
? Math.max(0, Math.round(maxHealth * 0.02))
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function hasOwnedRomScoutBloodArrow(
|
||||
auras: readonly ActiveAura[],
|
||||
ownerId: string,
|
||||
now: number,
|
||||
): boolean {
|
||||
return auras.some((aura) => aura.sourceId === ownerId
|
||||
&& aura.targetId === ownerId
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG));
|
||||
}
|
||||
|
||||
export function isOwnedRomScoutBloodArrowAura(aura: ActiveAura, ownerId: string): boolean {
|
||||
return aura.sourceId === ownerId
|
||||
&& aura.targetId === ownerId
|
||||
&& aura.definition.utilityTags?.includes(ROM_SCOUT_BLOOD_ARROW_AURA_TAG) === true;
|
||||
}
|
||||
|
||||
export function deactivateRomBloodArrowFromAuraContextMenu(
|
||||
aura: ActiveAura,
|
||||
ownerId: string,
|
||||
deactivate: (ownerId: string, now?: number) => boolean,
|
||||
now?: number,
|
||||
): boolean {
|
||||
return isOwnedRomScoutBloodArrowAura(aura, ownerId)
|
||||
&& deactivate(ownerId, now);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { abilitiesForCharacter, abilityAtLevel } from "./abilityCatalog";
|
||||
import { ROM_ALL_ABILITIES } from "./romAbilityCatalog";
|
||||
import { commitPartyAbilityUse, partyAbilityCosts, partyAbilityIsReady } from "./partyAbilityAi";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import {
|
||||
clearPartyRuntimeRegistry,
|
||||
@@ -9,7 +11,8 @@ import {
|
||||
} from "./partyRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { abilityDamageSchool, useCombatStore } from "./combatStore";
|
||||
import { abilityDamageSchool, resolvedAbilityCosts, useCombatStore } from "./combatStore";
|
||||
import { deactivateRomBloodArrowFromAuraContextMenu } from "./romBloodArrow";
|
||||
|
||||
const BLOOD_ARROW_ID = "rom-scout-general-4";
|
||||
const HOLY_STRIKE_ID = "rom-knight-general-1";
|
||||
@@ -49,18 +52,26 @@ function runPlayerDamageComparison(passiveActive: boolean) {
|
||||
function companionWithBuild(
|
||||
seed: string,
|
||||
name: string,
|
||||
classId: "rom-knight" | "rom-mage",
|
||||
classId: "rom-knight" | "rom-mage" | "rom-scout",
|
||||
statRuleset: "rom" | "wow" = "rom",
|
||||
) {
|
||||
const secondaryClassId = classId === "rom-knight" ? "rom-scout"
|
||||
: classId === "rom-scout" ? "rom-rogue" : undefined;
|
||||
const specialization = classId === "rom-knight" ? "Knight/Scout"
|
||||
: classId === "rom-scout" ? "Scout/Rogue" : "Mage";
|
||||
const specializationId = classId === "rom-knight" ? "rom-knight:rom-scout"
|
||||
: classId === "rom-scout" ? "rom-scout:rom-rogue" : "rom-mage";
|
||||
const talentTreeId = classId === "rom-knight" ? "rom-knight-rom-scout"
|
||||
: classId === "rom-scout" ? "rom-scout-rom-rogue" : "rom-mage";
|
||||
return createConfiguredPartyMember({
|
||||
name,
|
||||
classId,
|
||||
statRuleset,
|
||||
...(classId === "rom-knight" ? { secondaryClassId: "rom-scout" as const } : {}),
|
||||
...(secondaryClassId ? { secondaryClassId } : {}),
|
||||
raceId: "rom-human",
|
||||
specialization: classId === "rom-knight" ? "Knight/Scout" : "Mage",
|
||||
specializationId: classId === "rom-knight" ? "rom-knight:rom-scout" : "rom-mage",
|
||||
talentTreeId: classId === "rom-knight" ? "rom-knight-rom-scout" : "rom-mage",
|
||||
specialization,
|
||||
specializationId,
|
||||
talentTreeId,
|
||||
role: "damage",
|
||||
}, 79, 0, seed);
|
||||
}
|
||||
@@ -74,7 +85,7 @@ function runOwnedHolyStrikeComparison(useBloodPunishment: boolean, statRuleset:
|
||||
registerPartyRuntimePosition(owner.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
|
||||
const bloodArrow = ROM_ALL_ABILITIES.find((ability) => ability.dbcSpellId === 490434)!;
|
||||
const bloodArrow = abilitiesForCharacter("rom-knight", "rom-scout").find((ability) => ability.dbcSpellId === 490434)!;
|
||||
const sourceAura = bloodArrow.effects.find((effect) => effect.kind === "apply-aura"
|
||||
&& effect.recipient === "caster" && effect.aura.utilityTags?.includes(BLOOD_ARROW_TAG));
|
||||
if (sourceAura?.kind !== "apply-aura") throw new Error("Blood Arrow's source aura is missing.");
|
||||
@@ -139,6 +150,42 @@ describe("Knight/Scout Blood Punishment current-CoA runtime", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "rom-human", level: 79, role: "damage" });
|
||||
});
|
||||
|
||||
it("limits current toggle semantics to verified Scout/Rogue and Knight/Scout owners", () => {
|
||||
const base = ROM_ALL_ABILITIES.find((ability) => ability.id === BLOOD_ARROW_ID)!;
|
||||
const verified = abilitiesForCharacter("rom-scout", "rom-rogue").find((ability) => ability.id === BLOOD_ARROW_ID)!;
|
||||
const unverified = abilitiesForCharacter("rom-scout", "rom-warrior").find((ability) => ability.id === BLOOD_ARROW_ID)!;
|
||||
const verifiedRank = abilityAtLevel(verified, 79);
|
||||
const unverifiedRank = abilityAtLevel(unverified, 79);
|
||||
const verifiedAura = verified.effects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
|
||||
const unverifiedAura = unverified.effects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
|
||||
const verifiedRankAura = verifiedRank.effects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
|
||||
const unverifiedRankAura = unverifiedRank.effects.find((effect) => effect.kind === "apply-aura" && effect.recipient === "caster");
|
||||
|
||||
expect(verified).not.toBe(base);
|
||||
expect(verified.cooldownMs).toBe(0);
|
||||
expect(verified.effects.some((effect) => effect.kind === "dot")).toBe(false);
|
||||
expect(verifiedAura).toMatchObject({ kind: "apply-aura", aura: { durationMs: null } });
|
||||
expect(verifiedRank.cooldownMs).toBe(0);
|
||||
expect(verifiedRank.effects.some((effect) => effect.kind === "dot")).toBe(false);
|
||||
expect(verifiedRank.description).toContain("Ranged attacks cost 2.0% HP each");
|
||||
expect(verifiedRankAura).toMatchObject({ kind: "apply-aura", aura: { durationMs: null } });
|
||||
expect(unverified).toBe(base);
|
||||
expect(unverified.effects.some((effect) => effect.kind === "dot")).toBe(true);
|
||||
expect(unverifiedAura).toMatchObject({ kind: "apply-aura", aura: { durationMs: 30_000 } });
|
||||
expect(unverifiedRank.effects.some((effect) => effect.kind === "dot")).toBe(true);
|
||||
expect(unverifiedRank.cooldownMs).toBeGreaterThan(0);
|
||||
expect(unverifiedRankAura).toMatchObject({ kind: "apply-aura", aura: { durationMs: 30_000 } });
|
||||
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-scout", secondaryClassId: "rom-warrior", raceId: "rom-human", level: 79, role: "damage",
|
||||
});
|
||||
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
const legacyReadyAt = useCombatStore.getState().cooldowns[BLOOD_ARROW_ID];
|
||||
expect(useCombatStore.getState().deactivateRomScoutBloodArrow(PLAYER_AGGRO_ID, 1_000)).toBe(false);
|
||||
expect(useCombatStore.getState().cooldowns[BLOOD_ARROW_ID]).toBe(legacyReadyAt);
|
||||
expect(bloodArrowAura(PLAYER_AGGRO_ID)).toBeDefined();
|
||||
});
|
||||
|
||||
it("resolves the player's 15% received healing and Holy-only 5% damage rider", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 79, role: "tank",
|
||||
@@ -151,7 +198,7 @@ describe("Knight/Scout Blood Punishment current-CoA runtime", () => {
|
||||
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
|
||||
const liveAura = bloodArrowAura(PLAYER_AGGRO_ID);
|
||||
expect(liveAura?.expiresAt).toBe(31_000);
|
||||
expect(liveAura?.expiresAt).toBeNull();
|
||||
const heal = useCombatStore.getState().healActor(PLAYER_AGGRO_ID, 100, {
|
||||
sourceActorId: "test-healer", abilityId: "blood-punishment-heal-test", school: "holy", now: 2_000,
|
||||
});
|
||||
@@ -176,23 +223,211 @@ describe("Knight/Scout Blood Punishment current-CoA runtime", () => {
|
||||
expect(upgraded.physical.rawAmount).toBe(plain.physical.rawAmount);
|
||||
});
|
||||
|
||||
it("ends the player's rider when the actual Blood Arrow marker is canceled", () => {
|
||||
it("ends the player's rider on the Blood Arrow toggle and starts its owner-local cooldown", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-scout", raceId: "rom-human", level: 79, role: "tank",
|
||||
});
|
||||
useCombatStore.setState({ health: useCombatStore.getState().maxHealth - 1_000 });
|
||||
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
expect(bloodArrowAura(PLAYER_AGGRO_ID)).toBeDefined();
|
||||
const activeArrow = bloodArrowAura(PLAYER_AGGRO_ID);
|
||||
expect(activeArrow).toBeDefined();
|
||||
const unrelatedBuff = {
|
||||
...activeArrow!,
|
||||
instanceId: "unrelated-player-buff",
|
||||
definition: {
|
||||
...activeArrow!.definition,
|
||||
utilityTags: activeArrow!.definition.utilityTags?.filter((tag) => tag !== BLOOD_ARROW_TAG),
|
||||
},
|
||||
};
|
||||
expect(deactivateRomBloodArrowFromAuraContextMenu(
|
||||
unrelatedBuff,
|
||||
PLAYER_AGGRO_ID,
|
||||
useCombatStore.getState().deactivateRomScoutBloodArrow,
|
||||
2_000,
|
||||
)).toBe(false);
|
||||
expect(bloodArrowAura(PLAYER_AGGRO_ID)).toBe(activeArrow);
|
||||
|
||||
useCombatStore.getState().consumeActorAuraTag(PLAYER_AGGRO_ID, BLOOD_ARROW_TAG);
|
||||
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 2_000)).toMatchObject({ ok: true });
|
||||
|
||||
expect(bloodArrowAura(PLAYER_AGGRO_ID)).toBeUndefined();
|
||||
expect(useCombatStore.getState().cooldowns[BLOOD_ARROW_ID]).toBe(12_000);
|
||||
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 3_000)).toMatchObject({
|
||||
ok: false,
|
||||
reason: "cooldown",
|
||||
});
|
||||
const afterCancellation = useCombatStore.getState().healActor(PLAYER_AGGRO_ID, 100, {
|
||||
sourceActorId: "test-healer", abilityId: "blood-punishment-cancel-test", school: "holy", now: 2_000,
|
||||
});
|
||||
expect(afterCancellation.rawAmount).toBe(101); // Knight's independent Devotion stays; the +15% rider ends.
|
||||
});
|
||||
|
||||
it.each(["rom", "wow"] as const)("does not bind the verified player's legacy activation or idle health drain in %s compatibility", (statRuleset) => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-scout", secondaryClassId: "rom-rogue", statRuleset,
|
||||
raceId: "rom-human", level: 79, role: "damage",
|
||||
});
|
||||
const before = useCombatStore.getState();
|
||||
const bloodArrow = abilitiesForCharacter("rom-scout", "rom-rogue").find((ability) => ability.id === BLOOD_ARROW_ID)!;
|
||||
expect(resolvedAbilityCosts(bloodArrow, [], { ...before, now: 1_000 }))
|
||||
.not.toEqual(expect.arrayContaining([expect.objectContaining({ resource: "health" })]));
|
||||
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
const activeHealth = useCombatStore.getState().health;
|
||||
|
||||
useCombatStore.getState().tick(31, 32_001);
|
||||
|
||||
expect(bloodArrowAura(PLAYER_AGGRO_ID)?.expiresAt).toBeNull();
|
||||
expect(useCombatStore.getState().health).toBeGreaterThanOrEqual(activeHealth);
|
||||
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage"
|
||||
&& event.abilityId === BLOOD_ARROW_ID
|
||||
&& event.sourceActorId === PLAYER_AGGRO_ID
|
||||
&& event.targetActorId === PLAYER_AGGRO_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["rom", "wow"] as const)("charges a committed ranged skill cast once in %s player compatibility", (statRuleset) => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-knight", secondaryClassId: "rom-scout", statRuleset,
|
||||
raceId: "rom-human", level: 79, role: "damage",
|
||||
});
|
||||
const initial = useCombatStore.getState();
|
||||
const manaBefore = initial.resourcePools.mana ?? initial.resource;
|
||||
useCombatStore.getState().registerMob("blood-arrow-cost-player", {
|
||||
name: "Blood Arrow Cost Target", level: 1, maxHealth: 1_000_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("blood-arrow-cost-player", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("blood-arrow-cost-player");
|
||||
|
||||
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
const activated = useCombatStore.getState();
|
||||
expect(activated.health).toBeGreaterThanOrEqual(initial.health);
|
||||
expect(activated.resourcePools.mana ?? activated.resource).toBe(manaBefore);
|
||||
const healthBeforeShot = activated.health;
|
||||
const manaBeforeShot = activated.resourcePools.mana ?? activated.resource;
|
||||
const arrowCostHp = Math.round(activated.maxHealth * 0.02);
|
||||
const arrowCostMana = Math.round(activated.maxResource * 0.01);
|
||||
const shot = ROM_ALL_ABILITIES.find((ability) => ability.id === SCOUT_SHOT_ID)!;
|
||||
const ordinaryShotCosts = resolvedAbilityCosts(shot, [], { ...activated, now: 3_000 });
|
||||
const ordinaryHealthCost = ordinaryShotCosts.find((cost) => cost.resource === "health")?.amount ?? 0;
|
||||
const ordinaryManaCost = ordinaryShotCosts.find((cost) => cost.resource === "mana")?.amount ?? 0;
|
||||
expect(useCombatStore.getState().castAbility(SCOUT_SHOT_ID, undefined, 3_000)).toMatchObject({ ok: true });
|
||||
|
||||
const committed = useCombatStore.getState();
|
||||
expect(committed.health).toBe(healthBeforeShot - ordinaryHealthCost - arrowCostHp);
|
||||
expect(committed.resourcePools.mana ?? committed.resource).toBe(manaBeforeShot - ordinaryManaCost - arrowCostMana);
|
||||
expect(bloodArrowAura(PLAYER_AGGRO_ID)?.expiresAt).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["rom", "wow"] as const)("charges Scout/Rogue ranged skill casts 2%% HP and no added mana in %s compatibility", (statRuleset) => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rom-scout", secondaryClassId: "rom-rogue", statRuleset,
|
||||
raceId: "rom-human", level: 79, role: "damage",
|
||||
});
|
||||
const initial = useCombatStore.getState();
|
||||
const initialFocus = initial.resourcePools.focus ?? initial.resource;
|
||||
const initialMana = initial.resourcePools.mana ?? 0;
|
||||
useCombatStore.getState().registerMob("scout-rogue-blood-arrow-cost-player", {
|
||||
name: "Scout/Rogue Blood Arrow Cost Target", level: 1, maxHealth: 1_000_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("scout-rogue-blood-arrow-cost-player", [3, 0, 0]);
|
||||
useCombatStore.getState().selectMob("scout-rogue-blood-arrow-cost-player");
|
||||
|
||||
expect(useCombatStore.getState().castAbility(BLOOD_ARROW_ID, undefined, 1_000)).toMatchObject({ ok: true });
|
||||
const activated = useCombatStore.getState();
|
||||
expect(activated.health).toBeGreaterThanOrEqual(initial.health);
|
||||
expect(activated.resourcePools.focus ?? activated.resource).toBe(initialFocus);
|
||||
expect(activated.resourcePools.mana ?? 0).toBe(initialMana);
|
||||
const healthBeforeShot = activated.health;
|
||||
const focusBeforeShot = activated.resourcePools.focus ?? activated.resource;
|
||||
const arrowCostHp = Math.round(activated.maxHealth * 0.02);
|
||||
const shot = ROM_ALL_ABILITIES.find((ability) => ability.id === SCOUT_SHOT_ID)!;
|
||||
const ordinaryShotCosts = resolvedAbilityCosts(shot, [], { ...activated, now: 3_000 });
|
||||
const ordinaryHealthCost = ordinaryShotCosts.find((cost) => cost.resource === "health")?.amount ?? 0;
|
||||
const ordinaryManaCost = ordinaryShotCosts.find((cost) => cost.resource === "mana")?.amount ?? 0;
|
||||
const ordinaryFocusCost = ordinaryShotCosts.find((cost) => cost.resource === "focus")?.amount ?? 0;
|
||||
expect(useCombatStore.getState().castAbility(SCOUT_SHOT_ID, undefined, 3_000)).toMatchObject({ ok: true });
|
||||
|
||||
const committed = useCombatStore.getState();
|
||||
expect(committed.health).toBe(healthBeforeShot - ordinaryHealthCost - arrowCostHp);
|
||||
expect(committed.resourcePools.focus ?? committed.resource).toBe(focusBeforeShot - ordinaryFocusCost);
|
||||
expect(committed.resourcePools.mana ?? 0).toBe(initialMana - ordinaryManaCost);
|
||||
});
|
||||
|
||||
it("charges an owned Scout/Rogue ranged auto once and applies Blood Arrow's ranged damage modifier", () => {
|
||||
const seed = "rom-scout-rogue-blood-arrow-auto-cost";
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "rom-human", level: 79, role: "damage" });
|
||||
const owner = companionWithBuild(seed, "Scout/Rogue", "rom-scout", "wow");
|
||||
const readyOwner = { ...owner, globalCooldownEndsAt: 10_000 };
|
||||
usePartyStore.getState().initializeParty(seed, 79, useCombatStore.getState().health, "damage", 1, "rom");
|
||||
usePartyStore.setState({ members: [readyOwner], command: "attack", activeMobId: "blood-arrow-scout-auto" });
|
||||
registerPartyRuntimePosition(owner.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().registerMob("blood-arrow-scout-auto", {
|
||||
name: "Scout Blood Arrow Auto Target", level: 1, maxHealth: 1_000_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("blood-arrow-scout-auto", [3, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(owner.id, "blood-arrow-scout-auto", true);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
const inactiveSwing = useCombatStore.getState().combatEvents.find((event) => (
|
||||
event.kind === "damage" && event.sourceActorId === owner.id && event.abilityId === "party-auto-attack"
|
||||
));
|
||||
expect(inactiveSwing).toBeDefined();
|
||||
const healthBeforeActiveSwing = usePartyStore.getState().members.find((member) => member.id === owner.id)!.health;
|
||||
|
||||
const bloodArrow = abilitiesForCharacter("rom-scout", "rom-rogue").find((ability) => ability.id === BLOOD_ARROW_ID)!;
|
||||
const sourceAura = bloodArrow.effects.find((effect) => effect.kind === "apply-aura"
|
||||
&& effect.recipient === "caster" && effect.aura.utilityTags?.includes(BLOOD_ARROW_TAG));
|
||||
if (sourceAura?.kind !== "apply-aura") throw new Error("Blood Arrow's source aura is missing.");
|
||||
useCombatStore.getState().applyActorAura(owner.id, owner.id, bloodArrow, sourceAura, 2_000);
|
||||
|
||||
advanceDungeonPartyCombat(4_000);
|
||||
|
||||
const ownerAfterSwing = usePartyStore.getState().members.find((member) => member.id === owner.id)!;
|
||||
const actorAfterSwing = useCombatStore.getState().actors[owner.id];
|
||||
const activeSwings = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.sourceActorId === owner.id && event.abilityId === "party-auto-attack"
|
||||
));
|
||||
expect(ownerAfterSwing.health).toBe(healthBeforeActiveSwing - Math.round(ownerAfterSwing.maxHealth * 0.02));
|
||||
expect(actorAfterSwing.health).toBe(ownerAfterSwing.health);
|
||||
expect(activeSwings).toHaveLength(2);
|
||||
expect(activeSwings[1].rawAmount).toBe(Math.round(activeSwings[0].rawAmount * 1.1));
|
||||
});
|
||||
|
||||
it.each(["rom", "wow"] as const)("activates and protects the owned Knight/Scout in %s compatibility", (statRuleset) => {
|
||||
const seed = `rom-knight-scout-blood-arrow-cost:${statRuleset}`;
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", raceId: "rom-human", level: 79, role: "damage" });
|
||||
const owner = companionWithBuild(seed, "Knight/Scout", "rom-knight", statRuleset);
|
||||
usePartyStore.getState().initializeParty(seed, 79, useCombatStore.getState().health, "damage", 1, "rom");
|
||||
usePartyStore.setState({ members: [owner], command: "attack", activeMobId: "blood-arrow-cost-owned" });
|
||||
registerPartyRuntimePosition(owner.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().registerMob("blood-arrow-cost-owned", {
|
||||
name: "Owned Blood Arrow Cost Target", level: 1, maxHealth: 1_000_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("blood-arrow-cost-owned", [3, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(owner.id, "blood-arrow-cost-owned", true);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
const activatedMember = usePartyStore.getState().members.find((member) => member.id === owner.id)!;
|
||||
expect(bloodArrowAura(owner.id)?.expiresAt).toBeNull();
|
||||
const shot = abilitiesForCharacter("rom-knight", "rom-scout")
|
||||
.find((ability) => ability.id === SCOUT_SHOT_ID)!;
|
||||
const costs = partyAbilityCosts(activatedMember, shot, 3_000);
|
||||
expect(costs).toEqual(expect.arrayContaining([
|
||||
{ resource: "health", amount: Math.round(activatedMember.maxHealth * 0.02) },
|
||||
{ resource: "mana", amount: Math.round(activatedMember.maxResource * 0.01) },
|
||||
]));
|
||||
expect(partyAbilityIsReady({
|
||||
...activatedMember,
|
||||
health: Math.round(activatedMember.maxHealth * 0.02),
|
||||
}, shot, 3_000)).toBe(false);
|
||||
|
||||
const committed = commitPartyAbilityUse(activatedMember, shot, 3_000);
|
||||
expect(committed?.health).toBe(activatedMember.health - Math.round(activatedMember.maxHealth * 0.02));
|
||||
expect(committed?.resourcePools.mana).toBe((activatedMember.resourcePools.mana ?? activatedMember.resource)
|
||||
- Math.round(activatedMember.maxResource * 0.01));
|
||||
});
|
||||
|
||||
it("uses the explicit native school for Earth Chain and the physical Wind Arrows hit", () => {
|
||||
for (const [sourceSkillId, expectedSchool] of [[494025, "nature"], [491128, "physical"]] as const) {
|
||||
const ability = ROM_ALL_ABILITIES.find((candidate) => candidate.dbcSpellId === sourceSkillId);
|
||||
@@ -233,7 +468,7 @@ describe("Knight/Scout Blood Punishment current-CoA runtime", () => {
|
||||
|
||||
const activeAura = bloodArrowAura(knightScout.id);
|
||||
expect(activeAura?.sourceId).toBe(knightScout.id);
|
||||
expect(activeAura?.expiresAt).toBe(31_000);
|
||||
expect(activeAura?.expiresAt).toBeNull();
|
||||
expect(activeAura?.definition.modifiers).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "healing", direction: "taken", value: 0.15 }),
|
||||
expect.objectContaining({ kind: "damage", direction: "dealt", school: "holy", value: 0.05 }),
|
||||
@@ -256,13 +491,15 @@ describe("Knight/Scout Blood Punishment current-CoA runtime", () => {
|
||||
});
|
||||
expect(playerHeal.rawAmount).toBe(100);
|
||||
|
||||
useCombatStore.getState().tick(30.1, 31_001);
|
||||
expect(bloodArrowAura(knightScout.id)).toBeUndefined();
|
||||
useCombatStore.getState().tick(60, 61_001);
|
||||
expect(bloodArrowAura(knightScout.id)).toBeDefined();
|
||||
usePartyStore.getState().damageMember(knightScout.id, 100);
|
||||
const afterExpiryHeal = useCombatStore.getState().healActor(knightScout.id, 100, {
|
||||
sourceActorId: PLAYER_AGGRO_ID, abilityId: "owned-blood-punishment-expiry-test", school: "holy", now: 31_002,
|
||||
expect(useCombatStore.getState().deactivateRomScoutBloodArrow(knightScout.id, 61_002)).toBe(true);
|
||||
expect(bloodArrowAura(knightScout.id)).toBeUndefined();
|
||||
const afterDeactivationHeal = useCombatStore.getState().healActor(knightScout.id, 100, {
|
||||
sourceActorId: PLAYER_AGGRO_ID, abilityId: "owned-blood-punishment-deactivation-test", school: "holy", now: 61_003,
|
||||
});
|
||||
expect(afterExpiryHeal.rawAmount).toBe(101); // Devotion remains learned; only Blood Arrow's +15% ended.
|
||||
expect(afterDeactivationHeal.rawAmount).toBe(101); // Devotion remains learned; only Blood Arrow's +15% ended.
|
||||
});
|
||||
|
||||
it("resolves the owned NPC's Holy damage rider through its cast and damage path", () => {
|
||||
|
||||
Reference in new issue
Block a user