diff --git a/modules/mod-paragon/data/sql/db-world/base/paragon_spell_ae_cost.sql b/modules/mod-paragon/data/sql/db-world/base/paragon_spell_ae_cost.sql index 693ce21..f03514b 100644 --- a/modules/mod-paragon/data/sql/db-world/base/paragon_spell_ae_cost.sql +++ b/modules/mod-paragon/data/sql/db-world/base/paragon_spell_ae_cost.sql @@ -472,6 +472,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES (61999, 1), (62078, 1), (62124, 1), + (62600, 1), (62757, 1), (64382, 1), (64843, 1), diff --git a/modules/mod-paragon/data/sql/db-world/updates/2026_05_11_05.sql b/modules/mod-paragon/data/sql/db-world/updates/2026_05_11_05.sql new file mode 100644 index 0000000..fde0837 --- /dev/null +++ b/modules/mod-paragon/data/sql/db-world/updates/2026_05_11_05.sql @@ -0,0 +1,19 @@ +-- mod-paragon: surface Savage Defense (62600) on the Druid Feral spell tab +-- of the Character Advancement panel. +-- +-- The bake (tools/_gen_paragon_advancement_spells_lua.py) used to drop every +-- SPELL_ATTR0_PASSIVE spell up front, even when the trainer explicitly sells +-- it. That filter was correct for class-internal triggers (Feline Grace, etc.) +-- but kicked out Savage Defense -- a passive that DRUID trainer 33 sells at +-- level 40 (trainer_spell.sql line 2457). Bake now carves out a small +-- PASSIVE_TRAINER_ALLOWLIST so legitimate trainer-taught passives survive. +-- +-- The base file (data/sql/db-world/base/paragon_spell_ae_cost.sql) was +-- regenerated alongside this migration so fresh deployments already have +-- this row. Existing servers do not re-run base files on content change, +-- so this update inserts the new (spell_id, ae_cost) pair idempotently. +-- INSERT IGNORE keeps any per-row tuning a server operator may have already +-- applied. + +INSERT IGNORE INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES + (62600, 1); -- Savage Defense (Druid, trainer 33, level 40) diff --git a/modules/mod-paragon/src/Paragon_Essence.cpp b/modules/mod-paragon/src/Paragon_Essence.cpp index 016362b..6c993a0 100644 --- a/modules/mod-paragon/src/Paragon_Essence.cpp +++ b/modules/mod-paragon/src/Paragon_Essence.cpp @@ -987,6 +987,89 @@ void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set& out) } } +// Riding-skill gating for spells whose effective rank in the spellbook +// depends on the player's flying skill (currently the Druid forms +// Flight Form 33943 / Swift Flight Form 40120). Returns true when the +// player is allowed to learn this specific spell id at this moment. +// +// 33943 (Flight Form, 150% flight) requires 34090 Expert Riding. +// 40120 (Swift Flight Form, 280% flight) requires 34091 Artisan Riding. +// +// Any other spell id is always allowed (returns true). Used by +// `PanelLearnSpellChain` so a Paragon panel purchase / level-up cascade +// silently skips the unaffordable rank but keeps walking the chain -- +// e.g. a player with Expert Riding only gets Flight Form, never Swift, +// even though both ranks are in the same SpellChain.dbc graph. +[[nodiscard]] bool IsParagonSpellAllowedByRidingSkill(Player* pl, uint32 spellId) +{ + if (!pl) + return true; + if (spellId == 33943) + return pl->HasSpell(34090) || pl->HasSpell(34091); // expert OR artisan + if (spellId == 40120) + return pl->HasSpell(34091); // artisan required for swift + return true; +} + +// Walk a rank chain and learn every rank up to the player's current +// level (and not past riding-skill gates), without any of the +// PanelLearnSpellChain panel/AE bookkeeping. Used by talent-grant +// cascades (Mangle / Feral Charge / Mutilate / etc.) where the talent +// LEARN_SPELL effect grants the rank-1 ability and stock would have +// upgraded it via Player::learnSkillRewardedSpells -- but the Paragon +// class-skill cascade is intentionally disabled (Player.cpp guard), so +// nothing else picks up the higher ranks. Idempotent: skips ranks the +// player already has, so safe to re-run on level-up / login. +void TeachLevelGatedAbilityChainNoPanel(Player* pl, uint32 chainHead) +{ + if (!pl || !chainHead) + return; + uint32 const playerLevel = pl->GetLevel(); + uint32 const firstId = sSpellMgr->GetFirstSpellInChain(chainHead); + uint32 cur = firstId ? firstId : chainHead; + while (cur) + { + SpellInfo const* info = sSpellMgr->GetSpellInfo(cur); + if (!info) + break; + + uint32 const reqLv = GetParagonPanelSpellRequiredLevel(info); + if (playerLevel < reqLv) + break; + + if (!pl->HasSpell(cur) && IsParagonSpellAllowedByRidingSkill(pl, cur)) + pl->learnSpell(cur, false); + + uint32 const next = sSpellMgr->GetNextSpellInChain(cur); + if (!next || next == cur) + break; + cur = next; + } +} + +// Walk every SPELL_EFFECT_LEARN_SPELL on `talentRankSpellId` (the +// `TalentEntry::RankID[r]` of a talent rank) and, for each granted +// spell, run TeachLevelGatedAbilityChainNoPanel so the player ends up +// with the highest rank their level can support. Mangle, Feral Charge, +// Mutilate, etc. all fit this pattern. +void CascadeRanksForTalentLearnSpellEffects(Player* pl, uint32 talentRankSpellId) +{ + if (!pl || !talentRankSpellId) + return; + SpellInfo const* rankInfo = sSpellMgr->GetSpellInfo(talentRankSpellId); + if (!rankInfo) + return; + for (uint8 e = 0; e < MAX_SPELL_EFFECTS; ++e) + { + if (rankInfo->Effects[e].Effect != SPELL_EFFECT_LEARN_SPELL) + continue; + uint32 const grantId = rankInfo->Effects[e].TriggerSpell; + if (!grantId) + continue; + TeachLevelGatedAbilityChainNoPanel(pl, grantId); + } +} + // True when `depSpellId` (any rank in its chain) is the target of a // SPELL_EFFECT_LEARN_SPELL on any rank of the anchor purchase chain // (`anchorChainHead` is the chain head / panel_spells.spell_id). @@ -1103,7 +1186,7 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId) if (playerLevel < reqLv) break; - if (!pl->HasSpell(cur)) + if (!pl->HasSpell(cur) && IsParagonSpellAllowedByRidingSkill(pl, cur)) { std::unordered_set before = SnapshotKnownSpells(pl); if (diag) @@ -2085,7 +2168,23 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err) uint32 const targetRank = std::min(currentRank + delta, MAX_TALENT_RANK); for (uint32 r = currentRank; r < targetRank; ++r) + { pl->LearnTalent(tid, r, /*command=*/true); + + // Fractured / Paragon: talents that LEARN_SPELL an ability + // (Mangle, Feral Charge, Mutilate, ...) only directly grant the + // rank-1 ability spell. Stock classes auto-rank-up the granted + // spell via Player::learnSkillRewardedSpells on level-up, but + // that path is intentionally disabled for Paragon class skill + // lines (Player.cpp guard) -- so without this cascade the + // ability stays at rank 1 forever (Mangle Bear 33878 instead + // of 33986 / 33987 / 48563 / 48564). Walk every LEARN_SPELL + // target on this rank's RankID spell and grant the highest + // rank the player's level allows. + if (TalentEntry const* freshTe = sTalentStore.LookupEntry(tid)) + if (uint32 rankSpell = freshTe->RankID[r]) + CascadeRanksForTalentLearnSpellEffects(pl, rankSpell); + } } for (auto const& [tid, delta] : talentDeltas) @@ -4013,6 +4112,45 @@ public: ReconcileEssenceForPlayer(player); SaveCurrencyToDb(player); PushCurrency(player); + + // Fractured / Paragon: rank-up cascade for level-up. Without this, + // higher ranks of panel-purchased spells AND talent-LEARN_SPELL + // granted abilities (Mangle, Feral Charge, Mutilate, ...) never + // appear on ding because Player::learnSkillRewardedSpells is + // disabled for the class skill line on Paragon (intentional, to + // keep the panel as the sole authority over class abilities). + // + // Cheap re-walks: PanelLearnSpellChain / TeachLevelGated... both + // skip ranks the player already has, so the only real work each + // level-up is adding the one new rank the player just qualified + // for (if any). + if (QueryResult r = CharacterDatabase.Query( + "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid)) + { + do + { + uint32 const head = r->Fetch()[0].Get(); + PanelLearnSpellChain(player, head); + } while (r->NextRow()); + } + + if (QueryResult tr = CharacterDatabase.Query( + "SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", lowGuid)) + { + do + { + Field* f = tr->Fetch(); + uint32 const talentId = f[0].Get(); + uint32 const rank = f[1].Get(); + TalentEntry const* te = sTalentStore.LookupEntry(talentId); + if (!te) + continue; + uint32 const cap = std::min(rank, MAX_TALENT_RANK); + for (uint32 i = 0; i < cap; ++i) + if (uint32 rankSpell = te->RankID[i]) + CascadeRanksForTalentLearnSpellEffects(player, rankSpell); + } while (tr->NextRow()); + } } bool OnPlayerCanLearnTalent(Player* player, TalentEntry const* talent, uint32 /*rank*/) override diff --git a/src/server/game/Entities/Player/Player.cpp b/src/server/game/Entities/Player/Player.cpp index 3d4219d..f2654ae 100644 --- a/src/server/game/Entities/Player/Player.cpp +++ b/src/server/game/Entities/Player/Player.cpp @@ -7143,14 +7143,16 @@ bool Player::CheckAttackFitToAuraRequirement(WeaponAttackType attackType, AuraEf if (spellInfo->EquippedItemClass == -1) return true; - // Fractured / Paragon: cross-class wildcard relaxes weapon-class subclass - // gates on per-swing proc matches. A Paragon's talent list spans every - // class so a stock weapon-subclass mask (e.g. Maelstrom Weapon's - // axe/mace/staff/fist/dagger restriction) excludes weapons the player - // can legitimately wield. Accept any equipped weapon in attackType slot - // when listener is a Paragon AND the spell gates on ITEM_CLASS_WEAPON; - // ITEM_CLASS_ARMOR (shield) gates still enforce the original mask. - if (spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON && IsParagonWildcardCaller(this)) + // Fractured / Paragon: cross-class wildcard relaxes the weapon-subclass + // gate ONLY for the curated allowlist of cross-class proc talents + // (currently just Maelstrom Weapon 51528-51532). Weapon-specialization + // talents like Sword Specialization, Mace Specialization, Hack and + // Slash, Two-Handed Weapon Specialization etc. deliberately stay + // weapon-gated for Paragon -- the player picks a weapon and the + // matching specialization passive activates, same as any class. + if (spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON + && IsParagonWildcardCaller(this) + && IsParagonWeaponSubclassWildcardSpell(spellInfo->Id)) return GetWeaponForAttack(attackType, true) != nullptr; Item* item = GetWeaponForAttack(attackType, true); @@ -12591,15 +12593,19 @@ bool Player::HasItemFitToSpellRequirements(SpellInfo const* spellInfo, Item cons if (spellInfo->EquippedItemClass < 0) return true; - // Fractured / Paragon: cross-class wildcard relaxes weapon-class subclass - // gates so passive talent auras (e.g. Maelstrom Weapon talents 51528-51532) - // attach for any equipped weapon, not just the talent's restrictive - // subclass mask. Mirrors CheckAttackFitToAuraRequirement so per-swing - // proc match agrees with talent-attach time. Still requires *some* weapon - // to be equipped (otherwise unarmed Paragons would auto-activate every - // weapon-gated talent in the game). ITEM_CLASS_ARMOR (shield) is left - // alone -- shield-gated talents still need an actual shield. - if (spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON && IsParagonWildcardCaller(this)) + // Fractured / Paragon: cross-class wildcard relaxes the weapon-subclass + // gate ONLY for the curated allowlist of cross-class proc talents + // (currently just Maelstrom Weapon 51528-51532) so the passive talent + // aura attaches when the player wields a non-stock weapon. Weapon- + // specialization talents (Sword/Mace Specialization, Hack and Slash, + // Two-Handed Weapon Specialization, etc.) deliberately stay weapon- + // gated -- they're meant to bind to a specific weapon type. Still + // requires *some* weapon equipped (unarmed Paragons don't auto-activate + // weapon talents). ITEM_CLASS_ARMOR (shield) is left alone -- shield- + // gated talents still need an actual shield. + if (spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON + && IsParagonWildcardCaller(this) + && IsParagonWeaponSubclassWildcardSpell(spellInfo->Id)) { for (uint8 i = EQUIPMENT_SLOT_MAINHAND; i < EQUIPMENT_SLOT_TABARD; ++i) if (Item const* item = GetUseableItemByPos(INVENTORY_SLOT_BAG_0, i)) diff --git a/src/server/game/Entities/Unit/Unit.cpp b/src/server/game/Entities/Unit/Unit.cpp index 36f672e..43c0aec 100644 --- a/src/server/game/Entities/Unit/Unit.cpp +++ b/src/server/game/Entities/Unit/Unit.cpp @@ -103,6 +103,29 @@ bool ParagonFamilyMatches(Unit const* listener, uint32 expectedFamily, uint32 ac return expectedFamily == actualFamily; } +bool IsParagonWeaponSubclassWildcardSpell(uint32 spellId) +{ + if (!spellId) + return false; + + // Resolve to the first-rank id so callers can pass any rank. + uint32 firstRankId = spellId; + if (SpellInfo const* info = sSpellMgr->GetSpellInfo(spellId)) + if (SpellInfo const* first = info->GetFirstRankSpell()) + firstRankId = first->Id; + + switch (firstRankId) + { + // Maelstrom Weapon (talent ranks 51528 / 51529 / 51530 / 51531 / 51532). + // Cross-class proc talent that should fire off any equipped weapon + // for a Paragon caster (1H sword, polearm, staff, fist, dagger, etc.). + case 51528: + return true; + default: + return false; + } +} + float baseMoveSpeed[MAX_MOVE_TYPE] = { 2.5f, // MOVE_WALK diff --git a/src/server/game/Entities/Unit/Unit.h b/src/server/game/Entities/Unit/Unit.h index 689a2dd..f82fc0f 100644 --- a/src/server/game/Entities/Unit/Unit.h +++ b/src/server/game/Entities/Unit/Unit.h @@ -2287,6 +2287,19 @@ private: // beyond what they already include via Unit.h's transitive headers. [[nodiscard]] bool ParagonFamilyMatches(Unit const* listener, uint32 expectedFamily, uint32 actualFamily); +// Fractured / Paragon: returns true iff `spellId` is on the curated allowlist +// of cross-class proc talents whose `EquippedItemSubClassMask` should be +// bypassed for Paragon casters (e.g. Maelstrom Weapon ranks 51528-51532). +// Used by `Player::HasItemFitToSpellRequirements`, +// `Player::CheckAttackFitToAuraRequirement`, and +// `Aura::IsProcTriggeredOnEvent` to let these specific talents fire from +// any equipped weapon while keeping weapon-specialization talents +// (Sword Specialization, Mace Specialization, Hack and Slash, Two-Handed +// Weapon Specialization, etc.) gated by their stock weapon-class mask. +// The allowlist is matched against `SpellInfo::GetFirstRankSpell()`'s id +// so all ranks of a talent are covered by listing the rank-1 id. +[[nodiscard]] bool IsParagonWeaponSubclassWildcardSpell(uint32 spellId); + namespace Acore { // Binary predicate for sorting Units based on percent value of a power diff --git a/src/server/game/Spells/Auras/SpellAuras.cpp b/src/server/game/Spells/Auras/SpellAuras.cpp index 97c8457..030a089 100644 --- a/src/server/game/Spells/Auras/SpellAuras.cpp +++ b/src/server/game/Spells/Auras/SpellAuras.cpp @@ -2255,20 +2255,17 @@ uint8 Aura::GetProcEffectMask(AuraApplication* aurApp, ProcEventInfo& eventInfo, return 0; if (!item->IsFitToSpellRequirements(GetSpellInfo())) { - // Fractured / Paragon: cross-class wildcard relaxes weapon- - // class subclass gates on per-event proc evaluation. This - // mirrors Player::CheckAttackFitToAuraRequirement and - // Player::HasItemFitToSpellRequirements -- without this - // third bypass, the talent attaches (HasItemFit lets it), - // the per-swing match accepts the weapon (CheckAttackFit - // lets it), but IsProcTriggeredOnEvent still kills the - // proc here for any weapon outside the talent's stock - // subclass mask (e.g. Maelstrom Weapon on a Paragon - // wielding a 1H sword or polearm). Restricted to - // ITEM_CLASS_WEAPON so shield-gated talents still need - // an actual shield. + // Fractured / Paragon: cross-class wildcard relaxes the + // weapon-subclass gate ONLY for the curated allowlist of + // cross-class proc talents (currently just Maelstrom Weapon + // 51528-51532). Weapon-specialization talents (Sword/Mace + // Specialization, Hack and Slash, Two-Handed Weapon + // Specialization, etc.) deliberately stay weapon-gated for + // Paragon. Restricted to ITEM_CLASS_WEAPON so shield-gated + // talents still need an actual shield. if (!(GetSpellInfo()->EquippedItemClass == ITEM_CLASS_WEAPON - && IsParagonWildcardCaller(target))) + && IsParagonWildcardCaller(target) + && IsParagonWeaponSubclassWildcardSpell(GetSpellInfo()->Id))) return 0; } }