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:
Docker Build
2026-05-11 19:09:25 -04:00
parent 7c57abd69f
commit 999f7e94bd
7 changed files with 228 additions and 31 deletions
@@ -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)
+139 -1
View File
@@ -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