From fdb6739f8d6d34840a6aa26a85829bcc4106d8c8 Mon Sep 17 00:00:00 2001 From: phenom Date: Sun, 20 Sep 2026 20:15:40 -0400 Subject: [PATCH] Update Healer Man server --- docs/BALANCE_LAB.md | 6 +++ docs/COMBAT_ROTATION_MATRIX.md | 13 ++++-- src/game/abilityRules.ts | 6 +-- src/game/combatRotation.test.ts | 45 ++++++++++++++++++-- src/game/combatRotation.ts | 18 ++++++-- src/game/partyDamageAudit.test.ts | 61 +++++++++++++++++++++++++++ src/game/partyRuntime.test.ts | 46 ++++++++++++++++++++ src/game/partyRuntime.ts | 16 +++++-- src/game/wow335AbilityCatalog.test.ts | 23 ++++++++++ src/game/wow335AbilityCatalog.ts | 18 +++++++- 10 files changed, 236 insertions(+), 16 deletions(-) diff --git a/docs/BALANCE_LAB.md b/docs/BALANCE_LAB.md index 6a89d0ef..060eabd6 100644 --- a/docs/BALANCE_LAB.md +++ b/docs/BALANCE_LAB.md @@ -550,6 +550,12 @@ Deadly application at four-plus combo points only while the pending Envenom lacks enough doses. Envenom normally consumes the used doses; Orin's pinned 3/3 Master Poisoner build preserves them 100% of the time as specified by the talent. +Ready DPS pack logic is deliberately thresholded and cluster-aware: +Marksmanship uses Multi-Shot at two nearby engaged targets and Volley at three, +while Fire uses Flamestrike and Assassination uses Fan of Knives at three. +Volley's six Arcane pulses are recovered from its DBC trigger child, Fan of +Knives is centered on the Rogue, active ground effects are not replaced early, +and companion AoE does not pull unengaged enemies. ## Code map diff --git a/docs/COMBAT_ROTATION_MATRIX.md b/docs/COMBAT_ROTATION_MATRIX.md index 6f1cffcc..247d2667 100644 --- a/docs/COMBAT_ROTATION_MATRIX.md +++ b/docs/COMBAT_ROTATION_MATRIX.md @@ -41,9 +41,9 @@ The leftmost available action wins after reactive rules, learned-talent gating, | Paladin | Protection | Devotion Aura; Blessing of Might; Hammer of the Righteous; Shield of Righteousness; Holy Shield; Consecration; Judgement of Wisdom | | Paladin | Retribution | Retribution Aura; Blessing of Might; Divine Storm; Crusader Strike; Judgement of Wisdom; Consecration; Exorcism; Hammer of Wrath | | Hunter | Beast Mastery | Aspect of the Dragonhawk/Hawk; Call Pet; Kill Command; Bestial Wrath; Serpent Sting; Arcane Shot; Multi-Shot; Steady Shot | -| Hunter | Marksmanship | Aspect of the Dragonhawk/Hawk; Call Pet; Misdirection; Rapid Fire; Readiness; Kill Shot; Chimera Shot; Aimed Shot; Serpent Sting; Arcane Shot; Steady Shot; Silencing Shot; Disengage; Feign Death | +| Hunter | Marksmanship | Aspect of the Dragonhawk/Hawk; Call Pet; Misdirection; Rapid Fire; Readiness; Kill Shot; Volley (3+); Multi-Shot (2+); Chimera Shot; Aimed Shot; Serpent Sting; Arcane Shot; Steady Shot; Silencing Shot; Disengage; Feign Death | | Hunter | Survival | Aspect of the Dragonhawk/Hawk; Call Pet; Kill Shot; Explosive Shot; Black Arrow; Serpent Sting; Aimed Shot; Arcane Shot; Steady Shot | -| Rogue | Assassination | Deadly/Instant Poison; Tricks of the Trade; Cold Blood; Envenom; Shiv; Rupture; Mutilate; Eviscerate; Sinister Strike; Kick; Evasion; Cloak of Shadows; Vanish | +| Rogue | Assassination | Deadly/Instant Poison; Tricks of the Trade; Cold Blood; Fan of Knives (3+); Envenom; Shiv; Rupture; Mutilate; Eviscerate; Sinister Strike; Kick; Evasion; Cloak of Shadows; Vanish | | Rogue | Combat | Killing Spree; Blade Flurry; Eviscerate; Sinister Strike | | Rogue | Subtlety | Shadow Dance; Rupture; Eviscerate; Hemorrhage; Backstab | | Priest | Discipline | Inner Fire; Pain Suppression; Inner Focus; Power Infusion; Shadowfiend; Divine Hymn; Penance; Power Word: Shield; Flash/Greater Heal; Renew; Dispel Magic; Cure Disease; Resurrection | @@ -56,7 +56,7 @@ The leftmost available action wins after reactive rules, learned-talent gating, | Shaman | Enhancement | Windfury Weapon (Rockbiter fallback); Lightning Shield; Feral Spirit; Stormstrike; Lava Lash; Earth Shock; Lightning Bolt | | Shaman | Restoration | Water Shield; Rockbiter Weapon; Lightning Shield; Riptide; Chain Heal; Lesser Healing Wave; Healing Wave; Earth Shield | | Mage | Arcane | Mage Armor; Arcane Barrage; Arcane Blast; Arcane Missiles | -| Mage | Fire | Molten Armor; Combustion; Mirror Image; Living Bomb; Fireball; Scorch; Fire Blast; Counterspell; Blink; Ice Block; Fire/Frost Ward | +| Mage | Fire | Molten Armor; Combustion; Mirror Image; Flamestrike (3+); Living Bomb; Fireball; Scorch; Fire Blast; Counterspell; Blink; Ice Block; Fire/Frost Ward | | Mage | Frost | Frost Armor; Deep Freeze; Frostbolt; Ice Lance; Cone of Cold | | Warlock | Affliction | Fel Armor; Felhunter; Haunt; Unstable Affliction; Corruption; Curse of Agony; Drain Soul; Shadow Bolt | | Warlock | Demonology | Fel Armor; Felguard; Metamorphosis; Immolate; Corruption; Shadow Bolt | @@ -106,6 +106,13 @@ reset itself and monopolize the Hunter rotation. Blink and Disengage create distance from threat, low-health danger, or an imminent strong melee hit; Fire Blast is the instant Fire action available while moving. +Ready DPS AoE uses local engaged packs rather than every hostile in the +encounter. Marksmanship uses Multi-Shot when two targets fit its three-target +chain and channels Volley into clustered packs of three or more. Fire uses +Flamestrike at three or more targets and allows its ground fire to finish +ticking before recasting. Assassination uses the full-circle Fan of Knives at +three or more targets. Companion AoE never counts or damages unengaged packs. + Assassination treats Envenom as its primary four-plus-combo-point finisher, matching the pinned Playerbots Assassination trigger, and banks Energy rather than spending those points on cheaper Rupture while Envenom becomes affordable. diff --git a/src/game/abilityRules.ts b/src/game/abilityRules.ts index 26bc2279..5ad29c92 100644 --- a/src/game/abilityRules.ts +++ b/src/game/abilityRules.ts @@ -5,14 +5,14 @@ import type { AbilityTargetingRules, } from "./abilityCatalog"; -const CHAIN_ABILITIES = new Set(["chain heal", "chain lightning", "prayer of mending"]); +const CHAIN_ABILITIES = new Set(["chain heal", "chain lightning", "multi-shot", "prayer of mending"]); const GROUND_ABILITIES = new Set([ "blizzard", "rain of fire", "flamestrike", "death and decay", "consecration", "volley", "hurricane", "earthquake", "meteor", "healing rain", ]); const CONE_ABILITIES = new Set([ "cone of cold", "dragon's breath", "dragons breath", "shockwave", "cleave", - "swipe", "swipe (cat)", "fan of knives", "whirlwind", + "swipe", "swipe (cat)", "whirlwind", ]); const BEHIND_ABILITIES = new Set(["backstab", "ambush", "garrote", "ravage", "shred", "pounce"]); const MOVEMENT_CAST_ABILITIES = new Set(["scorch", "steady shot", "spiritwalker's grace"]); @@ -62,7 +62,7 @@ export function targetingRulesForAbility(ability: AbilityDefinition): AbilityTar requiresBehindTarget: hostile && BEHIND_ABILITIES.has(name), attackArcRadians: shape === "cone" ? Math.PI / 2 : Math.PI, ...(ability.radius ? { radius: ability.radius } : {}), - ...(shape === "chain" ? { maximumTargets: 3, jumpRange: 12 } : {}), + ...(shape === "chain" ? { maximumTargets: 3, jumpRange: name === "multi-shot" ? 10 : 12 } : {}), ...(shape === "ground" ? { groundRange: ability.range.max > 0 ? ability.range.max : name.endsWith(" trap") || name === "consecration" ? 0 : 30, radius: ability.radius ?? 8 } : {}), }); } diff --git a/src/game/combatRotation.test.ts b/src/game/combatRotation.test.ts index 08ee1ff9..1e22cfb2 100644 --- a/src/game/combatRotation.test.ts +++ b/src/game/combatRotation.test.ts @@ -209,9 +209,9 @@ describe("shared combat rotations", () => { const contracts = [ { classId: "warrior" as const, specialization: "Protection", id: "warrior:protection", actions: ["Defensive Stance", "Taunt", "Shield Bash", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "Shield Block", "Shield Wall", "Last Stand", "Enraged Regeneration"] }, { classId: "priest" as const, specialization: "Discipline", id: "priest:discipline", actions: ["Penance", "Power Word: Shield", "Flash Heal", "Greater Heal", "Renew", "Dispel Magic", "Cure Disease", "Resurrection", "Pain Suppression", "Inner Focus", "Power Infusion", "Shadowfiend"] }, - { classId: "hunter" as const, specialization: "Marksmanship", id: "hunter:marksmanship", actions: ["Call Pet", "Aspect of the Dragonhawk", "Serpent Sting", "Chimera Shot", "Aimed Shot", "Arcane Shot", "Steady Shot", "Kill Shot", "Rapid Fire", "Readiness", "Misdirection", "Feign Death", "Silencing Shot", "Disengage"] }, - { classId: "mage" as const, specialization: "Fire", id: "mage:fire", actions: ["Molten Armor", "Living Bomb", "Fireball", "Scorch", "Fire Blast", "Combustion", "Mirror Image", "Counterspell", "Fire Ward", "Frost Ward", "Ice Block", "Blink"] }, - { classId: "rogue" as const, specialization: "Assassination", id: "rogue:assassination", actions: ["Instant Poison", "Deadly Poison", "Mutilate", "Shiv", "Rupture", "Envenom", "Cold Blood", "Kick", "Tricks of the Trade", "Evasion", "Cloak of Shadows", "Vanish"] }, + { classId: "hunter" as const, specialization: "Marksmanship", id: "hunter:marksmanship", actions: ["Call Pet", "Aspect of the Dragonhawk", "Serpent Sting", "Chimera Shot", "Aimed Shot", "Arcane Shot", "Steady Shot", "Multi-Shot", "Volley", "Kill Shot", "Rapid Fire", "Readiness", "Misdirection", "Feign Death", "Silencing Shot", "Disengage"] }, + { classId: "mage" as const, specialization: "Fire", id: "mage:fire", actions: ["Molten Armor", "Living Bomb", "Fireball", "Scorch", "Fire Blast", "Flamestrike", "Combustion", "Mirror Image", "Counterspell", "Fire Ward", "Frost Ward", "Ice Block", "Blink"] }, + { classId: "rogue" as const, specialization: "Assassination", id: "rogue:assassination", actions: ["Instant Poison", "Deadly Poison", "Mutilate", "Fan of Knives", "Shiv", "Rupture", "Envenom", "Cold Blood", "Kick", "Tricks of the Trade", "Evasion", "Cloak of Shadows", "Vanish"] }, ]; for (const contract of contracts) { const profile = rotationProfileFor(contract.classId, contract.id)!; @@ -432,6 +432,45 @@ describe("shared combat rotations", () => { .toBe("Envenom"); }); + it("uses Ready DPS area actions only at their authored pack thresholds", () => { + const multiShot = ability("hunter", "Multi-Shot"); + const volley = ability("hunter", "Volley"); + const hunter = context({ + classId: "hunter", + specializationId: "hunter:marksmanship", + primaryResource: "mana", + resources: { mana: 10_000 }, + maximumResources: { mana: 10_000 }, + abilities: [volley, multiShot], + }); + expect(evaluateCombatRotation({ ...hunter, hostileCount: 1 })).toBeNull(); + expect(evaluateCombatRotation({ ...hunter, hostileCount: 2 })?.ability.name).toBe("Multi-Shot"); + expect(evaluateCombatRotation({ ...hunter, hostileCount: 3 })?.ability.name).toBe("Volley"); + + const flamestrike = ability("mage", "Flamestrike"); + const fire = context({ + classId: "mage", + specializationId: "mage:fire", + primaryResource: "mana", + resources: { mana: 10_000 }, + maximumResources: { mana: 10_000 }, + abilities: [flamestrike], + }); + expect(evaluateCombatRotation({ ...fire, hostileCount: 2 })).toBeNull(); + expect(evaluateCombatRotation({ ...fire, hostileCount: 3 })?.ability.name).toBe("Flamestrike"); + + const fan = ability("rogue", "Fan of Knives"); + const assassination = context({ + specializationId: "rogue:assassination", + primaryResource: "energy", + resources: { energy: 100 }, + maximumResources: { energy: 100 }, + abilities: [fan], + }); + expect(evaluateCombatRotation({ ...assassination, hostileCount: 2 })).toBeNull(); + expect(evaluateCombatRotation({ ...assassination, hostileCount: 3 })?.ability.name).toBe("Fan of Knives"); + }); + it("reacts before throughput and conserves long offensive cooldowns for durable targets", () => { const kick = ability("rogue", "Kick"); const strike = ability("rogue", "Sinister Strike"); diff --git a/src/game/combatRotation.ts b/src/game/combatRotation.ts index 0c2c3556..31bc34e5 100644 --- a/src/game/combatRotation.ts +++ b/src/game/combatRotation.ts @@ -111,9 +111,9 @@ export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.free profile("paladin:protection", "paladin", ["Devotion Aura", "Blessing of Might", "Seal of Vengeance", "Seal of Corruption", "Seal of Wisdom", "Seal of Righteousness", "Hammer of the Righteous", "Shield of Righteousness", "Holy Shield", "Consecration", "Judgement of Wisdom"]), profile("paladin:retribution", "paladin", ["Retribution Aura", "Devotion Aura", "Blessing of Might", "Seal of Command", "Seal of Vengeance", "Seal of Corruption", "Seal of Righteousness", "Divine Storm", "Crusader Strike", "Judgement of Wisdom", "Consecration", "Exorcism", "Hammer of Wrath"]), profile("hunter:beast-mastery", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Kill Command", "Bestial Wrath", "Serpent Sting", "Arcane Shot", "Multi-Shot", "Steady Shot"]), - profile("hunter:marksmanship", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Misdirection", "Rapid Fire", "Readiness", "Kill Shot", "Chimera Shot", "Aimed Shot", "Serpent Sting", "Arcane Shot", "Steady Shot", "Silencing Shot", "Disengage", "Feign Death"]), + profile("hunter:marksmanship", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Misdirection", "Rapid Fire", "Readiness", "Kill Shot", "Volley", "Multi-Shot", "Chimera Shot", "Aimed Shot", "Serpent Sting", "Arcane Shot", "Steady Shot", "Silencing Shot", "Disengage", "Feign Death"]), profile("hunter:survival", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Kill Shot", "Explosive Shot", "Black Arrow", "Serpent Sting", "Aimed Shot", "Arcane Shot", "Steady Shot"]), - profile("rogue:assassination", "rogue", ["Deadly Poison", "Instant Poison", "Tricks of the Trade", "Cold Blood", "Envenom", "Shiv", "Rupture", "Mutilate", "Eviscerate", "Sinister Strike", "Kick", "Evasion", "Cloak of Shadows", "Vanish"]), + profile("rogue:assassination", "rogue", ["Deadly Poison", "Instant Poison", "Tricks of the Trade", "Cold Blood", "Fan of Knives", "Envenom", "Shiv", "Rupture", "Mutilate", "Eviscerate", "Sinister Strike", "Kick", "Evasion", "Cloak of Shadows", "Vanish"]), profile("rogue:combat", "rogue", ["Killing Spree", "Blade Flurry", "Eviscerate", "Sinister Strike"]), profile("rogue:subtlety", "rogue", ["Shadow Dance", "Rupture", "Eviscerate", "Hemorrhage", "Backstab"]), profile("priest:discipline", "priest", ["Inner Fire", "Pain Suppression", "Inner Focus", "Power Infusion", "Shadowfiend", "Divine Hymn", "Penance", "Power Word: Shield", "Flash Heal", "Greater Heal", "Renew", "Dispel Magic", "Cure Disease", "Resurrection"]), @@ -126,7 +126,7 @@ export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.free profile("shaman:enhancement", "shaman", ["Windfury Weapon", "Rockbiter Weapon", "Lightning Shield", "Feral Spirit", "Stormstrike", "Lava Lash", "Earth Shock", "Lightning Bolt"]), profile("shaman:restoration", "shaman", ["Water Shield", "Rockbiter Weapon", "Lightning Shield", "Riptide", "Chain Heal", "Lesser Healing Wave", "Healing Wave", "Earth Shield"]), profile("mage:arcane", "mage", ["Mage Armor", "Ice Armor", "Frost Armor", "Arcane Barrage", "Arcane Blast", "Arcane Missiles"]), - profile("mage:fire", "mage", ["Molten Armor", "Combustion", "Mirror Image", "Living Bomb", "Fireball", "Scorch", "Fire Blast", "Counterspell", "Blink", "Ice Block", "Fire Ward", "Frost Ward"]), + profile("mage:fire", "mage", ["Molten Armor", "Combustion", "Mirror Image", "Flamestrike", "Living Bomb", "Fireball", "Scorch", "Fire Blast", "Counterspell", "Blink", "Ice Block", "Fire Ward", "Frost Ward"]), profile("mage:frost", "mage", ["Frost Armor", "Ice Armor", "Mage Armor", "Deep Freeze", "Frostbolt", "Ice Lance", "Cone of Cold"]), profile("warlock:affliction", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felhunter", "Summon Voidwalker", "Summon Imp", "Haunt", "Unstable Affliction", "Corruption", "Curse of Agony", "Drain Soul", "Shadow Bolt"]), profile("warlock:demonology", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felguard", "Summon Voidwalker", "Summon Imp", "Metamorphosis", "Immolate", "Corruption", "Shadow Bolt"]), @@ -162,6 +162,17 @@ const TARGET_HEALTH_GATES = new Map([ ["Kill Shot", 0.2], ]); +const MINIMUM_HOSTILE_TARGETS = new Map([ + ["Multi-Shot", 2], + ["Volley", 3], + ["Flamestrike", 3], + ["Fan of Knives", 3], +]); + +export function minimumHostileTargetsForAbility(abilityName: string): number { + return MINIMUM_HOSTILE_TARGETS.get(abilityName) ?? 1; +} + const BEAR_FORM_ACTION_NAMES = new Set([ "Bash", "Challenging Roar", @@ -387,6 +398,7 @@ function scoreAbility( const targetHealthGate = TARGET_HEALTH_GATES.get(ability.name); if (targetHealthGate !== undefined && targetHealth > targetHealthGate) return null; + if ((context.hostileCount ?? 1) < minimumHostileTargetsForAbility(ability.name)) return null; if (CONDITIONAL_ABILITY_NAMES.has(ability.name) && !context.availableConditionalAbilityNames?.has(ability.name)) return null; if (context.classId === "druid") { diff --git a/src/game/partyDamageAudit.test.ts b/src/game/partyDamageAudit.test.ts index 8bc62650..1d85a16f 100644 --- a/src/game/partyDamageAudit.test.ts +++ b/src/game/partyDamageAudit.test.ts @@ -6,6 +6,7 @@ import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRun import { advanceDungeonPartyCombat, partyUsesRangedAttacks, resetPartyRuntimeClock } from "./partyRuntime"; import { subscribeCombatPresentation } from "./combatPresentation"; import { abilitiesForClass } from "./abilityCatalog"; +import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime"; function partyTemplates(level: number): PartyMember[] { const templates = new Map(); @@ -76,6 +77,66 @@ describe("NPC sustained damage audit", () => { expect(useCombatStore.getState().mobs["area:c"].health).toBe(1_000); }); + for (const row of [ + { classId: "hunter" as const, specialization: "Marksmanship", specializationId: "hunter:marksmanship", abilityName: "Multi-Shot", targets: 2 }, + { classId: "hunter" as const, specialization: "Marksmanship", specializationId: "hunter:marksmanship", abilityName: "Volley", targets: 3 }, + { classId: "mage" as const, specialization: "Fire", specializationId: "mage:fire", abilityName: "Flamestrike", targets: 3 }, + { classId: "rogue" as const, specialization: "Assassination", specializationId: "rogue:assassination", abilityName: "Fan of Knives", targets: 3 }, + ]) { + it(`${row.specialization} ${row.abilityName} damages its ${row.targets}-target Ready threshold`, () => { + clearMobRuntimeRegistry(); + clearPartyRuntimeRegistry(); + resetPartyRuntimeClock(); + useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 }); + const member = createConfiguredPartyMember({ + name: `${row.specialization} area test`, + classId: row.classId, + specialization: row.specialization, + specializationId: row.specializationId, + talentTreeId: `${row.classId}-${row.specialization.toLowerCase()}`, + talentRanks: runtimePartyTalentRanksForLevel(row.classId, row.specialization, 80), + role: "damage", + }, 80, 0, `${row.classId}-${row.abilityName}-area`); + const ability = abilitiesForClass(row.classId).find((candidate) => candidate.name === row.abilityName)!; + usePartyStore.getState().initializeParty( + `${row.classId}-${row.abilityName}-area`, + 80, + useCombatStore.getState().health, + "damage", + ); + usePartyStore.setState({ + members: [{ + ...member, + cooldowns: Object.fromEntries(abilitiesForClass(row.classId) + .filter((candidate) => candidate.id !== ability.id) + .map((candidate) => [candidate.id, 60_000])), + }], + }); + useCombatStore.getState().synchronizeActors(); + registerPartyRuntimePosition(member.id, [0, 0, 0]); + const targetIds = Array.from({ length: row.targets }, (_, index) => `area-ready-${index}`); + for (const [index, id] of targetIds.entries()) { + useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0 }); + setMobPosition(id, [index * 2, 0, 0]); + useCombatStore.getState().damageMob(id, 1, 100); + } + updatePartyRuntimeTargetVisibility(member.id, targetIds[0], true); + + for (let now = 1_000; now <= 12_000; now += 100) { + useCombatStore.getState().tick(0.1, now); + advanceDungeonPartyCombat(now); + } + + const hitTargets = new Set(useCombatStore.getState().combatEvents + .filter((event) => event.kind === "damage" + && event.sourceActorId === member.id + && event.abilityId === ability.id + && event.effectiveAmount > 0) + .map((event) => event.targetActorId)); + expect(hitTargets).toEqual(new Set(targetIds)); + }); + } + it("generates Bear-form Rage from weapon hits and regenerates Cat-form Energy", () => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); diff --git a/src/game/partyRuntime.test.ts b/src/game/partyRuntime.test.ts index 3034d646..80e5f800 100644 --- a/src/game/partyRuntime.test.ts +++ b/src/game/partyRuntime.test.ts @@ -479,6 +479,52 @@ describe("party combat runtime", () => { }); }); + it("taunts a loose add off an ally while the rest of the party stays on the boss", () => { + const party = usePartyStore.getState(); + const tank = party.members.find((member) => member.role === "tank")!; + const damage = party.members.find((member) => member.role === "damage")!; + for (const member of party.members) registerPartyRuntimePosition(member.id, [0, 0, 0]); + + useCombatStore.getState().registerMob("boss-with-add", { + name: "Boss with add", + boss: true, + maxHealth: 10_000, + xpReward: 0, + }); + useCombatStore.getState().registerMob("boss-loose-add", { + name: "Loose add", + maxHealth: 1_000, + xpReward: 0, + }); + setMobPosition("boss-with-add", [2, 0, 0]); + setMobPosition("boss-loose-add", [8, 0, 0]); + useCombatStore.getState().damageMob("boss-with-add", 1, 100, { + actorId: tank.id, + role: "tank", + }); + useCombatStore.getState().damageMob("boss-loose-add", 1, 100, { + actorId: damage.id, + role: "damage", + }); + useCombatStore.getState().selectMob("boss-with-add"); + for (const member of party.members) { + updatePartyRuntimeTargetVisibility(member.id, "boss-with-add", true); + updatePartyRuntimeTargetVisibility(member.id, "boss-loose-add", true); + } + useCombatStore.getState().advanceMobCombat(200); + + advanceDungeonPartyCombat(1_000); + + const runtime = usePartyStore.getState(); + expect(runtime.memberTargetIds[tank.id]).toBe("boss-loose-add"); + expect(runtime.memberTargetIds[damage.id]).toBe("boss-with-add"); + expect(useCombatStore.getState().mobs["boss-loose-add"].forcedTarget).toMatchObject({ + actorId: tank.id, + endsAt: 4_000, + }); + expect(runtime.members.find((member) => member.id === tank.id)?.tauntReadyAt).toBe(9_000); + }); + it("shares an eight-second tank taunt cooldown across loose mobs", () => { const party = usePartyStore.getState(); const tank = party.members.find((member) => member.role === "tank")!; diff --git a/src/game/partyRuntime.ts b/src/game/partyRuntime.ts index 17833c9e..c716a415 100644 --- a/src/game/partyRuntime.ts +++ b/src/game/partyRuntime.ts @@ -57,6 +57,7 @@ import { commitPartyAbilityUse, partyAbilityAmount, } from "./partyAbilityAi"; +import { minimumHostileTargetsForAbility } from "./combatRotation"; import { emitRotationDecision } from "./rotationTelemetry"; import { imminentCooldownMechanic } from "./companionCooldownPolicy"; import { normalizeActorResourcePool } from "./combatActors"; @@ -799,7 +800,7 @@ function partyHostileTargets( for (const id of ordered) { if (selected.length >= (targeting.maximumTargets ?? 3)) break; const position = getMobPosition(id); - if (!position || combat.mobs[id]?.dead) continue; + if (!position || !combat.mobs[id]?.engaged || combat.mobs[id]?.dead) continue; if (selected.length > 0 && distanceSquared(previous, position) > (targeting.jumpRange ?? 12) ** 2) continue; if (!combatHasLineOfSight(previous, position)) continue; selected.push(id); @@ -812,7 +813,7 @@ function partyHostileTargets( return Object.values(combat.mobs) .filter((mob) => { const position = getMobPosition(mob.id); - return !mob.dead && Boolean(position + return mob.engaged && !mob.dead && Boolean(position && distanceSquared(origin, position) <= (targeting.radius ?? ability.radius ?? ability.range.max) ** 2 && combatHasLineOfSight(origin, position) && (!memberSpatial || isPositionInFacingArc(memberSpatial, position, targeting.attackArcRadians ?? Math.PI / 2))); @@ -826,7 +827,7 @@ function partyHostileTargets( return Object.values(combat.mobs) .filter((mob) => { const position = getMobPosition(mob.id); - return !mob.dead && Boolean(position + return mob.engaged && !mob.dead && Boolean(position && distanceSquared(center, position) <= radius * radius && combatHasLineOfSight(center, position)); }) @@ -1824,7 +1825,14 @@ export function advanceDungeonPartyCombat(now = Date.now()): void { const abilityTargetId = (ability.target === "self" && !hostileSelfArea) || ability.target === "friendly" ? member.id : memberTargetId; + const minimumHostiles = minimumHostileTargetsForAbility(ability.name); return partyAbilityTargetIsValid(runtimeMember, ability, abilityTargetId) + && (minimumHostiles <= 1 || partyHostileTargets( + ability, + abilityTargetId, + memberPosition, + member.id, + ).length >= minimumHostiles) && (ability.name !== "Envenom" || matchingAuraStacks( currentCombat.auras, ROGUE_DEADLY_POISON_DOSE_TAG, @@ -1845,6 +1853,8 @@ export function advanceDungeonPartyCombat(now = Date.now()): void { // Let maintained effects tick instead of resetting their first tick on every GCD. && !currentCombat.effects.some((effect) => effect.sourceActorId === member.id && effect.sourceAbilityId === ability.id && effect.targetId === abilityTargetId && effect.remainingTicks > 0) + && !currentCombat.areaEffects.some((effect) => effect.sourceActorId === member.id + && effect.sourceAbilityId === ability.id && effect.expiresAt > now) && !ability.effects.some((effect) => effect.kind === "apply-aura" && currentCombat.auras.some((aura) => aura.sourceId === member.id && aura.definition.id === effect.aura.id && aura.targetId === abilityTargetId)); diff --git a/src/game/wow335AbilityCatalog.test.ts b/src/game/wow335AbilityCatalog.test.ts index 7bd464a5..2fefe481 100644 --- a/src/game/wow335AbilityCatalog.test.ts +++ b/src/game/wow335AbilityCatalog.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { abilityAtLevel, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog"; +import { aiHintsForAbility, targetingRulesForAbility } from "./abilityRules"; import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; function byId(id: string): AbilityDefinition { @@ -41,6 +42,28 @@ describe("WoW 3.3.5 executable effect mapping", () => { ]); }); + it("maps the Ready DPS area actions to executable multi-target shapes", () => { + const multiShot = abilityAtLevel(byId("wow335-hunter-multi-shot"), 80); + expect(targetingRulesForAbility(multiShot)).toMatchObject({ + shape: "chain", + maximumTargets: 3, + jumpRange: 10, + }); + expect(aiHintsForAbility(multiShot).categories).toContain("area-damage"); + + const volley = abilityAtLevel(byId("wow335-hunter-volley"), 80); + expect(volley).toMatchObject({ target: "hostile", castMode: "channel", castTimeMs: 6_000, radius: 8 }); + expect(targetingRulesForAbility(volley).shape).toBe("ground"); + expect(effectsOfKind(volley, "dot")).toEqual([ + expect.objectContaining({ ticks: 6, intervalMs: 1_000, tag: "volley" }), + ]); + + const fan = abilityAtLevel(byId("wow335-rogue-fan-of-knives"), 80); + expect(fan).toMatchObject({ target: "self", radius: 8 }); + expect(targetingRulesForAbility(fan).shape).toBe("circle"); + expect(aiHintsForAbility(fan).categories).toContain("area-damage"); + }); + it("gives every imported classic rank executable or explicit utility semantics", () => { const effects = Object.values(WOW335_ABILITIES_BY_CLASS) .flat() diff --git a/src/game/wow335AbilityCatalog.ts b/src/game/wow335AbilityCatalog.ts index e466f715..d7ccb1e9 100644 --- a/src/game/wow335AbilityCatalog.ts +++ b/src/game/wow335AbilityCatalog.ts @@ -793,6 +793,20 @@ function classScriptEffects( case "hunter:Freezing Arrow": return [{ kind: "status", status: "freeze", durationMs: 10_000 }]; case "hunter:Feign Death": return [{ kind: "threat", mode: "percent", amount: -100 }]; case "hunter:Readiness": return [{ kind: "cooldown-reset", mode: "reset" }]; + // Volley's player-facing spell is a six-second channel aura whose Arcane + // pulses live in same-rank triggered child rows. Keep the channel/cost but + // make the six ground-area pulses executable by players and companions. + case "hunter:Volley": { + const pulse = rank.effects.find((effect) => effect.effectType === 27); + return pulse ? [{ + kind: "dot", + coefficient: fallbackCoefficient(pulse, rank), + ...amountScaling(pulse, rank), + ticks: 6, + intervalMs: 1_000, + tag: "volley", + }] : []; + } case "rogue:Shiv": return [{ kind: "damage", coefficient: 1 }]; case "rogue:Killing Spree": return [{ kind: "dot", coefficient: 0.5, ticks: 5, intervalMs: 500, tag: "killing-spree", @@ -1286,6 +1300,7 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str || (classId === "warrior" && abilityName === "Bloodrage" && (effect.kind === "resource" || effect.kind === "heal")) || (classId === "priest" && abilityName === "Inner Focus" && effect.kind === "apply-aura") + || (classId === "hunter" && abilityName === "Volley" && effect.kind === "apply-aura") || (classId === "rogue" && abilityName === "Envenom" && effect.kind === "apply-aura") || (classId === "rogue" && abilityName === "Vanish" && effect.kind === "movement") )) @@ -1489,7 +1504,8 @@ function abilityNameRadius(abilityName: string): number { } function abilityNameTarget(abilityName: string): AbilityTarget | undefined { - if (["Bladestorm", "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", "Fan of Knives"].includes(abilityName)) return "self"; + if (abilityName === "Volley") return "hostile"; if (abilityName === "Penance") return "friendly"; if (abilityName === "Death Coil") return "hostile"; return undefined;