From 9c1bbfafecc1b6d12dacb69a05bb4754cce40cc5 Mon Sep 17 00:00:00 2001 From: moostigre Date: Thu, 24 Sep 2026 20:46:24 +0200 Subject: [PATCH] fix(Spells/Paladin): Apply Beacon target healing bonuses (#27772) --- e2e/README.md | 2 +- .../spells/aura/beacon_of_light_e2e_test.go | 298 ++++++++++++++++++ .../game/Spells/SpellInfoCorrections.cpp | 15 +- src/server/scripts/Spells/spell_paladin.cpp | 13 +- 4 files changed, 318 insertions(+), 10 deletions(-) create mode 100644 e2e/suites/spells/aura/beacon_of_light_e2e_test.go diff --git a/e2e/README.md b/e2e/README.md index 4727bc10bd..bf80c6d62a 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -147,7 +147,7 @@ go test -tags=e2e ./local/... -count=1 -v -timeout 30m -parallel 1 | combat/pets | summon / GUID / attack / dismiss | P1 | covered; dungeon Raise Dead `blocked-harness` (ready-check / instance summon) | #27081 | | combat/threat | engage / taunt switch / kill clears combat | P1 | covered | — | | combat/vehicles | spellclick steed enter/exit | P2 | covered | — | -| spells/aura | apply/query; CC broken by damage; mount persist; paladin same-aura per-caster + Aura Mastery | P1 | covered (`TestAC_26130_*`, `TestAC_25765_*`) | #26130 #25765 | +| spells/aura | apply/query; CC broken by damage; mount persist; paladin same-aura per-caster + Aura Mastery; Beacon applies its target's Improved Devotion Aura healing bonus exactly once for Holy Light, Flash of Light, Holy Shock and Lay on Hands | P1 | covered (`TestAC_26130_*`, `TestAC_25765_*`, `TestAC_27756_*`) | #26130 #25765 #27756 | | spells/cast | Charge on dummy; fail path; stance; Raise Dead + ghoul | P1 | covered (`TestAC_27061_*`) | #27061 | | spells/effects | Charge / grounding totem / Sweeping Strikes Execute; forced cast summons at the forced caster, not at the unit that forced it; a tripped Gordunni Trap rolls out both dirt mounds | P1 | covered (`TestAC_26997_*`, `TestEffects_ForceCastDestination`, `TestEffects_GordunniTrapRollsBothMounds`); dummy-summon `blocked-harness` (engineering dummy lifetime) | #26774 #26997 #27621 | | social/group | form / leave / leader / loot method / disband | P2 | covered | — | diff --git a/e2e/suites/spells/aura/beacon_of_light_e2e_test.go b/e2e/suites/spells/aura/beacon_of_light_e2e_test.go new file mode 100644 index 0000000000..c402311e32 --- /dev/null +++ b/e2e/suites/spells/aura/beacon_of_light_e2e_test.go @@ -0,0 +1,298 @@ +//go:build e2e + +package aura_test + +import ( + "bytes" + "encoding/binary" + "fmt" + "testing" + "time" + + _ "github.com/go-sql-driver/mysql" + + "github.com/azerothcore/AzerothGhost/e2e/e2eharness" + "github.com/azerothcore/azerothcore-wotlk/e2e/internal/meta" +) + +const ( + spellBeaconOfLight = uint32(53563) + spellLightsBeacon = uint32(53651) + spellBeaconHolyLight = uint32(53652) + spellBeaconFlashOfLight = uint32(53653) + spellBeaconHolyShock = uint32(53654) + spellHolyLight = uint32(48782) + spellFlashOfLight = uint32(48785) + spellHolyShock = uint32(48825) + spellHolyShockHealing = uint32(48821) + spellLayOnHands = uint32(48788) + spellDevotionAura = uint32(48942) + spellImprovedDevotionAura = uint32(20140) + spellImprovedDevotionEffect = uint32(63514) + + smsgSpellHealLog = uint16(0x0150) + healingBonusPct = uint32(6) + testMaxHealth = uint32(200_000) +) + +type spellHealLog struct { + Target uint64 + Caster uint64 + SpellID uint32 + Heal uint32 + Overheal uint32 +} + +type beaconHealCase struct { + name string + castSpell uint32 + sourceHealSpell uint32 + beaconHealSpell uint32 +} + +// Issue: https://github.com/azerothcore/azerothcore-wotlk/issues/27756 +// +// Improved Devotion Aura gives grouped targets 6% more healing received. The +// ungrouped recipient supplies the target-without-bonus case without depending +// on range or terrain. Healing the grouped paladin supplies the both-targets- +// have-the-bonus case. Beacon must use its target's bonus exactly once for all +// four source heals. +func TestAC_27756_BeaconUsesTargetHealingBonusOnce(t *testing.T) { + meta.Begin(t, meta.TestMeta{ + Tags: []string{"med", "spells", "issue", "multi_bot"}, + Runtime: "med", + Issue: 27756, + Category: "spells/aura", + }) + + bots := e2eharness.NewScenario(t, e2eharness.ScenarioOpts{ + Prefix: "Bcn277", + Bots: []e2eharness.BotSpec{ + {Role: "healer", Race: e2eharness.RaceHuman, Class: e2eharness.ClassPaladin, Level: 80}, + {Role: "beacon", Race: e2eharness.RaceHuman, Class: e2eharness.ClassPaladin, Level: 80}, + {Role: "recipient", Race: e2eharness.RaceHuman, Class: e2eharness.ClassWarrior, Level: 80}, + }, + }) + healer := e2eharness.ByRole(t, bots, "healer") + beacon := e2eharness.ByRole(t, bots, "beacon") + recipient := e2eharness.ByRole(t, bots, "recipient") + + pad := e2eharness.PackagePad(t) + recipient.TeleportPad(t, pad) + e2eharness.FormPartyAtPad(t, pad, healer, beacon) + defer e2eharness.DisbandParty(t, healer, beacon) + if recipient.InGroup() { + e2eharness.Preconditionf(t, "recipient unexpectedly joined the healer and Beacon target's party") + } + + for _, spellID := range []uint32{spellBeaconOfLight, spellHolyLight, spellFlashOfLight, spellHolyShock, spellLayOnHands} { + healer.Learn(t, spellID) + } + beacon.Learn(t, spellImprovedDevotionAura) + beacon.Learn(t, spellDevotionAura) + + for _, bot := range bots { + e2eharness.CombatReady(t, bot.World, e2eharness.CombatReadyOpts{Power: true}) + } + healer.GM(t, ".cheat cooldown on") + healer.FlushWorld(t) + + beacon.CastMust(t, spellDevotionAura, 0, 10*time.Second) + healer.WaitUnitAura(t, beacon.GUID, spellImprovedDevotionEffect, 10*time.Second) + healer.WaitUnitAura(t, healer.GUID, spellImprovedDevotionEffect, 10*time.Second) + if recipient.HasAura(spellImprovedDevotionEffect) { + e2eharness.Preconditionf(t, "ungrouped recipient unexpectedly has Improved Devotion Aura effect %d", spellImprovedDevotionEffect) + } + + healLogs := make(chan spellHealLog, 64) + cancelHealLogs := healer.World.AddPacketHook(func(opcode uint16, data []byte) { + if opcode != smsgSpellHealLog { + return + } + if event, ok := parseSpellHealLog(data); ok { + select { + case healLogs <- event: + default: + } + } + }) + defer cancelHealLogs() + + cases := []beaconHealCase{ + {name: "Holy Light", castSpell: spellHolyLight, sourceHealSpell: spellHolyLight, beaconHealSpell: spellBeaconHolyLight}, + {name: "Flash of Light", castSpell: spellFlashOfLight, sourceHealSpell: spellFlashOfLight, beaconHealSpell: spellBeaconFlashOfLight}, + {name: "Holy Shock", castSpell: spellHolyShock, sourceHealSpell: spellHolyShockHealing, beaconHealSpell: spellBeaconHolyShock}, + {name: "Lay on Hands", castSpell: spellLayOnHands, sourceHealSpell: spellLayOnHands, beaconHealSpell: spellBeaconHolyShock}, + } + + matrices := []struct { + name string + source *e2eharness.ScenarioBot + wantBeaconHealing func(uint32) uint32 + }{ + { + name: "Beacon target bonus only", + source: recipient, + wantBeaconHealing: func(sourceHeal uint32) uint32 { + return sourceHeal + sourceHeal*healingBonusPct/100 + }, + }, + { + name: "Both targets have the bonus", + source: healer, + wantBeaconHealing: func(sourceHeal uint32) uint32 { + return sourceHeal + }, + }, + } + + for _, matrix := range matrices { + t.Run(matrix.name, func(t *testing.T) { + wantSourceBonus := matrix.source == healer + if got := matrix.source.HasAura(spellImprovedDevotionEffect); got != wantSourceBonus { + e2eharness.Preconditionf(t, "%s Improved Devotion Aura effect=%v, want %v", matrix.source.Name, got, wantSourceBonus) + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + prepareInjuredPlayer(t, matrix.source) + prepareInjuredPlayer(t, beacon) + drainHealLogs(healLogs) + + healer.CastMust(t, spellBeaconOfLight, beacon.GUID, 10*time.Second) + healer.WaitUnitAura(t, beacon.GUID, spellBeaconOfLight, 10*time.Second) + healer.WaitUnitAura(t, healer.GUID, spellLightsBeacon, 10*time.Second) + drainHealLogs(healLogs) + + healer.CastMust(t, testCase.castSpell, matrix.source.GUID, 10*time.Second) + sourceLog, beaconLog := waitForBeaconHealPair(t, healLogs, matrix.source.GUID, + testCase.sourceHealSpell, beacon.GUID, testCase.beaconHealSpell, 10*time.Second) + if sourceLog.Heal == 0 || beaconLog.Heal == 0 { + e2eharness.ConfirmedBugf(t, 27756, "%s produced zero healing source=%d Beacon=%d", + testCase.name, sourceLog.Heal, beaconLog.Heal) + } + + want := matrix.wantBeaconHealing(sourceLog.Heal) + if !withinOne(beaconLog.Heal, want) { + e2eharness.ConfirmedBugf(t, 27756, + "%s: source healed %d but Beacon healed %d, want %d (source overheal=%d, Beacon overheal=%d)", + testCase.name, sourceLog.Heal, beaconLog.Heal, want, sourceLog.Overheal, beaconLog.Overheal) + } + t.Logf("PASS %s source=%d Beacon=%d want=%d", testCase.name, sourceLog.Heal, beaconLog.Heal, want) + }) + } + }) + } +} + +func prepareInjuredPlayer(t *testing.T, bot *e2eharness.ScenarioBot) { + t.Helper() + if err := bot.World.SetTarget(bot.GUID); err != nil { + e2eharness.Preconditionf(t, "%s select self before health setup: %v", bot.Name, err) + } + bot.GM(t, fmt.Sprintf(".modify hp %d", testMaxHealth)) + bot.FlushWorld(t) + waitForSelfHealth(t, bot, func(health, maxHealth uint32) bool { + return health == testMaxHealth && maxHealth == testMaxHealth + }, "full health after .modify hp") + + bot.Damage(t, bot.GUID, testMaxHealth/2) + waitForSelfHealth(t, bot, func(health, maxHealth uint32) bool { + return health > 0 && health < maxHealth + }, "damage before healing") +} + +func waitForSelfHealth(t *testing.T, bot *e2eharness.ScenarioBot, ready func(uint32, uint32) bool, state string) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + health, maxHealth := bot.World.Health(), bot.World.MaxHealth() + if ready(health, maxHealth) { + return + } + time.Sleep(40 * time.Millisecond) + } + e2eharness.Preconditionf(t, "%s never reached %s (health=%d/%d)", bot.Name, state, bot.World.Health(), bot.World.MaxHealth()) +} + +func waitForBeaconHealPair(t *testing.T, logs <-chan spellHealLog, sourceTarget uint64, sourceSpell uint32, + beaconTarget uint64, beaconSpell uint32, timeout time.Duration) (spellHealLog, spellHealLog) { + t.Helper() + deadline := time.NewTimer(timeout) + defer deadline.Stop() + + var sourceLog, beaconLog spellHealLog + for sourceLog.Heal == 0 || beaconLog.Heal == 0 { + select { + case event := <-logs: + switch { + case event.Target == sourceTarget && event.SpellID == sourceSpell: + sourceLog = event + case event.Target == beaconTarget && event.SpellID == beaconSpell: + beaconLog = event + } + case <-deadline.C: + e2eharness.ConfirmedBugf(t, 27756, + "missing heal logs source(target=0x%X spell=%d heal=%d) Beacon(target=0x%X spell=%d heal=%d)", + sourceTarget, sourceSpell, sourceLog.Heal, beaconTarget, beaconSpell, beaconLog.Heal) + } + } + return sourceLog, beaconLog +} + +func drainHealLogs(logs <-chan spellHealLog) { + for { + select { + case <-logs: + default: + return + } + } +} + +func parseSpellHealLog(data []byte) (spellHealLog, bool) { + reader := bytes.NewReader(data) + target, ok := readPackedGUID(reader) + if !ok { + return spellHealLog{}, false + } + caster, ok := readPackedGUID(reader) + if !ok { + return spellHealLog{}, false + } + + event := spellHealLog{Target: target, Caster: caster} + if binary.Read(reader, binary.LittleEndian, &event.SpellID) != nil || + binary.Read(reader, binary.LittleEndian, &event.Heal) != nil || + binary.Read(reader, binary.LittleEndian, &event.Overheal) != nil { + return spellHealLog{}, false + } + return event, true +} + +func readPackedGUID(reader *bytes.Reader) (uint64, bool) { + mask, err := reader.ReadByte() + if err != nil { + return 0, false + } + + var guid uint64 + for index := uint(0); index < 8; index++ { + if mask&(1< want { + return got-want <= 1 + } + return want-got <= 1 +} diff --git a/src/server/game/Spells/SpellInfoCorrections.cpp b/src/server/game/Spells/SpellInfoCorrections.cpp index da3aeebb4f..83a315d090 100644 --- a/src/server/game/Spells/SpellInfoCorrections.cpp +++ b/src/server/game/Spells/SpellInfoCorrections.cpp @@ -606,16 +606,21 @@ void SpellMgr::LoadSpellInfoCorrections() spellInfo->AttributesEx3 |= SPELL_ATTR3_SUPPRESS_TARGET_PROCS; }); - ApplySpellFix({ - 54968, // Glyph of Holy Light, Damage Class should be magic - 53652, // Beacon of Light heal, Damage Class should be magic - 53654 - }, [](SpellInfo* spellInfo) + // Glyph of Holy Light + ApplySpellFix({ 54968 }, [](SpellInfo* spellInfo) { spellInfo->AttributesEx3 |= SPELL_ATTR3_IGNORE_CASTER_MODIFIERS; spellInfo->DmgClass = SPELL_DAMAGE_CLASS_MAGIC; }); + // Beacon of Light + ApplySpellFix({ 53652, 53653, 53654 }, [](SpellInfo* spellInfo) + { + spellInfo->AttributesEx3 |= SPELL_ATTR3_IGNORE_CASTER_MODIFIERS; + spellInfo->AttributesEx6 &= ~SPELL_ATTR6_IGNORE_HEALTH_MODIFIERS; + spellInfo->DmgClass = SPELL_DAMAGE_CLASS_MAGIC; + }); + // Wild Hunt ApplySpellFix({ 62758, 62762 }, [](SpellInfo* spellInfo) { diff --git a/src/server/scripts/Spells/spell_paladin.cpp b/src/server/scripts/Spells/spell_paladin.cpp index 3c5fe22063..cf98fc16d6 100644 --- a/src/server/scripts/Spells/spell_paladin.cpp +++ b/src/server/scripts/Spells/spell_paladin.cpp @@ -139,6 +139,7 @@ enum PaladinProcSpells SPELL_PALADIN_BEACON_OF_LIGHT_HL = 53652, SPELL_PALADIN_BEACON_OF_LIGHT_FOL = 53653, SPELL_PALADIN_BEACON_OF_LIGHT_HS = 53654, + SPELL_PALADIN_LAY_ON_HANDS_R1 = 633, SPELL_PALADIN_HOLY_LIGHT_R1 = 635, SPELL_PALADIN_FLASH_OF_LIGHT_R1 = 19750, SPELL_PALADIN_GLYPH_OF_HOLY_LIGHT_HEAL = 54968, @@ -152,6 +153,7 @@ enum PaladinProcSpells SPELL_PALADIN_HOLY_POWER_SPELL_POWER = 28793, SPELL_PALADIN_HOLY_POWER_MP5 = 28795, SPELL_PALADIN_HOLY_MENDING = 64891, + SPELL_PALADIN_GLYPH_OF_DIVINITY = 54939, SPELL_PALADIN_GLYPH_OF_DIVINITY_PROC = 54986, SPELL_PALADIN_HEART_OF_THE_CRUSADER_EFF_R1 = 21183, SPELL_PALADIN_JUDGEMENTS_OF_THE_JUST_PROC = 68055, @@ -1102,7 +1104,8 @@ class spell_pal_lay_on_hands : public SpellScript caster->CastSpell(caster, SPELL_PALADIN_IMMUNE_SHIELD_MARKER, true); } // Xinef: Glyph of Divinity - else if (target && caster->HasAura(54939) && GetSpellInfo()->Id != 633 && _manaAmount > 0) // excluding first rank + else if (target && caster->HasAura(SPELL_PALADIN_GLYPH_OF_DIVINITY) && + GetSpellInfo()->Id != SPELL_PALADIN_LAY_ON_HANDS_R1 && _manaAmount > 0) // excluding first rank { _manaAmount = target->GetPower(POWER_MANA) - _manaAmount; if (_manaAmount > 0) @@ -2220,7 +2223,8 @@ class spell_pal_light_s_beacon : public AuraScript SPELL_PALADIN_BEACON_OF_LIGHT_FOL, SPELL_PALADIN_BEACON_OF_LIGHT_HS, SPELL_PALADIN_HOLY_LIGHT_R1, - SPELL_PALADIN_FLASH_OF_LIGHT_R1 + SPELL_PALADIN_FLASH_OF_LIGHT_R1, + SPELL_PALADIN_LAY_ON_HANDS_R1 }); } @@ -2252,8 +2256,9 @@ class spell_pal_light_s_beacon : public AuraScript else healSpellId = SPELL_PALADIN_BEACON_OF_LIGHT_HS; - // Use heal amount before target-specific modifiers to avoid copying them - uint32 healAmount = healInfo->GetHealBeforeTakenMods(); + // Lay on Hands applies target modifiers before HealInfo is built, so use its unmodified base heal. + uint32 healAmount = procSpell->IsRankOf(sSpellMgr->AssertSpellInfo(SPELL_PALADIN_LAY_ON_HANDS_R1)) ? + eventInfo.GetActor()->GetMaxHealth() : healInfo->GetHealBeforeTakenMods(); int32 heal = CalculatePct(healAmount, aurEff->GetAmount()); Unit* beaconTarget = GetCaster();