From 38b3d4d160d8cc5e56fa79adcd329e68fd4a11da Mon Sep 17 00:00:00 2001 From: phenom Date: Sat, 19 Sep 2026 12:23:33 -0400 Subject: [PATCH] Release Healer Man 0.1.55 --- docs/BALANCE_LAB.md | 15 +++-- docs/COMBAT_ROTATION_MATRIX.md | 4 +- package-lock.json | 4 +- package.json | 2 +- release-version.json | 4 +- scripts/balance-lab/runtime.ts | 9 ++- src/game/combatAuraRuntime.test.ts | 17 +++++ src/game/combatRotation.test.ts | 26 +++++++ src/game/combatRotation.ts | 12 +++- src/game/combatStore.ts | 86 ++++++++++++++++++------ src/game/partyRuntime.test.ts | 97 +++++++++++++++++++++++++++ src/game/partyRuntime.ts | 17 ++++- src/game/wow335AbilityCatalog.test.ts | 10 +++ src/game/wow335AbilityCatalog.ts | 17 ++++- 14 files changed, 279 insertions(+), 41 deletions(-) diff --git a/docs/BALANCE_LAB.md b/docs/BALANCE_LAB.md index ca2acd6a..277f2f9d 100644 --- a/docs/BALANCE_LAB.md +++ b/docs/BALANCE_LAB.md @@ -282,6 +282,10 @@ under `parties//` and adds these suite-level files: `totalHealing` and `totalHealingReceived` are the corresponding absolute values across the included runs. Reports created before target attribution 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 non-throughput release, hit, damage, healing, overheal, or absorb event. 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 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 damage action. Reactive interrupts, taunts, control, and defenses still take precedence. Bear and Cat actions are isolated to their matching Feral specs; Overpower, Revenge, and Victory Rush require an explicitly modeled trigger; 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 -Runic Power generated during an action persist without erasing that action's -cooldown/GCD, and short-range melee abilities use companion combat reach. +Off-profile control, interrupt, and debuff buttons cannot consume ordinary +rotation GCDs after their reactive conditions have been checked. Incidental DBC +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 diff --git a/docs/COMBAT_ROTATION_MATRIX.md b/docs/COMBAT_ROTATION_MATRIX.md index 0f6a202b..41519339 100644 --- a/docs/COMBAT_ROTATION_MATRIX.md +++ b/docs/COMBAT_ROTATION_MATRIX.md @@ -18,7 +18,7 @@ The most useful upstream starting points are `src/Ai/Class//Strategy/` fo | 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. | -`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 tree and 20 in a compatible secondary tree, prefers executable active talents 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 | | --- | --- | --- | -| 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 | 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 | diff --git a/package-lock.json b/package-lock.json index 8c626a45..cafa7b12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.54", + "version": "0.1.55", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.54", + "version": "0.1.55", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 60f54e84..034cfc62 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.54", + "version": "0.1.55", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index 371a7563..fbd426da 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.54", - "versionCode": 1054 + "versionName": "0.1.55", + "versionCode": 1055 } diff --git a/scripts/balance-lab/runtime.ts b/scripts/balance-lab/runtime.ts index 05e98d23..a5a11c8a 100644 --- a/scripts/balance-lab/runtime.ts +++ b/scripts/balance-lab/runtime.ts @@ -379,6 +379,7 @@ async function runBoss( abilities: new Map(), })); const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics])); + const actorAlive = new Map(); const bossAbilities = new Map(); // Instant resource, aura, pet, and summon actions do not produce the damage, // healing, absorb, or cast events used by the original report collector. @@ -435,8 +436,6 @@ async function runBoss( } else if (event.kind === "absorb" && source) { source.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: {}, }); combat.synchronizeActors(); + for (const metrics of memberMetrics) { + actorAlive.set(metrics.actorId, currentHealth(metrics.actorId)[0] > 0); + } combat.registerMob(bossActorId, { name: entity.name, boss: true, @@ -500,6 +502,9 @@ async function runBoss( controller.act(now, activeBuild.role, runScenario, false); for (const metrics of memberMetrics) { 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, maxHealth > 0 ? Math.max(0, health / maxHealth) : 0, diff --git a/src/game/combatAuraRuntime.test.ts b/src/game/combatAuraRuntime.test.ts index 5420abbd..b33ab500 100644 --- a/src/game/combatAuraRuntime.test.ts +++ b/src/game/combatAuraRuntime.test.ts @@ -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" diff --git a/src/game/combatRotation.test.ts b/src/game/combatRotation.test.ts index 5b1d517b..566dc20a 100644 --- a/src/game/combatRotation.test.ts +++ b/src/game/combatRotation.test.ts @@ -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", [ diff --git a/src/game/combatRotation.ts b/src/game/combatRotation.ts index 9fddcab6..c24058e2 100644 --- a/src/game/combatRotation.ts +++ b/src/game/combatRotation.ts @@ -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; diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index 69eac6fa..0a686827 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -5286,12 +5286,29 @@ export const useCombatStore = create((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((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((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((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((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((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((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)) { diff --git a/src/game/partyRuntime.test.ts b/src/game/partyRuntime.test.ts index c9e8e120..6ccf1bf7 100644 --- a/src/game/partyRuntime.test.ts +++ b/src/game/partyRuntime.test.ts @@ -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", () => { diff --git a/src/game/partyRuntime.ts b/src/game/partyRuntime.ts index d79bcd85..7213f820 100644 --- a/src/game/partyRuntime.ts +++ b/src/game/partyRuntime.ts @@ -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 = { 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") { diff --git a/src/game/wow335AbilityCatalog.test.ts b/src/game/wow335AbilityCatalog.test.ts index 54d119c9..10fba11d 100644 --- a/src/game/wow335AbilityCatalog.test.ts +++ b/src/game/wow335AbilityCatalog.test.ts @@ -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 }), ); diff --git a/src/game/wow335AbilityCatalog.ts b/src/game/wow335AbilityCatalog.ts index 36d06920..12e7cdb7 100644 --- a/src/game/wow335AbilityCatalog.ts +++ b/src/game/wow335AbilityCatalog.ts @@ -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;