Paragon: narrow weapon bypass; cascade talent ranks; Savage Defense
* Weapon-narrow: replace blanket EquippedItemSubClassMask bypass with an explicit allowlist (IsParagonWeaponSubclassWildcardSpell, currently Maelstrom Weapon 51528 only). Applied at all three gates: Player:: HasItemFitToSpellRequirements, Player::CheckAttackFitToAuraRequirement, and Aura::IsProcTriggeredOnEvent. Maelstrom Weapon still procs from any weapon for Paragon, but Hack and Slash / Sword / Mace Specialization remain correctly weapon-gated. * Talent ability-rank cascade: TeachLevelGatedAbilityChainNoPanel + CascadeRanksForTalentLearnSpellEffects walk the SPELL_EFFECT_LEARN_SPELL chain a talent rank grants and learn each rank up to the player's level. Wired into HandleCommit (on talent purchase) and OnPlayerLevelChanged (on level-up). Fixes Mangle (and any future LEARN_SPELL talent) being stuck at rank 1 because Player::learnSkillRewardedSpells is intentionally disabled for Paragon's class skill lines. * Riding-skill gate for flight forms (IsParagonSpellAllowedByRidingSkill): Flight Form (33943) requires Expert Riding (34090); Swift Flight Form (40120) requires Artisan Riding (34091). Applied in PanelLearnSpellChain and TeachLevelGatedAbilityChainNoPanel so the cascade can't push past a rank the player isn't trained for. Also backticks `rank` in the level-up query (MySQL reserved word). * Savage Defense (62600) on the panel: the SpellData bake's blanket SPELL_ATTR0_PASSIVE filter dropped Savage Defense even though Druid trainer 33 sells it at level 40. Bake (in fractured-tooling) now carves out a small PASSIVE_TRAINER_ALLOWLIST and the regenerated paragon_spell_ae_cost.sql + 2026_05_11_05.sql migration surface it on the Druid Feral spell tab. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
@@ -987,6 +987,89 @@ void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set<uint32>& 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<uint32> 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<uint32>(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<uint32>();
|
||||
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>();
|
||||
uint32 const rank = f[1].Get<uint32>();
|
||||
TalentEntry const* te = sTalentStore.LookupEntry(talentId);
|
||||
if (!te)
|
||||
continue;
|
||||
uint32 const cap = std::min<uint32>(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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user