From da17074a63de831d3dae8201da58bebcec467658 Mon Sep 17 00:00:00 2001 From: Docker Build Date: Tue, 12 May 2026 04:49:38 -0400 Subject: [PATCH] Paragon: Runeforging support (panel-purchasable, no anvil required) Lets the Paragon class buy Runeforging from the Character Advancement panel and apply rune enchants from anywhere in the world without needing to be near a runeforge GameObject. Three carve-outs work together: * Spell.cpp: bypass the SpellFocusObject GO proximity check when the caster is a Paragon and the spell belongs to SKILL_RUNEFORGING (776). Stock DK behaviour is unchanged -- the bypass is gated on getClass() == CLASS_PARAGON, not on the IsClass() context hook. * Player.cpp: skip the Paragon class-skill cascade block for skill 776 so the rune-enchant SLA cascade actually fires. Without this the player gets the Runeforging skill but no rune options at the anvil. * Paragon_Essence.cpp: - Treat SKILL_RUNEFORGING children as a meta-skill cluster: cascade them like passives even though they're active casts, so they stick as panel_spell_children and get cleaned up via the standard refund path. - Whitelist the 8 basic rune-enchants in PruneSkillLineCascadeChildren so they don't get evicted as "active in children = legacy garbage". - Force-attach them in PanelLearnSpellChain (the SLA rows ship with AcquireMethod=0, so the engine cascade alone won't grant them). - Add an OnPlayerLogin fixup so existing Paragons who bought Runeforging before this change get the 8 runes retro-granted. - Stop filtering SPELL_ATTR0_DO_NOT_DISPLAY in PushSpellSnapshot -- Runeforging itself is hidden in the DBC but is a real panel purchase that must show in the Overview tab. The two advanced runes (Stoneskin Gargoyle, Nerubian Carapace) are intentionally excluded from the auto-grant -- retail gates them behind heroic dungeon / raid item drops and the SLA AcquireMethod=0 honours that gating. No SQL migration needed; works against existing DBC + SLA data. Co-authored-by: Cursor --- modules/mod-paragon/src/Paragon_Essence.cpp | 183 +++++++++++++++++++- src/server/game/Entities/Player/Player.cpp | 15 +- src/server/game/Spells/Spell.cpp | 30 ++++ 3 files changed, 220 insertions(+), 8 deletions(-) diff --git a/modules/mod-paragon/src/Paragon_Essence.cpp b/modules/mod-paragon/src/Paragon_Essence.cpp index 6c993a0..b4ffaed 100644 --- a/modules/mod-paragon/src/Paragon_Essence.cpp +++ b/modules/mod-paragon/src/Paragon_Essence.cpp @@ -584,6 +584,37 @@ void RevokeBlockedSpellsForPlayer(Player* pl) continue; } + // Same migration for meta-skill cascade spells. Earlier builds + // (and this one until just now) revoked the rune-enchant spells + // (Razorice, Cinderglacier, Rune of the Fallen Crusader, ...) + // when a Paragon learned Runeforging via the panel, because + // they're active spells and the default classifier treats + // unknown active cascades as leaks. New policy: anything on + // SKILL_RUNEFORGING is part of the Runeforging meta-skill + // package and stays. Drop the revoked row and, if we have a + // still-owned parent (typically Runeforging itself, 53428), + // re-record as a child so refund/unlearn still cleans them up. + bool isMetaSkillRevoke = false; + { + auto bounds = sSpellMgr->GetSkillLineAbilityMapBounds(sid); + for (auto it = bounds.first; it != bounds.second; ++it) + { + if (it->second->SkillLine == SKILL_RUNEFORGING) + { + isMetaSkillRevoke = true; + break; + } + } + } + if (isMetaSkillRevoke) + { + if (parent && ownedPanelSpells.count(parent)) + passiveMigrate.emplace_back(parent, sid); + passiveStaleAll.push_back(sid); + ++migrated; + continue; + } + if (allowed.count(sid)) { stale.push_back(sid); @@ -753,11 +784,57 @@ void RevokeBlockedSpellsForPlayer(Player* pl) return false; } +// Allowlist for ACTIVE spells we explicitly want kept as +// panel_spell_children, even though the general policy is "actives in +// children = legacy garbage, drop them" (see +// PruneSkillLineCascadeChildrenFromDb). +// +// The original kAttached set was 100% passives (Frost Fever, Blood +// Plague, Forceful Deflection, Runic Focus). For those, "passive == +// keep" was a perfect proxy. Runeforging changed that: the 8 basic +// rune-enchant spells (53344, 53343, 53341, 53331, 53342, 53323, +// 54447, 54446) are ACTIVE casts that we DO want to attach to the +// Runeforging panel purchase so: +// * The Lua-substitute Runeforge UI can cast them (HasActiveSpell). +// * Refunding Runeforging cleans them up via the standard +// panel_spell_children unlearn path. +// +// Without this allowlist, PruneSkillLineCascadeChildrenFromDb runs +// immediately after PanelLearnSpellChain attaches them, sees them as +// non-passive, drops them, and inserts panel_spell_revoked rows -- +// stranding the player with no usable runeforging menu. +// +// Every entry here MUST also appear in PanelLearnSpellChain::kAttached +// AND in OnPlayerLogin's kFixup list (or a shared source if those ever +// get factored out). The pair ordering is (parentHead, attachedSpell), +// matching kAttached / kFixup. +struct IntentionalActiveAttached { uint32 parent; uint32 child; }; +static IntentionalActiveAttached const kIntentionalActiveAttached[] = { + { 53428, 53344 }, // Runeforging -> Rune of the Fallen Crusader + { 53428, 53343 }, // Runeforging -> Rune of Razorice + { 53428, 53341 }, // Runeforging -> Rune of Cinderglacier + { 53428, 53331 }, // Runeforging -> Rune of Lichbane + { 53428, 53342 }, // Runeforging -> Rune of Spellshattering + { 53428, 53323 }, // Runeforging -> Rune of Swordshattering + { 53428, 54447 }, // Runeforging -> Rune of Spellbreaking + { 53428, 54446 }, // Runeforging -> Rune of Swordbreaking +}; + +[[nodiscard]] static bool IsIntentionalActiveAttachedChild(uint32 parent, uint32 child) +{ + for (auto const& e : kIntentionalActiveAttached) + if (e.parent == parent && e.child == child) + return true; + return false; +} + // Current policy: cascade-granted passives stick as panel_spell_children; // only actives get revoked. This pass exists to scrub *legacy* rows that // older logic inserted incorrectly — specifically, any active spell that // ended up in panel_spell_children from a build that classified things -// differently. Passive children are always retained. +// differently. Passive children are always retained, as are entries +// whitelisted via kIntentionalActiveAttached (Runeforging rune-enchants +// are active casts that we deliberately attach as children). static void PruneSkillLineCascadeChildrenFromDb(Player* pl, uint32 lowGuid) { if (!pl) @@ -776,6 +853,8 @@ static void PruneSkillLineCascadeChildrenFromDb(Player* pl, uint32 lowGuid) SpellInfo const* info = sSpellMgr->GetSpellInfo(child); if (info && info->IsPassive()) continue; // passives always stay + if (IsIntentionalActiveAttachedChild(parent, child)) + continue; // intentional active attachment // Active in children -> legacy garbage. Drop the row, revoke the // spell, and persist into panel_spell_revoked so the login sweep // catches future cascade re-fires. @@ -1228,13 +1307,45 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId) if (!dep) continue; - if (dep->IsPassive()) + // Meta-skill cascade carve-out. Runeforging (776) is a + // CLASS-category skill that, once granted, is supposed to + // cascade ALL its rune-enchant spells (Rune of the Fallen + // Crusader, Razorice, Cinderglacier, Lichbane, Spell-/ + // Sword-shattering, Spell-/Sword-breaking, Stoneskin + // Gargoyle, Nerubian Carapace) for the player to choose + // from at a runeforge anvil. Those rune-enchants are + // ACTIVE spells, so the default policy below would + // revoke them and the player would learn Runeforging + // for nothing. Treat the whole cluster the same way we + // treat passive deps: persist as children of the panel + // purchase so refund/unlearn drops them too, but do NOT + // revoke them. + // + // Detection: walk the dep's own SkillLineAbility entries + // and check for SKILL_RUNEFORGING. This auto-handles all + // 10 rune-enchant spells without an ID-by-ID allowlist. + bool isMetaSkillCascade = false; + { + auto bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId); + for (auto it = bounds.first; it != bounds.second; ++it) + { + if (it->second->SkillLine == SKILL_RUNEFORGING) + { + isMetaSkillCascade = true; + break; + } + } + } + + if (dep->IsPassive() || isMetaSkillCascade) { DbInsertPanelSpellChild(lowGuid, trackId, spellId); if (diag) LOG_INFO("module", - "[paragon-diag] +{} (passive dep, kept as child of {})", - spellId, trackId); + "[paragon-diag] +{} ({} dep, kept as child of {})", + spellId, + isMetaSkillCascade ? "meta-skill" : "passive", + trackId); } else { @@ -1301,6 +1412,34 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId) { 45477, 59921 }, // Icy Touch -> Frost Fever (passive entry) { 45477, 61455 }, // Icy Touch -> Runic Focus (parry from spell power) { 45902, 49410 }, // Blood Strike -> Forceful Deflection (parry from strength) + + // Runeforging -> 8 basic rune-enchants. The + // SkillLineAbility rows for these (skill 776) all ship + // with AcquireMethod = 0 in the DBC (i.e. NOT auto-learn- + // on-skill-grant). For stock DKs the engine's hardcoded + // runeforging UI hand-rolls the cast for whichever rune + // the player picks, but for our Lua-substitute UI the + // server's HandleCastSpellOpcode / HasActiveSpell gate + // rejects the cast unless the spell is in the spellbook. + // Force-attach them as panel children so: + // 1. The player actually owns the spells (cast works). + // 2. Refunding Runeforging cleans them up via the + // standard panel_spell_children unlearn path. + // The two ADVANCED runes (Stoneskin Gargoyle 62158 and + // Nerubian Carapace 70164) are intentionally NOT listed: + // retail gates them behind item drops from heroic + // dungeons / Naxx / ICC, and our SkillLineAbility rows + // for them already use AcquireMethod=0 so the player + // gets them when they pick up the appropriate item, not + // for free with Runeforging itself. + { 53428, 53344 }, // Runeforging -> Rune of the Fallen Crusader + { 53428, 53343 }, // Runeforging -> Rune of Razorice + { 53428, 53341 }, // Runeforging -> Rune of Cinderglacier + { 53428, 53331 }, // Runeforging -> Rune of Lichbane + { 53428, 53342 }, // Runeforging -> Rune of Spellshattering + { 53428, 53323 }, // Runeforging -> Rune of Swordshattering + { 53428, 54447 }, // Runeforging -> Rune of Spellbreaking + { 53428, 54446 }, // Runeforging -> Rune of Swordbreaking }; // Self-heal: a previous build of mod-paragon (briefly shipped) @@ -2256,9 +2395,21 @@ void PushSpellSnapshot(Player* pl) continue; } - SpellInfo const* info = sSpellMgr->GetSpellInfo(sid); - if (info && info->HasAttribute(SPELL_ATTR0_DO_NOT_DISPLAY)) - continue; + // Note: we deliberately do NOT filter SPELL_ATTR0_DO_NOT_DISPLAY + // here. Earlier builds did, on the theory that hidden spells + // shouldn't appear in the spellbook-style Overview tab. That + // turned out to be wrong: cascade-granted hidden passives + // (Forceful Deflection, Frost Fever, ...) live in + // panel_spell_children, not in panel_spells -- so the only + // entries that ever land in this query are the chain heads + // the player explicitly purchased. Those MUST appear in the + // Overview even if their DBC entry is hidden, because they + // are the player's actual purchases (e.g. Runeforging 53428 + // is hidden in the DBC but is the entire Runeforging panel + // purchase). Filtering them out left chars whose only buy + // was Runeforging with an empty Overview tab -- looked like + // a regression but was actually the existing snapshot logic + // mismatching the panel's user-facing semantics. std::string token = (first ? "" : ",") + std::to_string(sid); if (buf.size() + token.size() > kSnapshotChunkBudget) @@ -4018,6 +4169,24 @@ public: { 45477, 59921 }, // Icy Touch -> Frost Fever (passive) { 45477, 61455 }, // Icy Touch -> Runic Focus { 45902, 49410 }, // Blood Strike -> Forceful Deflection + + // Runeforging -> 8 basic rune-enchants. Mirror of + // PanelLearnSpellChain::kAttached: the SLA rows for + // these (skill 776) ship with AcquireMethod=0 so the + // engine's normal cascade never grants them, and for + // the substitute Lua runeforging UI to actually be + // able to cast them HasActiveSpell needs to return + // true. Existing Paragon characters that bought + // Runeforging before this fix landed get them + // retro-granted on their next login. + { 53428, 53344 }, // Runeforging -> Fallen Crusader + { 53428, 53343 }, // Runeforging -> Razorice + { 53428, 53341 }, // Runeforging -> Cinderglacier + { 53428, 53331 }, // Runeforging -> Lichbane + { 53428, 53342 }, // Runeforging -> Spellshattering + { 53428, 53323 }, // Runeforging -> Swordshattering + { 53428, 54447 }, // Runeforging -> Spellbreaking + { 53428, 54446 }, // Runeforging -> Swordbreaking }; for (auto const& lf : kFixup) { diff --git a/src/server/game/Entities/Player/Player.cpp b/src/server/game/Entities/Player/Player.cpp index f2654ae..a79edb8 100644 --- a/src/server/game/Entities/Player/Player.cpp +++ b/src/server/game/Entities/Player/Player.cpp @@ -12048,7 +12048,20 @@ void Player::learnSkillRewardedSpells(uint32 skill_id, uint32 skill_value) // weapon, language, and racial skill cascades stay enabled so things // like recipe auto-learn, weapon proficiencies, and racial perks // still work. - if (getClass() == CLASS_PARAGON) + // + // Carve-out: SKILL_RUNEFORGING (776) is a CLASS-category skill but + // behaves like a profession in this context — the player buys ONE + // panel ability (Runeforging, spell 53428) and the rune-enchant + // spells (Rune of the Fallen Crusader, Razorice, Cinderglacier, ...) + // are supposed to come along for the ride via the standard SLA + // cascade, exactly the same way they do for a stock DK. Without + // this carve-out, the early-return below blocks the cascade and a + // Paragon who buys Runeforging gets the skill but no actual rune + // options at the runeforge anvil. The cascade only fires once per + // skill-grant for 776 (it's not on UpdateSkillsForLevel) so the + // "leaking back into the spellbook" concern that motivates the + // early-return doesn't apply to this skill. + if (getClass() == CLASS_PARAGON && skill_id != SKILL_RUNEFORGING) { if (SkillLineEntry const* sl = sSkillLineStore.LookupEntry(skill_id)) if (sl->categoryId == SKILL_CATEGORY_CLASS) diff --git a/src/server/game/Spells/Spell.cpp b/src/server/game/Spells/Spell.cpp index f9f276e..a73e9ed 100644 --- a/src/server/game/Spells/Spell.cpp +++ b/src/server/game/Spells/Spell.cpp @@ -7860,6 +7860,36 @@ SpellCastResult Spell::CheckSpellFocus() // check spell focus object if (m_spellInfo->RequiresSpellFocus) { + // Fractured / Paragon: skip the GO proximity check for Paragon + // casters when the spell is a runeforge enchant (skill line 776). + // Paragons get a Runeforge tab in the Character Advancement + // panel that lets them apply rune-enchants from anywhere in the + // world -- no need to fly back to Acherus or find the nearest + // Eastern Plaguelands anvil. The skill-line gate keeps the + // bypass tightly scoped: only the 10 SkillLineAbility-tagged + // rune-enchant spells qualify, every other spell that uses + // SpellFocusObject (Enchanting bench, Cooking fire, Lockpicking + // anvil, etc.) keeps its requirement intact. + // + // DK / other class casters are unchanged -- this carve-out + // intentionally checks getClass() == CLASS_PARAGON rather than + // IsClass(CLASS_DEATH_KNIGHT, CLASS_CONTEXT_ABILITY) (which + // would also return true for Paragons via mod-paragon's hook). + // Stock DK behavior must stay vanilla; the QoL bypass is a + // class-12 feature only. + if (m_caster && m_caster->IsPlayer() + && m_caster->ToPlayer()->getClass() == CLASS_PARAGON) + { + auto bounds = sSpellMgr->GetSkillLineAbilityMapBounds(m_spellInfo->Id); + for (auto it = bounds.first; it != bounds.second; ++it) + { + if (it->second->SkillLine == SKILL_RUNEFORGING) + { + return SPELL_CAST_OK; + } + } + } + CellCoord p(Acore::ComputeCellCoord(m_caster->GetPositionX(), m_caster->GetPositionY())); Cell cell(p);