diff --git a/modules/mod-paragon/src/Paragon_Essence.cpp b/modules/mod-paragon/src/Paragon_Essence.cpp index 2c00d02..56e36ed 100644 --- a/modules/mod-paragon/src/Paragon_Essence.cpp +++ b/modules/mod-paragon/src/Paragon_Essence.cpp @@ -543,16 +543,47 @@ void RevokeBlockedSpellsForPlayer(Player* pl) BuildPanelOwnedSpellsAllowlist(lowGuid, allowed); QueryResult r = CharacterDatabase.Query( - "SELECT revoked_spell_id FROM character_paragon_panel_spell_revoked WHERE guid = {}", + "SELECT parent_spell_id, revoked_spell_id FROM character_paragon_panel_spell_revoked WHERE guid = {}", lowGuid); if (!r) return; + // Cache panel_spells for this guid so we can reattach migrating passive + // rows to a still-owned parent (per "passives stick" policy below). + std::unordered_set ownedPanelSpells; + if (QueryResult ps = CharacterDatabase.Query( + "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid)) + { + do { ownedPanelSpells.insert(ps->Fetch()[0].Get()); } while (ps->NextRow()); + } + uint32 removed = 0; + uint32 migrated = 0; std::vector stale; // allowlisted -> drop the row + std::vector passiveStaleAll; // passive revokes -> drop unconditionally + std::vector> passiveMigrate; // (parent, child) -> insert as child do { - uint32 const sid = r->Fetch()[0].Get(); + Field const* f = r->Fetch(); + uint32 const parent = f[0].Get(); + uint32 const sid = f[1].Get(); + + // Legacy migration: previous builds revoked passive cascade + // rewards (Forceful Deflection, Runic Focus, ...). New policy is + // that all cascade-granted passives stick. Drop those rows + // and, where we have a still-owned parent, reattach the passive + // as a panel_spell_child so future reset/unlearn drops it + // alongside the parent. + SpellInfo const* info = sSpellMgr->GetSpellInfo(sid); + if (info && info->IsPassive()) + { + if (parent && ownedPanelSpells.count(parent)) + passiveMigrate.emplace_back(parent, sid); + passiveStaleAll.push_back(sid); + ++migrated; + continue; + } + if (allowed.count(sid)) { stale.push_back(sid); @@ -565,6 +596,29 @@ void RevokeBlockedSpellsForPlayer(Player* pl) } } while (r->NextRow()); + for (auto const& kv : passiveMigrate) + DbInsertPanelSpellChild(lowGuid, kv.first, kv.second); + + if (!passiveStaleAll.empty()) + { + std::sort(passiveStaleAll.begin(), passiveStaleAll.end()); + passiveStaleAll.erase(std::unique(passiveStaleAll.begin(), passiveStaleAll.end()), passiveStaleAll.end()); + + std::string in; + in.reserve(passiveStaleAll.size() * 8); + bool first = true; + for (uint32 sid : passiveStaleAll) + { + if (!first) in += ","; + in += std::to_string(sid); + first = false; + } + CharacterDatabase.DirectExecute( + "DELETE FROM character_paragon_panel_spell_revoked " + "WHERE guid = {} AND revoked_spell_id IN ({})", + lowGuid, in); + } + if (!stale.empty()) { // Build IN-list. `stale` is bounded by the player's revoked rows. @@ -597,6 +651,11 @@ void RevokeBlockedSpellsForPlayer(Player* pl) LOG_INFO("module", "Paragon panel: re-revoked {} skill-cascade dependents for {} on login", removed, pl->GetName()); + + if (migrated) + LOG_INFO("module", + "Paragon panel: migrated {} passive revokes to children for {} (legacy)", + migrated, pl->GetName()); } [[nodiscard]] static bool SkillLineAbilityIsSkillCascadeSigned(SkillLineAbilityEntry const* sla) @@ -632,12 +691,52 @@ void RevokeBlockedSpellsForPlayer(Player* pl) return out; } +// Forward: defined after `CollectSpellChainIds` (needs the chain walker). +[[nodiscard]] static bool IsDepGrantedByLearnSpellOnAnyChainRank(uint32 anchorChainHead, uint32 depSpellId); + +// Plague Strike / Icy Touch teach their disease passives through the same +// DK weapon skill-line machinery as true cascade rewards (Forceful +// Deflection, …) — there is often no SPELL_EFFECT_LEARN_SPELL row on the +// strike itself (the debuff spell is reached via TRIGGER_SPELL at cast +// time). Without an explicit carve-out, `IsSpellSkillLineCascadeDependent` +// returns true and we revoke Blood Plague / Frost Fever right after the +// learnSpell diff inserts them. +[[nodiscard]] static bool IsParagonStrikeTiedDiseasePassive(uint32 anchorChainHead, uint32 depSpellId) +{ + uint32 const dHead = sSpellMgr->GetFirstSpellInChain(depSpellId); + uint32 const depH = dHead ? dHead : depSpellId; + // On-target debuff spellbook rows (wrong for our panel attach, but still + // strike-tied) plus the correct passive spellbook entries (59879 / 59921). + // Without the latter, `PruneSkillLineCascadeChildrenFromDb` classifies + // (45462,59879) as a skill-line cascade child and strips the forced attach + // immediately after `PanelLearnSpellChain` returns. + if (anchorChainHead == 45462 && (depH == 55078 || depH == 59879)) // Plague Strike -> Blood Plague + return true; + if (anchorChainHead == 45477 && (depH == 55095 || depH == 59921)) // Icy Touch -> Frost Fever + return true; + return false; +} + // True when `depSpellId` is granted as a skill-line reward on one of the // same SkillLines as `anchorSpellId` (e.g. Blood Strike -> Forceful // Deflection / Blood Presence). Passives learned only via spell effects // (disease auras, etc.) typically return false here. +// +// IMPORTANT: some passives (Blood Plague from Plague Strike) sit on the +// same SkillLine as the anchor AND carry LEARNED_ON_SKILL_* rows, so the +// naive skill-line intersection would classify them as "cascade" and +// revoke them. Those spells are still legitimate spell-effect grants when +// Plague Strike's SPELL_EFFECT_LEARN_SPELL points at them — we exclude +// that case first via `IsDepGrantedByLearnSpellOnAnyChainRank`. [[nodiscard]] static bool IsSpellSkillLineCascadeDependent(uint32 anchorSpellId, uint32 depSpellId) { + uint32 const anchorHead = sSpellMgr->GetFirstSpellInChain(anchorSpellId); + uint32 const head = anchorHead ? anchorHead : anchorSpellId; + if (IsDepGrantedByLearnSpellOnAnyChainRank(head, depSpellId)) + return false; + if (IsParagonStrikeTiedDiseasePassive(head, depSpellId)) + return false; + std::unordered_set const anchorLines = SkillLinesLinkedToSpell(anchorSpellId); if (anchorLines.empty()) return false; @@ -654,9 +753,11 @@ void RevokeBlockedSpellsForPlayer(Player* pl) return false; } -// Older builds recorded skill-line cascade passives (Forceful Deflection, -// Runic Focus, ...) as `panel_spell_children`. Strip those rows and -// revoke the spell so the login sweep + reset logic match current policy. +// 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. static void PruneSkillLineCascadeChildrenFromDb(Player* pl, uint32 lowGuid) { if (!pl) @@ -672,8 +773,12 @@ static void PruneSkillLineCascadeChildrenFromDb(Player* pl, uint32 lowGuid) { uint32 const parent = r->Fetch()[0].Get(); uint32 const child = r->Fetch()[1].Get(); - if (!IsSpellSkillLineCascadeDependent(parent, child)) - continue; + SpellInfo const* info = sSpellMgr->GetSpellInfo(child); + if (info && info->IsPassive()) + continue; // passives always stay + // 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. CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_panel_spell_children WHERE guid = {} AND parent_spell_id = {} AND child_spell_id = {}", @@ -760,6 +865,10 @@ void RevokeUnwantedCascadeSpellsForPlayer(Player* pl) // For each activated SkillLine, walk its rewards and queue revokes for // active spells that aren't allowlisted but are currently in m_spells. + // Passive cascade rewards (Forceful Deflection, Runic Focus, ...) are + // intentionally retained — the panel-purchase commit recorded them as + // panel_spell_children so reset/queue-unlearn will drop them with the + // parent, but the login sweep MUST NOT strip them from the spellbook. std::vector toRevoke; for (uint32 skillLine : ourSkillLines) { @@ -779,6 +888,8 @@ void RevokeUnwantedCascadeSpellsForPlayer(Player* pl) SpellInfo const* info = sSpellMgr->GetSpellInfo(sid); if (!info) continue; + if (info->IsPassive()) + continue; // policy: passives always stay toRevoke.push_back(sid); } @@ -876,6 +987,53 @@ void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set& out) } } +// 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). +// Distinguishes Blood Plague (taught directly by Plague Strike ranks) +// from Forceful Deflection (skill-line reward only, no LEARN_SPELL on +// the Blood Strike ranks). +[[nodiscard]] static bool IsDepGrantedByLearnSpellOnAnyChainRank(uint32 anchorChainHead, uint32 depSpellId) +{ + if (!anchorChainHead || !depSpellId) + return false; + + std::unordered_set anchorRanks; + CollectSpellChainIds(anchorChainHead, anchorRanks); + if (anchorRanks.empty()) + anchorRanks.insert(anchorChainHead); + + std::unordered_set learnGrantIds; + for (uint32 rnk : anchorRanks) + { + SpellInfo const* si = sSpellMgr->GetSpellInfo(rnk); + if (!si) + continue; + for (uint8 e = 0; e < MAX_SPELL_EFFECTS; ++e) + { + if (si->Effects[e].Effect != SPELL_EFFECT_LEARN_SPELL) + continue; + uint32 const grant = si->Effects[e].TriggerSpell; + if (!grant) + continue; + CollectSpellChainIds(grant, learnGrantIds); + } + } + + if (learnGrantIds.empty()) + return false; + + std::unordered_set depRanks; + CollectSpellChainIds(depSpellId, depRanks); + if (depRanks.empty()) + depRanks.insert(depSpellId); + + for (uint32 d : depRanks) + if (learnGrantIds.count(d)) + return true; + return false; +} + // Learn every rank of the spell chain that contains `baseSpellId` for which // the player meets the SpellLevel requirement, then record ONLY the // first-rank id in character_paragon_panel_spells. @@ -896,7 +1054,11 @@ void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set& out) // classify those side effects: // * Passives that are pure spell-effect side effects (disease auras, // etc.) are kept; we record them in character_paragon_panel_spell_children -// so reset unlearns them alongside the parent. +// so reset unlearns them alongside the parent. SPELL_EFFECT_LEARN_SPELL +// grants are detected automatically; DK Plague Strike / Icy Touch disease +// passives (Blood Plague / Frost Fever) share the weapon skill line with +// their strikes and need an explicit carve-out — see +// `IsParagonStrikeTiedDiseasePassive` inside `IsSpellSkillLineCascadeDependent`. // * Passives that are skill-line cascade rewards on the same SkillLine // as the rank being learned (Forceful Deflection with Blood Strike) // are revoked like actives — they are not panel children. @@ -959,8 +1121,14 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId) int32(after.size()) - int32(before.size())); // Diff: classify each new spell that wasn't in the chain we - // asked for. Pure spell-effect passives stick (children); skill- - // line cascade passives and actives get revoked. + // asked for. + // * Passives (Forceful Deflection, Runic Focus, Blood Plague + // 59879, ...) ALWAYS stick. Recorded as panel_spell_children + // so reset/queue-unlearn drop them with the parent. + // * Actives (Blood Presence stance, Death Coil, Death Grip, + // ...) are revoked + persisted. AC's `_LoadSkills` re-fires + // `learnSkillRewardedSpells` on every login and would + // silently re-grant them otherwise. for (uint32 spellId : after) { if (before.count(spellId)) @@ -979,32 +1147,15 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId) if (dep->IsPassive()) { - if (IsSpellSkillLineCascadeDependent(cur, spellId)) - { - pl->removeSpell(spellId, SPEC_MASK_ALL, false); - DbInsertPanelSpellRevoked(lowGuid, trackId, spellId); - if (diag) - LOG_INFO("module", - "[paragon-diag] +{} (skill-line passive dep, REVOKED, parent={})", - spellId, trackId); - } - else - { - DbInsertPanelSpellChild(lowGuid, trackId, spellId); - if (diag) - LOG_INFO("module", - "[paragon-diag] +{} (passive dep, kept as child of {})", - spellId, trackId); - } + DbInsertPanelSpellChild(lowGuid, trackId, spellId); + if (diag) + LOG_INFO("module", + "[paragon-diag] +{} (passive dep, kept as child of {})", + spellId, trackId); } else { pl->removeSpell(spellId, SPEC_MASK_ALL, false); - // Persist so we can re-revoke after the next login -- - // AC's _LoadSkills -> learnSkillRewardedSpells will - // re-grant skill-rewarded actives (Blood Presence, - // Death Coil, Death Grip, ...) every time the player - // logs in. DbInsertPanelSpellRevoked(lowGuid, trackId, spellId); if (diag) LOG_INFO("module", @@ -1022,6 +1173,93 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId) DbInsertPanelSpell(lowGuid, trackId); + // ------------------------------------------------------------------ + // Forced passive attachments. Some Wrath spells *should* come with a + // passive entry in the spellbook (so the player can read what the + // disease/aura does), but the engine only adds those passives via + // `learnSkillRewardedSpells`, which fires exactly ONCE per skill — + // the first time the player learns a spell on that skill line. If + // the player bought Blood Strike before Plague Strike, the cascade + // ran for Blood Strike, revoked the disease passives, and a later + // Plague Strike purchase finds the skill already known and never + // re-grants them. + // + // For each (chain head -> attached spell) pair below: if the player + // does not already have the attached spell, learnSpell it (silently + // -- the silence window is still open) and record it as a panel + // child of `trackId` so reset/queue-unlearn drop it alongside the + // parent. If the attached spell currently has a stale revoke row + // pointing at it (left over from the cascade run for a different + // parent), that row is dropped so the next login doesn't unlearn it. + // ------------------------------------------------------------------ + { + // Static, intentionally tiny: every entry is a hand-curated + // spell-effect attachment that the spellbook UX expects to + // travel with the parent. Add new entries sparingly. + // IMPORTANT: 55078 / 55095 are the on-target *debuff* spell IDs for + // Blood Plague / Frost Fever (cast on enemies by Plague Strike / + // Icy Touch via SPELL_EFFECT_TRIGGER_SPELL). They are NOT marked + // passive in Spell.dbc, so the client renders them as castable + // spellbook icons. The correct *passive* spellbook entries the + // player is supposed to see are 59879 / 59921 (the descriptive + // "Passive disease" rows; SPELL_ATTR0_PASSIVE bit set). + struct AttachedPassive { uint32 parentHead; uint32 attachedSpell; }; + static AttachedPassive const kAttached[] = { + { 45462, 59879 }, // Plague Strike -> Blood Plague (passive entry) + { 45477, 59921 }, // Icy Touch -> Frost Fever (passive entry) + }; + + // Self-heal: a previous build of mod-paragon (briefly shipped) + // attached the on-target debuff IDs (55078 / 55095) instead of + // the passive spellbook IDs (59879 / 59921). Drop any such row + // and unlearn the spell so the player isn't left with a phantom + // "castable" Blood Plague / Frost Fever icon in their spellbook. + struct LegacyAttached { uint32 parentHead; uint32 wrongSpell; }; + static LegacyAttached const kLegacyWrong[] = { + { 45462, 55078 }, + { 45477, 55095 }, + }; + for (auto const& lw : kLegacyWrong) + { + if (lw.parentHead != trackId) + continue; + CharacterDatabase.DirectExecute( + "DELETE FROM character_paragon_panel_spell_children " + "WHERE guid = {} AND parent_spell_id = {} AND child_spell_id = {}", + lowGuid, trackId, lw.wrongSpell); + if (pl->HasSpell(lw.wrongSpell)) + pl->removeSpell(lw.wrongSpell, SPEC_MASK_ALL, false); + } + + for (auto const& ap : kAttached) + { + if (ap.parentHead != trackId) + continue; + if (pl->HasSpell(ap.attachedSpell)) + { + DbInsertPanelSpellChild(lowGuid, trackId, ap.attachedSpell); + continue; + } + pl->learnSpell(ap.attachedSpell, false); + DbInsertPanelSpellChild(lowGuid, trackId, ap.attachedSpell); + + // Drop any stale revoke row pointing at the just-attached + // spell. Otherwise the login sweep would unlearn it and + // PushSpellSnapshot's !HasSpell branch would then orphan + // the panel_spell_children row. + std::unordered_set attachedChain; + CollectSpellChainIds(ap.attachedSpell, attachedChain); + if (attachedChain.empty()) + attachedChain.insert(ap.attachedSpell); + DbDeletePanelSpellRevokedForChain(lowGuid, attachedChain); + + if (diag) + LOG_INFO("module", + "[paragon-diag] forced-attach +{} as child of {} (skill cascade missed it)", + ap.attachedSpell, trackId); + } + } + // Clear any stale revoke rows that targeted a rank in this chain. A // prior login sweep (before the purchase) or an earlier commit-time // diff (e.g., this chain was revoked as a cascade dependent of a @@ -1049,6 +1287,26 @@ void DbUpsertPanelTalent(uint32 lowGuid, uint32 talentId, uint32 rank) lowGuid, talentId, rank); } +// Player::HasTalent(spell, spec) ignores `spec` and only tests the active +// spec — useless for dual-spec. Walk the talent map + specMask instead. +[[nodiscard]] static bool PlayerTalentRankSpellKnownInAnySpec(Player* pl, uint32 rankSpellId) +{ + if (!pl || !rankSpellId) + return false; + PlayerTalentMap const& tm = pl->GetTalentMap(); + auto itr = tm.find(rankSpellId); + if (itr == tm.end() || !itr->second) + return false; + PlayerTalent const* const pt = itr->second; + if (pt->State == PLAYERSPELL_REMOVED) + return false; + uint8 const mask = pt->specMask; + for (uint8 s = 0; s < pl->GetSpecsCount(); ++s) + if (mask & (1u << s)) + return true; + return false; +} + uint32 ComputeTalentRankAnySpec(Player* pl, uint32 talentId) { if (!pl) @@ -1059,10 +1317,9 @@ uint32 ComputeTalentRankAnySpec(Player* pl, uint32 talentId) return 0; uint32 best = 0; - for (uint8 spec = 0; spec < pl->GetSpecsCount(); ++spec) - for (uint8 r = 0; r < MAX_TALENT_RANK; ++r) - if (te->RankID[r] && pl->HasTalent(te->RankID[r], spec)) - best = std::max(best, uint32(r + 1)); + for (uint8 r = 0; r < MAX_TALENT_RANK; ++r) + if (te->RankID[r] && PlayerTalentRankSpellKnownInAnySpec(pl, te->RankID[r])) + best = std::max(best, uint32(r + 1)); return best; } @@ -1126,7 +1383,8 @@ std::vector ParseCsvUInt(std::string_view csv) void SendSilenceOpenForCommit(Player* pl, std::vector> const& spellsAndCosts, std::vector> const& talentDeltas, - std::vector const& unlearnTrackIds = {}) + std::vector const& unlearnTrackIds = {}, + std::vector const& talentUnlearnIds = {}) { if (!pl) return; @@ -1166,6 +1424,19 @@ void SendSilenceOpenForCommit(Player* pl, allow.insert(te->RankID[r]); } + // Talent unlearns: each rank id is about to fire SMSG_REMOVED_SPELL. + // Whitelist them so the "You have unlearned " toast + // shown to the user is not suppressed. + for (uint32 tid : talentUnlearnIds) + { + TalentEntry const* te = sTalentStore.LookupEntry(tid); + if (!te) + continue; + for (uint8 r = 0; r < MAX_TALENT_RANK; ++r) + if (te->RankID[r]) + allow.insert(te->RankID[r]); + } + // Open the window even when the allow list is empty (still useful for // talent-only commits that cascade unrelated passives, etc.). std::string body = "R SILENCE OPEN"; @@ -1200,6 +1471,73 @@ void SendSilenceClose(Player* pl) SendAddonMessage(pl, "R SILENCE CLOSE"); } +// --------------------------------------------------------------------------- +// Buff-cheese cleanup. When a panel-purchased spell is unlearned (queue +// unlearn, build swap, full reset) any aura the player cast on themselves +// with that spell goes away with the spell. Without this, a Paragon could +// cast Power Word: Fortitude on themselves, queue PW:F for unlearn on +// Learn All to recoup the AE, and keep the buff for free until next zone +// change. +// +// Filters: only the player's *own* self-cast auras are touched. Buffs on +// the same player from another caster (a real priest in the group, paladin +// blessings, etc.) are left alone, and any auras the player cast on others +// are not affected by this sweep -- crowd-cleansing on every panel mutation +// would be more annoying than the cheese it closes. +// +// The chain walk is so any rank's aura goes regardless of which rank was +// active when it was cast (cast PW:F R8, then unlearn the chain whose head +// is R1 -> we still have to look at R8 to find the active aura). +// --------------------------------------------------------------------------- +void RemoveSelfCastAurasForChain(Player* pl, uint32 chainAnyRankId) +{ + if (!pl || !chainAnyRankId) + return; + std::unordered_set chainIds; + CollectSpellChainIds(chainAnyRankId, chainIds); + if (chainIds.empty()) + chainIds.insert(chainAnyRankId); + ObjectGuid const myGuid = pl->GetGUID(); + for (uint32 rankId : chainIds) + pl->RemoveOwnedAura(rankId, myGuid); +} + +// Blanket sweep of self-cast non-passive auras. Used at the tail of +// HandleBuildLoad (build swap) so any stale self-buffs cast prior to the +// swap are cleared, even if the spell that produced them is also in the +// new build's recipe. There is no useful semantic of "preserve buffs across +// loadout swap" -- the swap is meant to be a clean state transition, and +// keeping arbitrary buffs across it is exactly the cheese vector for +// any spell that is in the OUTGOING recipe but not the INCOMING one. +// +// Skipped: +// - auras whose caster is not the player (party buffs, NPC debuffs) +// - passives -- spellbook-driven; removing them just makes the engine's +// CastPassiveAuras / spellbook_apply_passives re-grant them on the +// next tick. Pointless churn. +void SweepSelfCastSpellAuras(Player* pl) +{ + if (!pl) + return; + ObjectGuid const myGuid = pl->GetGUID(); + std::vector toRemove; + toRemove.reserve(8); + for (auto const& kv : pl->GetOwnedAuras()) + { + Aura const* aura = kv.second; + if (!aura) + continue; + if (aura->GetCasterGUID() != myGuid) + continue; + SpellInfo const* si = aura->GetSpellInfo(); + if (!si || si->IsPassive()) + continue; + toRemove.push_back(kv.first); + } + for (uint32 spellId : toRemove) + pl->RemoveOwnedAura(spellId, myGuid); +} + // Removes one Character Advancement spell purchase (chain head in // character_paragon_panel_spells). Refunds that row's AE cost, unlearns // tracked passive children then the parent chain, and clears matching @@ -1260,11 +1598,141 @@ bool PanelUnlearnSpellPurchase(Player* pl, uint32 spellId, std::string* err) "DELETE FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {}", lowGuid, sid); + // Buff-cheese close: drop any self-cast aura whose source is this chain. + // (See RemoveSelfCastAurasForChain comment.) + RemoveSelfCastAurasForChain(pl, sid); + ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); d.abilityEssence += refund; return true; } +// Removes one Character Advancement talent purchase entirely. Refunds all +// ranks worth of TE (and AE for addToSpellBook talents), drops every rank +// spell from m_spells / m_talents across all specs, deletes the panel_talents +// row. Symmetric counterpart of PanelUnlearnSpellPurchase. +bool PanelUnlearnTalentPurchase(Player* pl, uint32 talentId, uint32* outRefundAE, + uint32* outRefundTE, std::string* err) +{ + if (outRefundAE) *outRefundAE = 0; + if (outRefundTE) *outRefundTE = 0; + + if (!pl || !talentId) + { + if (err) *err = "bad player or talent"; + return false; + } + + uint32 const lowGuid = pl->GetGUID().GetCounter(); + + QueryResult q = CharacterDatabase.Query( + "SELECT `rank` FROM character_paragon_panel_talents WHERE guid = {} AND talent_id = {}", + lowGuid, talentId); + if (!q) + { + if (err) *err = fmt::format("talent {} is not a panel purchase", talentId); + return false; + } + uint32 const dbRank = q->Fetch()[0].Get(); + + TalentEntry const* te = sTalentStore.LookupEntry(talentId); + if (!te) + { + if (err) *err = fmt::format("unknown talent {}", talentId); + return false; + } + + // Use the player's *actual* rank across specs, capped at the DB record. + // Refund matches what was actually spent: a partial-rank purchase that + // got reset out of one spec but not another should refund what was + // recorded in panel_talents, not the engine state. + uint32 const actual = ComputeTalentRankAnySpec(pl, talentId); + uint32 const refundRanks = std::min(dbRank, actual ? actual : dbRank); + if (!refundRanks) + { + // Player has no rank but row exists -> stale. Drop it and skip + // refund so we don't double-credit a previous reset. + CharacterDatabase.DirectExecute( + "DELETE FROM character_paragon_panel_talents WHERE guid = {} AND talent_id = {}", + lowGuid, talentId); + return true; + } + + uint32 const tePerRank = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); + uint32 const aePerRank = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); + uint32 const refundTE = refundRanks * tePerRank; + uint32 const refundAE = te->addToSpellBook ? (refundRanks * aePerRank) : 0; + + // Wipe every rank across all specs. Important caveats: + // * Player::removeSpell only touches m_spells. The m_talents entry + // (PlayerTalent + specMask) is NOT cleared, so HasTalent / + // `HasBeastMasteryInAnySpec` keep returning true after. + // Player::resetTalents pairs `_removeTalentAurasAndSpells` + + // `_removeTalent` for that reason — mirror it here. + // * `addToSpellBook` talents (Bladestorm/Starfall/...) also live in + // the spellbook and need a removeSpell so the icon goes away. + // * Some talents trigger `IsAdditionalTalentSpell` extras via + // SPELL_EFFECT_LEARN_SPELL — strip those too (matches resetTalents). + for (uint8 ri = 0; ri < MAX_TALENT_RANK; ++ri) + { + uint32 const rankSpell = te->RankID[ri]; + if (!rankSpell) + continue; + + SpellInfo const* spellInfo = sSpellMgr->GetSpellInfo(rankSpell); + + pl->_removeTalentAurasAndSpells(rankSpell); + + if (te->addToSpellBook && spellInfo + && !spellInfo->HasAttribute(SPELL_ATTR0_PASSIVE) + && !spellInfo->HasEffect(SPELL_EFFECT_LEARN_SPELL)) + { + if (pl->HasSpell(rankSpell)) + pl->removeSpell(rankSpell, SPEC_MASK_ALL, false); + } + + if (spellInfo) + { + for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i) + { + uint32 const trig = spellInfo->Effects[i].TriggerSpell; + if (spellInfo->Effects[i].Effect == SPELL_EFFECT_LEARN_SPELL && trig + && sSpellMgr->IsAdditionalTalentSpell(trig) + && pl->HasSpell(trig)) + { + pl->removeSpell(trig, SPEC_MASK_ALL, false); + } + } + } + + // Drop the m_talents row so HasTalent / HasBeastMasteryInAnySpec / + // OnPlayerLearnTalents bookkeeping stop seeing the talent. + pl->_removeTalent(rankSpell, SPEC_MASK_ALL); + } + + // Push the engine-side talent state to the client so the talent UI + // (and the +talent-points pool) reflects the unlearn immediately. + pl->SendTalentsInfoData(false); + + CharacterDatabase.DirectExecute( + "DELETE FROM character_paragon_panel_talents WHERE guid = {} AND talent_id = {}", + lowGuid, talentId); + + ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); + d.abilityEssence += refundAE; + d.talentEssence += refundTE; + + if (outRefundAE) *outRefundAE = refundAE; + if (outRefundTE) *outRefundTE = refundTE; + return true; +} + +// Forward declarations for helpers defined later in this TU. HandleCommit +// is far enough above them in the file that we'd need to either rearrange +// or declare upfront; declarations are smaller surface area. +bool HasBeastMasteryInAnySpec(Player* pl); +void MaybeForcePetTalentResetForBeastMasteryLoss(Player* pl, bool hadBeastMasteryBefore); + bool HandleCommit(Player* pl, std::string const& body, std::string* err) { // Strip leading "C COMMIT " (already stripped by caller, but be defensive) @@ -1285,17 +1753,42 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err) std::string_view spellsCsv = rest.substr(2, tPos - 2); std::string_view talentsCsv; std::string_view unlearnCsv; - size_t const uPos = rest.find(" u:", tPos); - if (uPos != std::string_view::npos) + std::string_view talentUnlearnCsv; + + // Layout after " t:" is one of: + // (no unlearns at all) + // u: (legacy) + // u:<...> tu: (current) + // tu: (no spell unlearns) + // The " u:" / " tu:" tokens are kept distinct (note the leading space) + // so a substring match for " u:" never collides with " tu:". + size_t const uPos = rest.find(" u:", tPos); + size_t const tuPos = rest.find(" tu:", tPos); + + auto inRange = [](size_t v) { return v != std::string_view::npos; }; + + if (inRange(uPos) && inRange(tuPos)) + { + talentsCsv = rest.substr(tPos + 3, uPos - (tPos + 3)); + unlearnCsv = rest.substr(uPos + 3, tuPos - (uPos + 3)); + talentUnlearnCsv = rest.substr(tuPos + 4); + } + else if (inRange(uPos)) { talentsCsv = rest.substr(tPos + 3, uPos - (tPos + 3)); unlearnCsv = rest.substr(uPos + 3); } + else if (inRange(tuPos)) + { + talentsCsv = rest.substr(tPos + 3, tuPos - (tPos + 3)); + talentUnlearnCsv = rest.substr(tuPos + 4); + } else talentsCsv = rest.substr(tPos + 3); - std::vector spellIds = ParseCsvUInt(spellsCsv); - std::vector unlearnRaw = ParseCsvUInt(unlearnCsv); + std::vector spellIds = ParseCsvUInt(spellsCsv); + std::vector unlearnRaw = ParseCsvUInt(unlearnCsv); + std::vector talentUnlearnRaw = ParseCsvUInt(talentUnlearnCsv); // Talents are "id:delta,id:delta,...". Parse into vector of pairs. std::vector> talentDeltas; @@ -1345,7 +1838,19 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err) unlearnTracks.push_back(tid); } - if (spellIds.size() + talentDeltas.size() + unlearnTracks.size() > kCommitMaxItems) + // Dedupe talent unlearns (same talent twice in one commit is a no-op). + std::unordered_set talentUnlearnSet; + std::vector talentUnlearns; + talentUnlearns.reserve(talentUnlearnRaw.size()); + for (uint32 tid : talentUnlearnRaw) + { + if (!tid) + continue; + if (talentUnlearnSet.insert(tid).second) + talentUnlearns.push_back(tid); + } + + if (spellIds.size() + talentDeltas.size() + unlearnTracks.size() + talentUnlearns.size() > kCommitMaxItems) { *err = "commit exceeds size cap"; return false; @@ -1364,6 +1869,47 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err) unlearnRefundAE += LookupSpellAECost(tid); } + // Pre-validate talent unlearns. Each must be a panel purchase, must + // not also appear in talentDeltas (can't learn + unlearn in one + // commit), and contributes its full ranks * costPerRank to the + // commit's refund pool. addToSpellBook talents add AE to the pool; + // all talents add TE. + uint32 talentUnlearnRefundAE = 0; + uint32 talentUnlearnRefundTE = 0; + { + uint32 const tePerRank_pre = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); + uint32 const aePerRank_pre = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); + for (uint32 tid : talentUnlearns) + { + for (auto const& [td, _delta] : talentDeltas) + { + if (td == tid) + { + *err = "cannot learn and unlearn the same talent in one commit"; + return false; + } + } + QueryResult r = CharacterDatabase.Query( + "SELECT `rank` FROM character_paragon_panel_talents WHERE guid = {} AND talent_id = {}", + pl->GetGUID().GetCounter(), tid); + if (!r) + { + *err = fmt::format("cannot unlearn talent {} (not a panel purchase)", tid); + return false; + } + uint32 const rank = r->Fetch()[0].Get(); + TalentEntry const* te = sTalentStore.LookupEntry(tid); + if (!te) + { + *err = fmt::format("unknown talent {}", tid); + return false; + } + talentUnlearnRefundTE += rank * tePerRank_pre; + if (te->addToSpellBook) + talentUnlearnRefundAE += rank * aePerRank_pre; + } + } + // Pre-validate spells: must be valid SpellInfo, not already learned, // and afford their combined AE cost. uint32 totalAE = 0; @@ -1430,15 +1976,16 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err) else talentsTE += delta * tePerRank; } - if (GetTE(pl) < talentsTE) + if (GetTE(pl) + talentUnlearnRefundTE < talentsTE) { - *err = fmt::format("not enough TE (need {} have {})", talentsTE, GetTE(pl)); + *err = fmt::format("not enough TE (need {} have {} plus {} from talent unlearns in this commit)", + talentsTE, GetTE(pl), talentUnlearnRefundTE); return false; } - if (GetAE(pl) + unlearnRefundAE < (totalAE + talentsAE)) + if (GetAE(pl) + unlearnRefundAE + talentUnlearnRefundAE < (totalAE + talentsAE)) { - *err = fmt::format("not enough AE (need {} total; you have {} plus {} from unlearns in this commit)", - totalAE + talentsAE, GetAE(pl), unlearnRefundAE); + *err = fmt::format("not enough AE (need {} total; you have {} plus {} from spell unlearns and {} from talent unlearns in this commit)", + totalAE + talentsAE, GetAE(pl), unlearnRefundAE, talentUnlearnRefundAE); return false; } @@ -1448,10 +1995,16 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err) // learnSpell drags along (Death Coil/Death Grip/Blood Plague/Blood // Presence/Forceful Deflection/Runic Focus/...) don't spam learn/ // unlearn toasts. Allow list = chain ranks of explicitly purchased - // spells + talent rank ids + chains/children for intentional unlearns. - SendSilenceOpenForCommit(pl, spellsAndCosts, talentDeltas, unlearnTracks); + // spells + talent rank ids + chains/children for intentional unlearns + // (spells + talents). + SendSilenceOpenForCommit(pl, spellsAndCosts, talentDeltas, unlearnTracks, talentUnlearns); - // Apply spell unlearns first so refunded AE is available for spends. + // Capture BM pre-state for the pet-talent-respec check below. Beast + // Mastery is a talent, so only the talent unlearn path can flip the + // value within this commit. + bool const hadBeastMasteryPre = !talentUnlearns.empty() && HasBeastMasteryInAnySpec(pl); + + // Apply unlearns first so refunded AE/TE is available for spends. for (uint32 tid : unlearnTracks) { if (!PanelUnlearnSpellPurchase(pl, tid, err)) @@ -1460,6 +2013,25 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err) return false; } } + for (uint32 tid : talentUnlearns) + { + if (!PanelUnlearnTalentPurchase(pl, tid, /*outRefundAE*/nullptr, + /*outRefundTE*/nullptr, err)) + { + SendSilenceClose(pl); + return false; + } + } + + // If a talent that puts an active aura on the player (e.g. an + // addToSpellBook talent like Improved Devotion Aura) just got + // refunded, the aura should go with it. Mirrors the build-swap + // sweep. Cheap when no talents were unlearned. + if (!talentUnlearns.empty()) + { + SweepSelfCastSpellAuras(pl); + MaybeForcePetTalentResetForBeastMasteryLoss(pl, hadBeastMasteryPre); + } // Apply spells: each consumes its individual AE cost. PanelLearnSpellChain // also grants every higher rank up to the player's current level so the @@ -1682,6 +2254,13 @@ bool HandleParagonResetAbilities(Player* pl, std::string* err) if (pl->HasSpell(sid)) pl->removeSpell(sid, SPEC_MASK_ALL, false); + + // Buff-cheese close: drop any self-cast aura sourced from this + // chain. Same rationale as PanelUnlearnSpellPurchase, applied + // here so a full Reset Abilities (or the reset phase of a build + // swap) doesn't leave Power Word: Fortitude / Inner Fire / etc. + // ticking after the spell is gone from the spellbook. + RemoveSelfCastAurasForChain(pl, sid); } while (r->NextRow()); } @@ -1730,7 +2309,44 @@ bool HandleParagonResetAbilities(Player* pl, std::string* err) return true; } -bool HandleParagonResetTalents(Player* pl, std::string* err) +// --------------------------------------------------------------------------- +// Beast Mastery (Hunter 51-pt talent, spell 53270): grants +4 pet talent +// points while learned. If the player loses the talent (Reset Talents, +// build swap to a non-BM recipe, ...) we must wipe the pet's current talent +// allocation -- otherwise the 4 extra slots they spent while specced into +// BM keep their effects after the talent is gone, which is straight cheese. +// +// Detection is "had it before, doesn't have it after". Beast Mastery is a +// single-rank talent (spell 53270 on talent id 2139 in our client bake); the +// rank spell must be looked up via the talent map's specMask — Player::HasTalent +// ignores its spec argument and only checks the active spec, which misses BM +// learned on the inactive dual-spec page. +// --------------------------------------------------------------------------- +constexpr uint32 kSpellBeastMastery = 53270; + +bool HasBeastMasteryInAnySpec(Player* pl) +{ + return PlayerTalentRankSpellKnownInAnySpec(pl, kSpellBeastMastery); +} + +void MaybeForcePetTalentResetForBeastMasteryLoss(Player* pl, bool hadBeastMasteryBefore) +{ + if (!pl || !hadBeastMasteryBefore) + return; + if (HasBeastMasteryInAnySpec(pl)) + return; // still learned somewhere -> no cheese to close + Pet* pet = pl->GetPet(); + if (!pet || pet->getPetType() != HUNTER_PET) + return; // only hunter pets have a talent tree in 3.3.5 + // Free, instant: refunds spent pet talents and re-pushes the talent UI. + // Same call the addon's "C RESET PET TALENTS" verb uses. + pl->ResetPetTalents(); + LOG_INFO("module", + "Paragon panel: {} lost Beast Mastery -> pet talents force-reset", + pl->GetName()); +} + +bool HandleParagonResetTalents(Player* pl, std::string* err, bool autoResetPetIfBmLost = true) { if (!pl || pl->getClass() != CLASS_PARAGON) { @@ -1744,6 +2360,13 @@ bool HandleParagonResetTalents(Player* pl, std::string* err) return false; } + // Capture pre-reset BM state so we can detect a "had it, lost it" + // transition once the engine's resetTalents pass below has wiped + // every spec. Skipped when the caller wants to handle the check + // themselves (HandleBuildLoad does it post-recipe-apply so it can + // also clear BM lost across a swap into a non-BM build). + bool const hadBeastMasteryPre = autoResetPetIfBmLost && HasBeastMasteryInAnySpec(pl); + LoadCurrencyFromDb(pl); uint32 const lowGuid = pl->GetGUID().GetCounter(); uint32 refundAE = 0; @@ -1799,6 +2422,14 @@ bool HandleParagonResetTalents(Player* pl, std::string* err) PushCurrency(pl); PushSnapshot(pl); PushBuildCatalog(pl); + + // Pet-talent cheese close: if BM was learned before this reset and + // resetTalents has now wiped it out of every spec, force a free pet + // talent respec. Skipped when the caller opted out (HandleBuildLoad + // defers this to its own post-recipe-apply check). + if (autoResetPetIfBmLost) + MaybeForcePetTalentResetForBeastMasteryLoss(pl, hadBeastMasteryPre); + LOG_INFO("module", "Paragon panel: {} reset talents (+{} AE +{} TE refund)", pl->GetName(), refundAE, refundTE); return true; } @@ -2968,6 +3599,14 @@ bool HandleBuildLoad(Player* pl, std::string const& payload, std::string* err) uint32 const activeId = GetActiveBuildId(lowGuid); bool const sameBuild = (activeId == targetId); + // Capture pre-swap Beast Mastery state. Phase 2's reset-then-reapply + // wipes every talent before the new recipe runs, so we can't use + // HandleParagonResetTalents' built-in BM check here -- it would fire + // mid-swap before the new build's BM (if any) is re-learned. Defer + // the pet-talent reset to the very end of the swap, when the final + // talent layout is committed (see Phase 5 below). + bool const hadBeastMasteryPreSwap = HasBeastMasteryInAnySpec(pl); + // ------------------------------------------------------------- // Phase 1: snapshot + park the current build's state. // @@ -2989,10 +3628,12 @@ bool HandleBuildLoad(Player* pl, std::string const& payload, std::string* err) // ------------------------------------------------------------- // Phase 2: reset all panel-bought spells/talents (refunds AE/TE - // through the existing reset path). + // through the existing reset path). autoResetPetIfBmLost=false + // because we're going to re-learn BM in Phase 3 if the new recipe + // includes it -- the BM-loss check is deferred to Phase 5. // ------------------------------------------------------------- std::string sub; - if (!HandleParagonResetTalents(pl, &sub)) + if (!HandleParagonResetTalents(pl, &sub, /*autoResetPetIfBmLost=*/false)) { *err = sub; return false; @@ -3126,6 +3767,29 @@ bool HandleBuildLoad(Player* pl, std::string const& payload, std::string* err) RestoreParkedPetForBuild(pl, targetId); PushBuildCatalog(pl); + + // ------------------------------------------------------------- + // Phase 5: cross-swap cleanup. + // + // - Pet-talent cheese close. We deferred this from Phase 2 so the + // check sees the FINAL talent layout. ResetPetTalents requires a + // summoned hunter pet, which is why this runs AFTER Phase 4's + // RestoreParkedPetForBuild (so the parked pet is back when we + // check) -- otherwise a player swapping into a non-BM build with + // a parked-while-summoned BM pet would skip the reset and arrive + // at the new build with stale +4 pet talents allocated. + // + // - Self-cast aura sweep. The build swap is meant to be a clean + // state transition; any buff the player cast on themselves + // before the swap drops here. Closes the Power-Word:-Fortitude + // style cheese vector for buffs whose source spell is in the + // OUTGOING recipe but not the INCOMING one (per-spell unlearns + // during Phase 2 already drop their auras, but this makes the + // overall semantic predictable for the whole swap). + // ------------------------------------------------------------- + MaybeForcePetTalentResetForBeastMasteryLoss(pl, hadBeastMasteryPreSwap); + SweepSelfCastSpellAuras(pl); + LOG_INFO("module", "Paragon build: {} {} build {}", pl->GetName(), sameBuild ? "reverted to snapshot of" : "loaded", targetId); return true; @@ -3184,18 +3848,90 @@ public: std::vector> emptyTalents; SendSilenceOpenForCommit(player, emptySpells, emptyTalents); - // Step 1: re-revoke spells we explicitly recorded last commit - // (cheap, exact: just walks the persisted revoke table). + uint32 const lowGuid = player->GetGUID().GetCounter(); + + // Step 1: explicit per-row revoke pass. Cheap — just walks the + // persisted revoke table — and the only step that catches + // (active dep, parent) rows the diff recorded at commit time. RevokeBlockedSpellsForPlayer(player); - // Step 2: scoped sweep across SkillLines we activated via panel - // purchases. Catches cascade-granted active spells that the - // commit-time diff missed -- e.g., legacy characters whose - // first commit predates the per-purchase revoke list, or any - // cascade re-fire on relog. Only walks our own SkillLines, so - // racials / weapon skills / Defense rewards are never touched. + + // Step 2: legacy passive-attach migration. MUST run before the + // scoped sweep so any cascade re-fire from `learnSpell` here + // (Blood Presence / Death Coil / Forceful Deflection / ...) + // is caught by Step 3 instead of leaking into the spellbook. + // + // 2a) A brief intermediate build of mod-paragon attached the + // on-target *debuff* IDs (55078 / 55095) as panel children + // of Plague Strike / Icy Touch. Those debuff rows render + // as castable spellbook icons because they aren't passive + // in Spell.dbc. Drop the panel_spell_children row and + // unlearn the debuff. No-op for unaffected characters. + struct LegacyBad { uint32 parent; uint32 child; }; + static LegacyBad const kLegacy[] = { + { 45462, 55078 }, + { 45477, 55095 }, + }; + for (auto const& lb : kLegacy) + { + if (CharacterDatabase.Query( + "SELECT 1 FROM character_paragon_panel_spell_children " + "WHERE guid = {} AND parent_spell_id = {} AND child_spell_id = {} LIMIT 1", + lowGuid, lb.parent, lb.child)) + { + CharacterDatabase.DirectExecute( + "DELETE FROM character_paragon_panel_spell_children " + "WHERE guid = {} AND parent_spell_id = {} AND child_spell_id = {}", + lowGuid, lb.parent, lb.child); + if (player->HasSpell(lb.child)) + player->removeSpell(lb.child, SPEC_MASK_ALL, false); + LOG_INFO("module", + "Paragon panel: stripped legacy debuff-as-passive {} (parent {}) for {}", + lb.child, lb.parent, player->GetName()); + } + } + // 2b) Re-attach the correct passive spellbook entry (59879 / + // 59921) for any panel-purchased Plague Strike / Icy Touch + // that's missing it. `learnSpell` here can re-fire the DK + // skill-line cascade and re-grant Blood Presence / Death + // Coil / Death Grip / Forceful Deflection — Step 3's + // scoped sweep is what cleans those up. + struct LegacyFix { uint32 parent; uint32 correctChild; }; + static LegacyFix const kFixup[] = { + { 45462, 59879 }, + { 45477, 59921 }, + }; + for (auto const& lf : kFixup) + { + if (!CharacterDatabase.Query( + "SELECT 1 FROM character_paragon_panel_spells " + "WHERE guid = {} AND spell_id = {} LIMIT 1", + lowGuid, lf.parent)) + continue; + if (player->HasSpell(lf.correctChild)) + { + DbInsertPanelSpellChild(lowGuid, lf.parent, lf.correctChild); + continue; + } + player->learnSpell(lf.correctChild, false); + DbInsertPanelSpellChild(lowGuid, lf.parent, lf.correctChild); + std::unordered_set chain; + CollectSpellChainIds(lf.correctChild, chain); + if (chain.empty()) + chain.insert(lf.correctChild); + DbDeletePanelSpellRevokedForChain(lowGuid, chain); + } + + // Step 3: scoped sweep across SkillLines we activated via panel + // purchases. Final pass — catches every cascade leak: those + // the commit-time diff missed, those re-fired by `_LoadSkills`, + // AND those re-fired by Step 2's legacy `learnSpell` calls. + // Only walks SkillLines we activated, so racials / weapon + // skills / Defense are never touched. RevokeUnwantedCascadeSpellsForPlayer(player); - // Step 3: Blood Elf only -- strip rogue/DK Arcane Torrent clones - // (skill-line overlay taught all three; see 2026_05_10_03.sql). + + // Step 4: Blood Elf only -- strip rogue/DK Arcane Torrent + // clones (skill-line overlay taught all three; see + // 2026_05_10_03.sql). RevokeDuplicateBloodElfArcaneTorrent(player); // Intentionally NOT calling SendSilenceClose here -- the chat @@ -3538,10 +4274,11 @@ public: { static ChatCommandTable paragonSubTable = { - { "currency", HandleCurrency, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, - { "learn", HandleLearn, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, - { "runes", HandleRunes, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, - { "hat", HandleHat, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, + { "currency", HandleCurrency, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, + { "learn", HandleLearn, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, + { "runes", HandleRunes, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, + { "hat", HandleHat, rbac::RBAC_PERM_COMMAND_LEARN, Console::No }, + { "recalibrate", HandlePanelRecalibrate, rbac::RBAC_PERM_COMMAND_MODIFY, Console::No }, }; static ChatCommandTable commandTable = @@ -3552,6 +4289,48 @@ public: return commandTable; } + // Full Character Advancement reset for the selected player (or self): + // unlearn all panel spells/talents, clear panel DB + active build pointer, + // then clamp AE/TE to the level-correct totals (same math as login + // reconciliation). Does not delete saved build catalog rows — only + // clears the active build link like RESET ALL from the addon. + static bool HandlePanelRecalibrate(ChatHandler* handler) + { + Player* target = handler->getSelectedPlayerOrSelf(); + if (!target || target->getClass() != CLASS_PARAGON) + { + handler->SendErrorMessage("Target must be a Paragon character.", false); + return false; + } + + if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) + { + handler->SendErrorMessage("Paragon.Currency.Enabled is off.", false); + return false; + } + + LoadCurrencyFromDb(target); + std::string err; + if (!HandleParagonResetAll(target, &err)) + { + handler->PSendSysMessage("Paragon recalibrate failed: {}", err); + return false; + } + + ReconcileEssenceForPlayer(target); + PushCurrency(target); + PushSnapshot(target); + PushBuildCatalog(target); + + handler->PSendSysMessage( + "Paragon recalibrate complete for {} (level {}): panel cleared, AE={}, TE={} (level-correct).", + target->GetName(), + uint32(target->GetLevel()), + GetAE(target), + GetTE(target)); + return true; + } + static bool HandleCurrency(ChatHandler* handler) { Player* pl = handler->GetPlayer(); diff --git a/src/server/game/Spells/SpellInfoCorrections.cpp b/src/server/game/Spells/SpellInfoCorrections.cpp index 39905a4..50cf941 100644 --- a/src/server/game/Spells/SpellInfoCorrections.cpp +++ b/src/server/game/Spells/SpellInfoCorrections.cpp @@ -5368,6 +5368,18 @@ void SpellMgr::LoadSpellInfoCorrections() LockEntry* key = const_cast(sLockStore.LookupEntry(36)); // 3366 Opening, allows to open without proper key key->Type[2] = LOCK_KEY_NONE; + // Fractured / Paragon: DK weapon-line "passives" Forceful Deflection and + // Runic Focus ship in 3.3.5a Spell.dbc without SPELL_ATTR0_PASSIVE set. + // SpellInfo::IsPassive() is therefore false, and mod-paragon's panel-learn + // diff treats them as castable actives and revokes them — while true + // actives (Blood Presence, Death Coil, Death Grip, ...) must stay + // stripped. Mark these two passive in-memory so the panel policy matches + // the spellbook UX for every class (stock DK benefits too). + ApplySpellFix({ 49410, 61455 }, [](SpellInfo* spellInfo) + { + spellInfo->Attributes |= SPELL_ATTR0_PASSIVE; + }); + // Fractured: strip reagent requirements from every player-class spell at // load time. Filtered by SpellFamilyName != 0 so that profession spells // (cooking, alchemy, enchanting, blacksmithing, jewelcrafting, leatherworking,