Release Healer Man 0.1.55
This commit is contained in:
1 parent
2dbb0acfc1
commit
38b3d4d160
14 files changed
+279
-41
No files matched your search
@@ -7,6 +7,7 @@ import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||
|
||||
function registerTarget(id = "target", distance = 2): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
|
||||
@@ -150,20 +151,36 @@ describe("named aura combat runtime", () => {
|
||||
const summonIds = prepared.summons.map((summon) => summon.id);
|
||||
useCombatStore.getState().applyPrecombatPreparation(now + 1_000);
|
||||
expect(useCombatStore.getState().summons.map((summon) => summon.id)).toEqual(summonIds);
|
||||
|
||||
registerTarget("pet-target");
|
||||
useCombatStore.getState().damageMob("pet-target", 1, now + 1_100);
|
||||
const beforePetAttack = useCombatStore.getState().mobs["pet-target"].health;
|
||||
useCombatStore.getState().tick(0.1, now + 2_100);
|
||||
expect(useCombatStore.getState().mobs["pet-target"].health).toBeLessThan(beforePetAttack);
|
||||
});
|
||||
|
||||
it("consumes a named absorb before player health", () => {
|
||||
const now = Date.now();
|
||||
const events: StructuredCombatEvent[] = [];
|
||||
const stop = subscribeCombatEvents((event) => events.push(event));
|
||||
expect(useCombatStore.getState().castAbility(
|
||||
"wow335-priest-power-word-shield",
|
||||
undefined,
|
||||
now,
|
||||
).ok).toBe(true);
|
||||
stop();
|
||||
const beforeHealth = useCombatStore.getState().health;
|
||||
const beforeAbsorb = useCombatStore.getState().auras
|
||||
.flatMap((aura) => aura.absorbs)
|
||||
.reduce((total, absorb) => total + absorb.remaining, 0);
|
||||
expect(beforeAbsorb).toBeGreaterThan(0);
|
||||
expect(events).toContainEqual(expect.objectContaining({
|
||||
kind: "absorb",
|
||||
sourceActorId: PLAYER_AGGRO_ID,
|
||||
targetActorId: PLAYER_AGGRO_ID,
|
||||
abilityId: "wow335-priest-power-word-shield",
|
||||
effectiveAmount: beforeAbsorb,
|
||||
}));
|
||||
expect(useCombatStore.getState().auras.find((aura) => aura.absorbs.length)?.definition.icon).toBe(
|
||||
useCombatStore.getState().abilities.find((ability) => (
|
||||
ability.id === "wow335-priest-power-word-shield"
|
||||
|
||||
@@ -374,6 +374,32 @@ describe("shared combat rotations", () => {
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("reserves off-profile utility for reactive needs instead of spending rotation GCDs", () => {
|
||||
const demoralizingShout = ability("warrior", "Demoralizing Shout");
|
||||
const pummel = ability("warrior", "Pummel");
|
||||
const mortalStrike = ability("warrior", "Mortal Strike");
|
||||
const arms = context({
|
||||
classId: "warrior",
|
||||
specializationId: "warrior:arms",
|
||||
primaryResource: "rage",
|
||||
resources: { rage: 100 },
|
||||
maximumResources: { rage: 100 },
|
||||
abilities: [demoralizingShout, pummel],
|
||||
knownDamagingAbilityNames: new Set([mortalStrike.name]),
|
||||
});
|
||||
|
||||
expect(evaluateCombatRotation(arms)).toBeNull();
|
||||
expect(evaluateCombatRotation({
|
||||
...arms,
|
||||
hostileTarget: {
|
||||
id: "caster",
|
||||
health: 1_000,
|
||||
maxHealth: 1_000,
|
||||
activeCast: { interruptible: true },
|
||||
},
|
||||
})?.ability.id).toBe(pummel.id);
|
||||
});
|
||||
|
||||
it("never falls a full-health friendly maintenance heal through to a hostile target", () => {
|
||||
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
|
||||
const prayer = semanticAbility("prayer", [
|
||||
|
||||
@@ -7,7 +7,7 @@ import { spendDeathKnightRunes } from "./combatResources";
|
||||
import { healingAbilityCanAidOthers } from "./healingAbilityRules";
|
||||
import type { PartyRole } from "./partyRoles";
|
||||
|
||||
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v3";
|
||||
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v4";
|
||||
|
||||
export interface RotationProfile {
|
||||
readonly id: string;
|
||||
@@ -90,7 +90,7 @@ const profile = (
|
||||
|
||||
/** Playerbots priorities translated to the executable Healer Man spell model. */
|
||||
export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([
|
||||
profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Overpower", "Execute", "Slam", "Heroic Strike"]),
|
||||
profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Bladestorm", "Overpower", "Execute", "Slam", "Heroic Strike"]),
|
||||
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodrage", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "Heroic Strike"]),
|
||||
profile("warrior:protection", "warrior", ["Defensive Stance", "Battle Stance", "Bloodrage", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "Sunder Armor", "Heroic Strike"]),
|
||||
profile("paladin:holy", "paladin", ["Concentration Aura", "Devotion Aura", "Blessing of Might", "Seal of Wisdom", "Seal of Light", "Seal of Righteousness", "Holy Shock", "Flash of Light", "Holy Light", "Sacred Shield", "Judgement of Light"]),
|
||||
@@ -430,6 +430,14 @@ function scoreAbility(
|
||||
if (maintained && (categories.has("debuff") || categories.has("buff"))) return null;
|
||||
if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null;
|
||||
if (rotationProfile && profileIndex < 0 && !damaging && (maintenanceBuff || petSummon || offensiveSummon)) return null;
|
||||
// A specialization profile is also an allow-list for utility GCDs once the
|
||||
// build has a real damage action. Reactive interrupts, taunts, threat drops,
|
||||
// and encounter-requested control already returned above; letting any other
|
||||
// imported debuff/control action through here makes characters spam buttons
|
||||
// such as Expose Armor and Demoralizing Shout instead of their rotation.
|
||||
if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction
|
||||
&& context.role !== "healer" && profileIndex < 0
|
||||
&& (categories.has("debuff") || categories.has("control") || categories.has("interrupt"))) return null;
|
||||
if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction
|
||||
&& context.role !== "healer" && profileIndex < 0 && damaging) return null;
|
||||
if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) return null;
|
||||
|
||||
+64
-22
@@ -5286,12 +5286,29 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
for (const recipientId of recipients) {
|
||||
const previousAuras = work.auras;
|
||||
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
|
||||
work.auras = [...applyCombatAura(previousAuras, {
|
||||
const auraResult = applyCombatAura(previousAuras, {
|
||||
definition: auraDefinition,
|
||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||
targetId: recipientId,
|
||||
now,
|
||||
}).auras];
|
||||
});
|
||||
work.auras = [...auraResult.auras];
|
||||
const grantedAbsorb = auraResult.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
|
||||
if (grantedAbsorb > 0) {
|
||||
recordCombatEventInWork(work, {
|
||||
occurredAt: now,
|
||||
kind: "absorb",
|
||||
sourceActorId: PLAYER_AURA_ENTITY_ID,
|
||||
targetActorId: recipientId,
|
||||
abilityId: ability.id,
|
||||
school: presentationSchoolForAbility(ability),
|
||||
rawAmount: grantedAbsorb,
|
||||
effectiveAmount: grantedAbsorb,
|
||||
overheal: 0,
|
||||
absorbed: grantedAbsorb,
|
||||
critical: false,
|
||||
});
|
||||
}
|
||||
applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras);
|
||||
if (auraDefinition.controlImmunities?.length) {
|
||||
if (
|
||||
@@ -6278,6 +6295,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
targetId: targetActorId,
|
||||
now,
|
||||
});
|
||||
const grantedAbsorb = result.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
|
||||
let health = state.health;
|
||||
let maxHealth = state.maxHealth;
|
||||
const actors = { ...state.actors };
|
||||
@@ -6316,24 +6334,40 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
}
|
||||
let combatEvents = recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: now,
|
||||
kind: "aura",
|
||||
sourceActorId,
|
||||
targetActorId,
|
||||
abilityId: ability.id,
|
||||
school: presentationSchoolForAbility(ability),
|
||||
rawAmount: 0,
|
||||
effectiveAmount: 1,
|
||||
overheal: 0,
|
||||
absorbed: 0,
|
||||
critical: false,
|
||||
});
|
||||
if (grantedAbsorb > 0) {
|
||||
combatEvents = recordCombatEvent(combatEvents, {
|
||||
occurredAt: now,
|
||||
kind: "absorb",
|
||||
sourceActorId,
|
||||
targetActorId,
|
||||
abilityId: ability.id,
|
||||
school: presentationSchoolForAbility(ability),
|
||||
rawAmount: grantedAbsorb,
|
||||
effectiveAmount: grantedAbsorb,
|
||||
overheal: 0,
|
||||
absorbed: grantedAbsorb,
|
||||
critical: false,
|
||||
});
|
||||
}
|
||||
set({
|
||||
auras: result.auras,
|
||||
health,
|
||||
maxHealth,
|
||||
actors,
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: now,
|
||||
kind: "aura",
|
||||
sourceActorId,
|
||||
targetActorId,
|
||||
abilityId: ability.id,
|
||||
school: presentationSchoolForAbility(ability),
|
||||
rawAmount: 0,
|
||||
effectiveAmount: 1,
|
||||
overheal: 0,
|
||||
absorbed: 0,
|
||||
critical: false,
|
||||
}),
|
||||
combatEvents,
|
||||
});
|
||||
if (companionActor) mirrorCompanionActor(companionActor);
|
||||
return true;
|
||||
@@ -8330,13 +8364,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
if (
|
||||
summon.health <= 0
|
||||
|| summon.controlledUntil > now
|
||||
|| summon.nextActionAt > now
|
||||
|| summon.globalCooldownEndsAt > now
|
||||
|| summon.cast
|
||||
|| summon.attackDamage <= 0
|
||||
) return summon;
|
||||
const readyToAct = summon.nextActionAt <= now && summon.globalCooldownEndsAt <= now;
|
||||
const summonAbility = abilityById(summon.sourceAbilityId);
|
||||
if (summon.kind === "totem" && summonAbility?.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot")) {
|
||||
if (!readyToAct) return summon;
|
||||
const partyMembers = usePartyStore.getState().members;
|
||||
const injured = [
|
||||
{ id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: state.playerPosition },
|
||||
@@ -8374,12 +8408,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
|
||||
}
|
||||
const targetId = summon.targetId && !work.mobs[summon.targetId]?.dead
|
||||
const targetId = summon.targetId && work.mobs[summon.targetId] && !work.mobs[summon.targetId].dead
|
||||
? summon.targetId
|
||||
: state.selectedTargetId && !work.mobs[state.selectedTargetId]?.dead
|
||||
: state.selectedTargetId && work.mobs[state.selectedTargetId] && !work.mobs[state.selectedTargetId].dead
|
||||
? state.selectedTargetId
|
||||
: Object.values(work.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
|
||||
if (!targetId) return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
|
||||
if (!targetId) return readyToAct
|
||||
? { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) }
|
||||
: summon;
|
||||
const targetPosition = getMobPosition(targetId);
|
||||
let position = summon.position;
|
||||
if (targetPosition) {
|
||||
@@ -8392,7 +8428,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
position = projectCombatGroundPoint(requested, step, position) ?? position;
|
||||
}
|
||||
if (
|
||||
Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5)
|
||||
readyToAct
|
||||
&& Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5)
|
||||
&& combatHasLineOfSight(position, targetPosition)
|
||||
) {
|
||||
damageMobInWork(
|
||||
@@ -8406,7 +8443,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
);
|
||||
}
|
||||
}
|
||||
return { ...summon, targetId, position, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
|
||||
return {
|
||||
...summon,
|
||||
targetId,
|
||||
position,
|
||||
nextActionAt: readyToAct ? now + Math.max(500, summon.pulseIntervalMs) : summon.nextActionAt,
|
||||
};
|
||||
});
|
||||
|
||||
for (const [id, mob] of Object.entries(work.mobs)) {
|
||||
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
|
||||
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
|
||||
@@ -138,6 +140,101 @@ describe("party combat runtime", () => {
|
||||
expect(useCombatStore.getState().summons.filter((summon) => (
|
||||
summon.ownerActorId === shaman.id && summon.sourceAbilityId === feralSpirit.id
|
||||
))).toHaveLength(2);
|
||||
const beforeSummonAttack = useCombatStore.getState().mobs["feral-spirit-boss"].health;
|
||||
useCombatStore.getState().tick(0.1, 10_000);
|
||||
expect(useCombatStore.getState().mobs["feral-spirit-boss"].health).toBeLessThan(beforeSummonAttack);
|
||||
});
|
||||
|
||||
it("gives companion Hunter and Warlock pets a hostile target when summoned from self", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const hunter = createConfiguredPartyMember({
|
||||
name: "Beast Mastery",
|
||||
classId: "hunter",
|
||||
specialization: "Beast Mastery",
|
||||
specializationId: "hunter:beast-mastery",
|
||||
talentTreeId: "hunter-beast-mastery",
|
||||
role: "damage",
|
||||
}, 80, 0, "pet-target-runtime");
|
||||
const warlock = createConfiguredPartyMember({
|
||||
name: "Demonology",
|
||||
classId: "warlock",
|
||||
specialization: "Demonology",
|
||||
specializationId: "warlock:demonology",
|
||||
talentTreeId: "warlock-demonology",
|
||||
talentRanks: runtimePartyTalentRanksForLevel("warlock", "Demonology", 80),
|
||||
role: "damage",
|
||||
}, 80, 1, "pet-target-runtime");
|
||||
usePartyStore.getState().initializeParty("pet-target-runtime", 80, useCombatStore.getState().health, "damage");
|
||||
usePartyStore.setState({ members: [hunter, warlock] });
|
||||
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [-14.5, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerEngagedMob("pet-runtime-boss", 100_000);
|
||||
const events: StructuredCombatEvent[] = [];
|
||||
const stop = subscribeCombatEvents((event) => events.push(event));
|
||||
for (let now = 1_000; now <= 30_000; now += 100) {
|
||||
useCombatStore.getState().tick(0.1, now);
|
||||
advanceDungeonPartyCombat(now);
|
||||
}
|
||||
stop();
|
||||
|
||||
const pets = useCombatStore.getState().summons.filter((summon) => (
|
||||
summon.ownerActorId === hunter.id || summon.ownerActorId === warlock.id
|
||||
));
|
||||
expect(new Set(pets.map((summon) => summon.ownerActorId))).toEqual(new Set([hunter.id, warlock.id]));
|
||||
expect(pets.every((summon) => summon.targetId === "pet-runtime-boss")).toBe(true);
|
||||
expect(events.filter((event) => event.kind === "damage"
|
||||
&& [hunter.id, warlock.id].includes(event.sourceActorId))).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ abilityId: "wow335-hunter-call-pet" }),
|
||||
expect.objectContaining({ abilityId: "wow335-warlock-summon-felguard" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("casts Power Word: Shield on an injured ally and grants absorb capacity", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const tank = createConfiguredPartyMember({
|
||||
name: "Tank",
|
||||
classId: "warrior",
|
||||
specialization: "Protection",
|
||||
specializationId: "warrior:protection",
|
||||
talentTreeId: "warrior-protection",
|
||||
role: "tank",
|
||||
}, 80, 0, "discipline-shield-runtime");
|
||||
const healer = createConfiguredPartyMember({
|
||||
name: "Discipline",
|
||||
classId: "priest",
|
||||
specialization: "Discipline",
|
||||
specializationId: "priest:discipline",
|
||||
talentTreeId: "priest-discipline",
|
||||
role: "healer",
|
||||
}, 80, 1, "discipline-shield-runtime");
|
||||
const priestAbilities = abilitiesForClass("priest");
|
||||
const shield = priestAbilities.find((ability) => ability.name === "Power Word: Shield")!;
|
||||
usePartyStore.getState().initializeParty("discipline-shield-runtime", 80, useCombatStore.getState().health, "damage");
|
||||
usePartyStore.setState({
|
||||
members: [
|
||||
{ ...tank, health: Math.round(tank.maxHealth * 0.5) },
|
||||
{
|
||||
...healer,
|
||||
resource: 100_000,
|
||||
maxResource: 100_000,
|
||||
resourcePools: { mana: 100_000 },
|
||||
cooldowns: Object.fromEntries(priestAbilities.filter((ability) => ability.id !== shield.id)
|
||||
.map((ability) => [ability.id, 60_000])),
|
||||
},
|
||||
],
|
||||
});
|
||||
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerEngagedMob("discipline-shield-boss", 100_000);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
const applied = useCombatStore.getState().auras.find((aura) => (
|
||||
aura.sourceId === healer.id
|
||||
&& aura.targetId === tank.id
|
||||
&& aura.definition.name.includes("Power Word: Shield")
|
||||
));
|
||||
expect(applied?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
|
||||
|
||||
@@ -972,7 +972,8 @@ function choosePartySupportIntent(
|
||||
health: candidate.health,
|
||||
maxHealth: candidate.maxHealth,
|
||||
})),
|
||||
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind))
|
||||
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind)
|
||||
|| (effect.kind === "apply-aura" && Boolean(effect.aura.absorbs?.length)))
|
||||
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
|
||||
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
|
||||
&& (!ability.effects.some(effect => effect.kind === "heal") || target.health / target.maxHealth > 0.5))
|
||||
@@ -1162,7 +1163,12 @@ function executePartyAbility(
|
||||
comboTargetId: comboPoints > 0 ? targetId : null,
|
||||
};
|
||||
} else if (effect.kind === "summon") {
|
||||
useCombatStore.getState().summonActor(member.id, ability, effect, origin, targetId, now);
|
||||
const summonTargetId = combat.mobs[targetId] && !combat.mobs[targetId].dead
|
||||
? targetId
|
||||
: combat.selectedTargetId && combat.mobs[combat.selectedTargetId] && !combat.mobs[combat.selectedTargetId].dead
|
||||
? combat.selectedTargetId
|
||||
: Object.values(combat.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
|
||||
useCombatStore.getState().summonActor(member.id, ability, effect, origin, summonTargetId, now);
|
||||
} else if (effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) {
|
||||
const summonEffect: Extract<AbilityEffect, { kind: "summon" }> = {
|
||||
kind: "summon",
|
||||
@@ -1170,7 +1176,12 @@ function executePartyAbility(
|
||||
coefficient: 0.35,
|
||||
durationMs: Number.MAX_SAFE_INTEGER - now,
|
||||
};
|
||||
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, targetId, now);
|
||||
const summonTargetId = combat.mobs[targetId] && !combat.mobs[targetId].dead
|
||||
? targetId
|
||||
: combat.selectedTargetId && combat.mobs[combat.selectedTargetId] && !combat.mobs[combat.selectedTargetId].dead
|
||||
? combat.selectedTargetId
|
||||
: Object.values(combat.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
|
||||
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, summonTargetId, now);
|
||||
} else if (effect.kind === "movement") {
|
||||
const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
|
||||
if (targetPosition && effect.movement !== "knockback" && effect.toward !== "away") {
|
||||
|
||||
@@ -117,6 +117,16 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
||||
expect.objectContaining({ coefficient: 1.8 }),
|
||||
);
|
||||
expect(effectsOfKind(byId("wow335-warrior-execute"), "heal")).toEqual([]);
|
||||
expect(effectsOfKind(byId("wow335-warrior-bladestorm"), "dot")).toEqual([
|
||||
expect.objectContaining({ coefficient: 0.6, ticks: 6, intervalMs: 1_000 }),
|
||||
]);
|
||||
expect(byId("wow335-warrior-bladestorm")).toMatchObject({ target: "self", radius: 8 });
|
||||
expect(effectsOfKind(byId("wow335-paladin-hammer-of-the-righteous"), "damage")).toEqual([
|
||||
expect.objectContaining({ coefficient: 1.2 }),
|
||||
]);
|
||||
expect(effectsOfKind(byId("wow335-druid-swiftmend"), "heal")).toEqual([
|
||||
expect.objectContaining({ coefficient: 2.2 }),
|
||||
]);
|
||||
expect(effectsOfKind(byId("wow335-rogue-mutilate"), "damage")).toContainEqual(
|
||||
expect.objectContaining({ coefficient: 2 }),
|
||||
);
|
||||
|
||||
@@ -744,6 +744,11 @@ function classScriptEffects(
|
||||
// The player spell delegates its weapon hit to a triggered child spell;
|
||||
// retain the imported self-heal aura and restore the missing attack.
|
||||
case "warrior:Bloodthirst": return [{ kind: "damage", coefficient: 1.2 }];
|
||||
// The player-facing aura delegates each weapon strike to server-only child
|
||||
// spells. Model the six one-second swings as a periodic damage payload.
|
||||
case "warrior:Bladestorm": return [{
|
||||
kind: "dot", coefficient: 0.6, ticks: 6, intervalMs: 1_000, tag: "bladestorm",
|
||||
}];
|
||||
case "warrior:Bloodrage": return [{ kind: "resource", resource: "rage", amount: 20 }];
|
||||
case "warrior:Execute": return [{ kind: "execute", coefficient: 1.8 }];
|
||||
case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }];
|
||||
@@ -764,6 +769,9 @@ function classScriptEffects(
|
||||
case "paladin:Judgement of Justice": return [{ kind: "status", status: "root", durationMs: 10_000 }];
|
||||
case "paladin:Righteous Defense": return [{ kind: "taunt", durationMs: 3_000, maxTargets: 3 }];
|
||||
case "paladin:Hand of Salvation": return [{ kind: "threat", mode: "percent", amount: -20, recipient: "ability-target" }];
|
||||
// The DBC parent carries a one-point trigger marker; its weapon hit lives in
|
||||
// a child spell that is not present in the player-facing progression rows.
|
||||
case "paladin:Hammer of the Righteous": return [{ kind: "damage", coefficient: 1.2 }];
|
||||
case "paladin:Divine Intervention": return [{
|
||||
kind: "apply-aura",
|
||||
aura: {
|
||||
@@ -881,6 +889,9 @@ function classScriptEffects(
|
||||
triggerSpellId: 0,
|
||||
}];
|
||||
case "warlock:Inferno": return [{ kind: "summon", creatureId: 89, coefficient: 0.8, durationMs: 60_000 }];
|
||||
// Swiftmend's DBC row exposes only the one-point consume marker; supply the
|
||||
// direct heal while leaving HoT consumption out until aura choice is modeled.
|
||||
case "druid:Swiftmend": return [{ kind: "heal", coefficient: 2.2 }];
|
||||
case "druid:Pounce": return [{ kind: "dot", coefficient: 0.18, ticks: 6, intervalMs: 3_000, tag: "pounce" }];
|
||||
default: return [];
|
||||
}
|
||||
@@ -1178,6 +1189,9 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
||||
|| (classId === "priest" && abilityName === "Penance"
|
||||
&& (effect.kind === "damage" || effect.kind === "heal"))
|
||||
|| (classId === "shaman" && abilityName === "Feral Spirit" && effect.kind === "summon")
|
||||
|| (classId === "warrior" && abilityName === "Bladestorm" && effect.kind === "dot")
|
||||
|| (classId === "paladin" && abilityName === "Hammer of the Righteous" && effect.kind === "damage")
|
||||
|| (classId === "druid" && abilityName === "Swiftmend" && effect.kind === "heal")
|
||||
|| (classId === "warrior" && abilityName === "Bloodrage"
|
||||
&& (effect.kind === "resource" || effect.kind === "heal"))
|
||||
))
|
||||
@@ -1351,6 +1365,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
||||
}
|
||||
|
||||
function abilityNameRadius(abilityName: string): number {
|
||||
if (abilityName === "Bladestorm") return 8;
|
||||
if (abilityName === "Hellfire") return 10;
|
||||
if (abilityName === "Mind Sear") return 10;
|
||||
if (abilityName === "Pestilence") return 10;
|
||||
@@ -1359,7 +1374,7 @@ function abilityNameRadius(abilityName: string): number {
|
||||
}
|
||||
|
||||
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
|
||||
if (["Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
|
||||
if (["Bladestorm", "Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
|
||||
if (abilityName === "Penance") return "friendly";
|
||||
if (abilityName === "Death Coil") return "hostile";
|
||||
return undefined;
|
||||
|
||||
Reference in new issue
Block a user