forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38b3d4d160 |
No files matched your search
+11
-4
@@ -282,6 +282,10 @@ under `parties/<party-id>/` and adds these suite-level files:
|
|||||||
`totalHealing` and `totalHealingReceived` are the corresponding absolute
|
`totalHealing` and `totalHealingReceived` are the corresponding absolute
|
||||||
values across the included runs. Reports created before target attribution
|
values across the included runs. Reports created before target attribution
|
||||||
was added remain readable but require a rerun to populate healing received.
|
was added remain readable but require a rerun to populate healing received.
|
||||||
|
- Member and ability `absorbs` count shield capacity granted by both direct
|
||||||
|
shield effects and named absorb auras such as Power Word: Shield. Member
|
||||||
|
`deathEvents` counts each alive-to-dead transition, so a member that is
|
||||||
|
resurrected and defeated again contributes another death.
|
||||||
- Ability `activeRuns` counts simulations with at least one cast, completed
|
- Ability `activeRuns` counts simulations with at least one cast, completed
|
||||||
non-throughput release, hit, damage, healing, overheal, or absorb event.
|
non-throughput release, hit, damage, healing, overheal, or absorb event.
|
||||||
Completed resource, aura, pet, and summon actions are recorded as casts even
|
Completed resource, aura, pet, and summon actions are recorded as casts even
|
||||||
@@ -341,15 +345,18 @@ remains the class's primary resource. A completed companion cast resolves the
|
|||||||
member's currently learned rank before applying its effects, so cast-time spell
|
member's currently learned rank before applying its effects, so cast-time spell
|
||||||
damage and healing cannot fall back to rank one.
|
damage and healing cannot fall back to rank one.
|
||||||
|
|
||||||
`playerbots-inspired-v3` keeps level-10-and-up non-healer builds on their
|
`playerbots-inspired-v4` keeps level-10-and-up non-healer builds on their
|
||||||
specialization's authored damage priority whenever that build knows a profile
|
specialization's authored damage priority whenever that build knows a profile
|
||||||
damage action. Reactive interrupts, taunts, control, and defenses still take
|
damage action. Reactive interrupts, taunts, control, and defenses still take
|
||||||
precedence. Bear and Cat actions are isolated to their matching Feral specs;
|
precedence. Bear and Cat actions are isolated to their matching Feral specs;
|
||||||
Overpower, Revenge, and Victory Rush require an explicitly modeled trigger;
|
Overpower, Revenge, and Victory Rush require an explicitly modeled trigger;
|
||||||
and Drain Soul, Hammer of Wrath, and Kill Shot obey their target-health gates.
|
and Drain Soul, Hammer of Wrath, and Kill Shot obey their target-health gates.
|
||||||
Incidental DBC aura categories cannot outrank explicit profile actions. Rage and
|
Off-profile control, interrupt, and debuff buttons cannot consume ordinary
|
||||||
Runic Power generated during an action persist without erasing that action's
|
rotation GCDs after their reactive conditions have been checked. Incidental DBC
|
||||||
cooldown/GCD, and short-range melee abilities use companion combat reach.
|
aura categories cannot outrank explicit profile actions. Rage and Runic Power
|
||||||
|
generated during an action persist without erasing that action's cooldown/GCD;
|
||||||
|
short-range melee abilities use companion combat reach; and pets move every
|
||||||
|
combat tick while retaining their own attack cadence.
|
||||||
|
|
||||||
## Code map
|
## Code map
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ The most useful upstream starting points are `src/Ai/Class/<Class>/Strategy/` fo
|
|||||||
| Combo points | Builders run below four points; finishers require at least four points on the current target. |
|
| Combo points | Builders run below four points; finishers require at least four points on the current target. |
|
||||||
| Major cooldowns | Offensive cooldowns of 60 seconds or longer are held for bosses or durable targets. |
|
| Major cooldowns | Offensive cooldowns of 60 seconds or longer are held for bosses or durable targets. |
|
||||||
|
|
||||||
`playerbots-inspired-v3` also generates legal level-scaled talent plans for all
|
`playerbots-inspired-v4` also generates legal level-scaled talent plans for all
|
||||||
31 classic specializations. Each level-80 plan spends 51 points in the selected
|
31 classic specializations. Each level-80 plan spends 51 points in the selected
|
||||||
tree and 20 in a compatible secondary tree, prefers executable active talents
|
tree and 20 in a compatible secondary tree, prefers executable active talents
|
||||||
and their prerequisites, and sanitizes obsolete imported prerequisite links.
|
and their prerequisites, and sanitizes obsolete imported prerequisite links.
|
||||||
@@ -34,7 +34,7 @@ The leftmost available action wins after reactive rules, learned-talent gating,
|
|||||||
|
|
||||||
| Class | Specialization | Maintenance and core priority |
|
| Class | Specialization | Maintenance and core priority |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Warrior | Arms | Battle Stance; Bloodrage; Rend; Mortal Strike; Overpower; Execute; Slam; Heroic Strike |
|
| Warrior | Arms | Battle Stance; Bloodrage; Rend; Mortal Strike; Bladestorm; Overpower; Execute; Slam; Heroic Strike |
|
||||||
| Warrior | Fury | Berserker/Battle Stance; Bloodrage; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike |
|
| Warrior | Fury | Berserker/Battle Stance; Bloodrage; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike |
|
||||||
| Warrior | Protection | Defensive/Battle Stance; Bloodrage; Shield Slam; Revenge; Devastate; Thunder Clap; Sunder Armor; Heroic Strike |
|
| Warrior | Protection | Defensive/Battle Stance; Bloodrage; Shield Slam; Revenge; Devastate; Thunder Clap; Sunder Armor; Heroic Strike |
|
||||||
| Paladin | Holy | Concentration Aura; Blessing of Might; Holy Shock; Flash of Light; Holy Light; Sacred Shield; Judgement of Light |
|
| Paladin | Holy | Concentration Aura; Blessing of Might; Holy Shock; Flash of Light; Holy Light; Sacred Shield; Judgement of Light |
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.54",
|
"version": "0.1.55",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.54",
|
"version": "0.1.55",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.54",
|
"version": "0.1.55",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.54",
|
"versionName": "0.1.55",
|
||||||
"versionCode": 1054
|
"versionCode": 1055
|
||||||
}
|
}
|
||||||
@@ -379,6 +379,7 @@ async function runBoss(
|
|||||||
abilities: new Map(),
|
abilities: new Map(),
|
||||||
}));
|
}));
|
||||||
const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics]));
|
const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics]));
|
||||||
|
const actorAlive = new Map<string, boolean>();
|
||||||
const bossAbilities = new Map<string, MutableAbilityMetrics>();
|
const bossAbilities = new Map<string, MutableAbilityMetrics>();
|
||||||
// Instant resource, aura, pet, and summon actions do not produce the damage,
|
// Instant resource, aura, pet, and summon actions do not produce the damage,
|
||||||
// healing, absorb, or cast events used by the original report collector.
|
// healing, absorb, or cast events used by the original report collector.
|
||||||
@@ -435,8 +436,6 @@ async function runBoss(
|
|||||||
} else if (event.kind === "absorb" && source) {
|
} else if (event.kind === "absorb" && source) {
|
||||||
source.absorbs += event.effectiveAmount;
|
source.absorbs += event.effectiveAmount;
|
||||||
abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount;
|
abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount;
|
||||||
} else if (event.kind === "death" && target) {
|
|
||||||
target.deathEvents += 1;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -470,6 +469,9 @@ async function runBoss(
|
|||||||
memberTargetIds: {},
|
memberTargetIds: {},
|
||||||
});
|
});
|
||||||
combat.synchronizeActors();
|
combat.synchronizeActors();
|
||||||
|
for (const metrics of memberMetrics) {
|
||||||
|
actorAlive.set(metrics.actorId, currentHealth(metrics.actorId)[0] > 0);
|
||||||
|
}
|
||||||
combat.registerMob(bossActorId, {
|
combat.registerMob(bossActorId, {
|
||||||
name: entity.name,
|
name: entity.name,
|
||||||
boss: true,
|
boss: true,
|
||||||
@@ -500,6 +502,9 @@ async function runBoss(
|
|||||||
controller.act(now, activeBuild.role, runScenario, false);
|
controller.act(now, activeBuild.role, runScenario, false);
|
||||||
for (const metrics of memberMetrics) {
|
for (const metrics of memberMetrics) {
|
||||||
const [health, maxHealth] = currentHealth(metrics.actorId);
|
const [health, maxHealth] = currentHealth(metrics.actorId);
|
||||||
|
const alive = health > 0;
|
||||||
|
if ((actorAlive.get(metrics.actorId) ?? alive) && !alive) metrics.deathEvents += 1;
|
||||||
|
actorAlive.set(metrics.actorId, alive);
|
||||||
metrics.minimumHealthPercent = Math.min(
|
metrics.minimumHealthPercent = Math.min(
|
||||||
metrics.minimumHealthPercent,
|
metrics.minimumHealthPercent,
|
||||||
maxHealth > 0 ? Math.max(0, health / maxHealth) : 0,
|
maxHealth > 0 ? Math.max(0, health / maxHealth) : 0,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|||||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||||
|
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||||
|
|
||||||
function registerTarget(id = "target", distance = 2): void {
|
function registerTarget(id = "target", distance = 2): void {
|
||||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
|
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);
|
const summonIds = prepared.summons.map((summon) => summon.id);
|
||||||
useCombatStore.getState().applyPrecombatPreparation(now + 1_000);
|
useCombatStore.getState().applyPrecombatPreparation(now + 1_000);
|
||||||
expect(useCombatStore.getState().summons.map((summon) => summon.id)).toEqual(summonIds);
|
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", () => {
|
it("consumes a named absorb before player health", () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const events: StructuredCombatEvent[] = [];
|
||||||
|
const stop = subscribeCombatEvents((event) => events.push(event));
|
||||||
expect(useCombatStore.getState().castAbility(
|
expect(useCombatStore.getState().castAbility(
|
||||||
"wow335-priest-power-word-shield",
|
"wow335-priest-power-word-shield",
|
||||||
undefined,
|
undefined,
|
||||||
now,
|
now,
|
||||||
).ok).toBe(true);
|
).ok).toBe(true);
|
||||||
|
stop();
|
||||||
const beforeHealth = useCombatStore.getState().health;
|
const beforeHealth = useCombatStore.getState().health;
|
||||||
const beforeAbsorb = useCombatStore.getState().auras
|
const beforeAbsorb = useCombatStore.getState().auras
|
||||||
.flatMap((aura) => aura.absorbs)
|
.flatMap((aura) => aura.absorbs)
|
||||||
.reduce((total, absorb) => total + absorb.remaining, 0);
|
.reduce((total, absorb) => total + absorb.remaining, 0);
|
||||||
expect(beforeAbsorb).toBeGreaterThan(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(
|
expect(useCombatStore.getState().auras.find((aura) => aura.absorbs.length)?.definition.icon).toBe(
|
||||||
useCombatStore.getState().abilities.find((ability) => (
|
useCombatStore.getState().abilities.find((ability) => (
|
||||||
ability.id === "wow335-priest-power-word-shield"
|
ability.id === "wow335-priest-power-word-shield"
|
||||||
|
|||||||
@@ -374,6 +374,32 @@ describe("shared combat rotations", () => {
|
|||||||
})).toBeNull();
|
})).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", () => {
|
it("never falls a full-health friendly maintenance heal through to a hostile target", () => {
|
||||||
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
|
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
|
||||||
const prayer = semanticAbility("prayer", [
|
const prayer = semanticAbility("prayer", [
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { spendDeathKnightRunes } from "./combatResources";
|
|||||||
import { healingAbilityCanAidOthers } from "./healingAbilityRules";
|
import { healingAbilityCanAidOthers } from "./healingAbilityRules";
|
||||||
import type { PartyRole } from "./partyRoles";
|
import type { PartyRole } from "./partyRoles";
|
||||||
|
|
||||||
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v3";
|
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v4";
|
||||||
|
|
||||||
export interface RotationProfile {
|
export interface RotationProfile {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
@@ -90,7 +90,7 @@ const profile = (
|
|||||||
|
|
||||||
/** Playerbots priorities translated to the executable Healer Man spell model. */
|
/** Playerbots priorities translated to the executable Healer Man spell model. */
|
||||||
export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([
|
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: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("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"]),
|
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 (maintained && (categories.has("debuff") || categories.has("buff"))) return null;
|
||||||
if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null;
|
if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null;
|
||||||
if (rotationProfile && profileIndex < 0 && !damaging && (maintenanceBuff || petSummon || offensiveSummon)) 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
|
if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction
|
||||||
&& context.role !== "healer" && profileIndex < 0 && damaging) return null;
|
&& context.role !== "healer" && profileIndex < 0 && damaging) return null;
|
||||||
if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) 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) {
|
for (const recipientId of recipients) {
|
||||||
const previousAuras = work.auras;
|
const previousAuras = work.auras;
|
||||||
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
|
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,
|
definition: auraDefinition,
|
||||||
sourceId: PLAYER_AURA_ENTITY_ID,
|
sourceId: PLAYER_AURA_ENTITY_ID,
|
||||||
targetId: recipientId,
|
targetId: recipientId,
|
||||||
now,
|
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);
|
applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras);
|
||||||
if (auraDefinition.controlImmunities?.length) {
|
if (auraDefinition.controlImmunities?.length) {
|
||||||
if (
|
if (
|
||||||
@@ -6278,6 +6295,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
targetId: targetActorId,
|
targetId: targetActorId,
|
||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
|
const grantedAbsorb = result.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
|
||||||
let health = state.health;
|
let health = state.health;
|
||||||
let maxHealth = state.maxHealth;
|
let maxHealth = state.maxHealth;
|
||||||
const actors = { ...state.actors };
|
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({
|
set({
|
||||||
auras: result.auras,
|
auras: result.auras,
|
||||||
health,
|
health,
|
||||||
maxHealth,
|
maxHealth,
|
||||||
actors,
|
actors,
|
||||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
combatEvents,
|
||||||
occurredAt: now,
|
|
||||||
kind: "aura",
|
|
||||||
sourceActorId,
|
|
||||||
targetActorId,
|
|
||||||
abilityId: ability.id,
|
|
||||||
school: presentationSchoolForAbility(ability),
|
|
||||||
rawAmount: 0,
|
|
||||||
effectiveAmount: 1,
|
|
||||||
overheal: 0,
|
|
||||||
absorbed: 0,
|
|
||||||
critical: false,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (companionActor) mirrorCompanionActor(companionActor);
|
if (companionActor) mirrorCompanionActor(companionActor);
|
||||||
return true;
|
return true;
|
||||||
@@ -8330,13 +8364,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
if (
|
if (
|
||||||
summon.health <= 0
|
summon.health <= 0
|
||||||
|| summon.controlledUntil > now
|
|| summon.controlledUntil > now
|
||||||
|| summon.nextActionAt > now
|
|
||||||
|| summon.globalCooldownEndsAt > now
|
|
||||||
|| summon.cast
|
|| summon.cast
|
||||||
|| summon.attackDamage <= 0
|
|| summon.attackDamage <= 0
|
||||||
) return summon;
|
) return summon;
|
||||||
|
const readyToAct = summon.nextActionAt <= now && summon.globalCooldownEndsAt <= now;
|
||||||
const summonAbility = abilityById(summon.sourceAbilityId);
|
const summonAbility = abilityById(summon.sourceAbilityId);
|
||||||
if (summon.kind === "totem" && summonAbility?.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot")) {
|
if (summon.kind === "totem" && summonAbility?.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot")) {
|
||||||
|
if (!readyToAct) return summon;
|
||||||
const partyMembers = usePartyStore.getState().members;
|
const partyMembers = usePartyStore.getState().members;
|
||||||
const injured = [
|
const injured = [
|
||||||
{ id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: state.playerPosition },
|
{ 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) };
|
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
|
? summon.targetId
|
||||||
: state.selectedTargetId && !work.mobs[state.selectedTargetId]?.dead
|
: state.selectedTargetId && work.mobs[state.selectedTargetId] && !work.mobs[state.selectedTargetId].dead
|
||||||
? state.selectedTargetId
|
? state.selectedTargetId
|
||||||
: Object.values(work.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
|
: 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);
|
const targetPosition = getMobPosition(targetId);
|
||||||
let position = summon.position;
|
let position = summon.position;
|
||||||
if (targetPosition) {
|
if (targetPosition) {
|
||||||
@@ -8392,7 +8428,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
position = projectCombatGroundPoint(requested, step, position) ?? position;
|
position = projectCombatGroundPoint(requested, step, position) ?? position;
|
||||||
}
|
}
|
||||||
if (
|
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)
|
&& combatHasLineOfSight(position, targetPosition)
|
||||||
) {
|
) {
|
||||||
damageMobInWork(
|
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)) {
|
for (const [id, mob] of Object.entries(work.mobs)) {
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ import {
|
|||||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||||
|
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||||
import { abilitiesForClass } from "./abilityCatalog";
|
import { abilitiesForClass } from "./abilityCatalog";
|
||||||
|
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||||
|
|
||||||
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
|
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
|
||||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
|
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
|
||||||
@@ -138,6 +140,101 @@ describe("party combat runtime", () => {
|
|||||||
expect(useCombatStore.getState().summons.filter((summon) => (
|
expect(useCombatStore.getState().summons.filter((summon) => (
|
||||||
summon.ownerActorId === shaman.id && summon.sourceAbilityId === feralSpirit.id
|
summon.ownerActorId === shaman.id && summon.sourceAbilityId === feralSpirit.id
|
||||||
))).toHaveLength(2);
|
))).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", () => {
|
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
|
||||||
|
|||||||
@@ -972,7 +972,8 @@ function choosePartySupportIntent(
|
|||||||
health: candidate.health,
|
health: candidate.health,
|
||||||
maxHealth: candidate.maxHealth,
|
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)
|
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
|
||||||
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
|
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
|
||||||
&& (!ability.effects.some(effect => effect.kind === "heal") || target.health / target.maxHealth > 0.5))
|
&& (!ability.effects.some(effect => effect.kind === "heal") || target.health / target.maxHealth > 0.5))
|
||||||
@@ -1162,7 +1163,12 @@ function executePartyAbility(
|
|||||||
comboTargetId: comboPoints > 0 ? targetId : null,
|
comboTargetId: comboPoints > 0 ? targetId : null,
|
||||||
};
|
};
|
||||||
} else if (effect.kind === "summon") {
|
} 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)) {
|
} else if (effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) {
|
||||||
const summonEffect: Extract<AbilityEffect, { kind: "summon" }> = {
|
const summonEffect: Extract<AbilityEffect, { kind: "summon" }> = {
|
||||||
kind: "summon",
|
kind: "summon",
|
||||||
@@ -1170,7 +1176,12 @@ function executePartyAbility(
|
|||||||
coefficient: 0.35,
|
coefficient: 0.35,
|
||||||
durationMs: Number.MAX_SAFE_INTEGER - now,
|
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") {
|
} else if (effect.kind === "movement") {
|
||||||
const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
|
const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
|
||||||
if (targetPosition && effect.movement !== "knockback" && effect.toward !== "away") {
|
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.objectContaining({ coefficient: 1.8 }),
|
||||||
);
|
);
|
||||||
expect(effectsOfKind(byId("wow335-warrior-execute"), "heal")).toEqual([]);
|
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(effectsOfKind(byId("wow335-rogue-mutilate"), "damage")).toContainEqual(
|
||||||
expect.objectContaining({ coefficient: 2 }),
|
expect.objectContaining({ coefficient: 2 }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -744,6 +744,11 @@ function classScriptEffects(
|
|||||||
// The player spell delegates its weapon hit to a triggered child spell;
|
// The player spell delegates its weapon hit to a triggered child spell;
|
||||||
// retain the imported self-heal aura and restore the missing attack.
|
// retain the imported self-heal aura and restore the missing attack.
|
||||||
case "warrior:Bloodthirst": return [{ kind: "damage", coefficient: 1.2 }];
|
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:Bloodrage": return [{ kind: "resource", resource: "rage", amount: 20 }];
|
||||||
case "warrior:Execute": return [{ kind: "execute", coefficient: 1.8 }];
|
case "warrior:Execute": return [{ kind: "execute", coefficient: 1.8 }];
|
||||||
case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }];
|
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: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: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" }];
|
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 [{
|
case "paladin:Divine Intervention": return [{
|
||||||
kind: "apply-aura",
|
kind: "apply-aura",
|
||||||
aura: {
|
aura: {
|
||||||
@@ -881,6 +889,9 @@ function classScriptEffects(
|
|||||||
triggerSpellId: 0,
|
triggerSpellId: 0,
|
||||||
}];
|
}];
|
||||||
case "warlock:Inferno": return [{ kind: "summon", creatureId: 89, coefficient: 0.8, durationMs: 60_000 }];
|
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" }];
|
case "druid:Pounce": return [{ kind: "dot", coefficient: 0.18, ticks: 6, intervalMs: 3_000, tag: "pounce" }];
|
||||||
default: return [];
|
default: return [];
|
||||||
}
|
}
|
||||||
@@ -1178,6 +1189,9 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
|||||||
|| (classId === "priest" && abilityName === "Penance"
|
|| (classId === "priest" && abilityName === "Penance"
|
||||||
&& (effect.kind === "damage" || effect.kind === "heal"))
|
&& (effect.kind === "damage" || effect.kind === "heal"))
|
||||||
|| (classId === "shaman" && abilityName === "Feral Spirit" && effect.kind === "summon")
|
|| (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"
|
|| (classId === "warrior" && abilityName === "Bloodrage"
|
||||||
&& (effect.kind === "resource" || effect.kind === "heal"))
|
&& (effect.kind === "resource" || effect.kind === "heal"))
|
||||||
))
|
))
|
||||||
@@ -1351,6 +1365,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
|||||||
}
|
}
|
||||||
|
|
||||||
function abilityNameRadius(abilityName: string): number {
|
function abilityNameRadius(abilityName: string): number {
|
||||||
|
if (abilityName === "Bladestorm") return 8;
|
||||||
if (abilityName === "Hellfire") return 10;
|
if (abilityName === "Hellfire") return 10;
|
||||||
if (abilityName === "Mind Sear") return 10;
|
if (abilityName === "Mind Sear") return 10;
|
||||||
if (abilityName === "Pestilence") return 10;
|
if (abilityName === "Pestilence") return 10;
|
||||||
@@ -1359,7 +1374,7 @@ function abilityNameRadius(abilityName: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
|
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 === "Penance") return "friendly";
|
||||||
if (abilityName === "Death Coil") return "hostile";
|
if (abilityName === "Death Coil") return "hostile";
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
Reference in new issue
Block a user