/* * mod-paragon — Ability Essence (AE) / Talent Essence (TE) * * Inspired by Project Ascension's classless currencies (see Fandom: * Ability Essence / Talent Essence). Server-side only: values persist in * character_paragon_currency; optional per-spell AE costs live in world * table paragon_spell_ae_cost. Client UI for bars / advancement panels is * separate (see README). */ #include "Bag.h" #include "CharacterDatabase.h" #include "Chat.h" #include "CommandScript.h" #include "Language.h" #include "Config.h" #include "Pet.h" #include "Player.h" #include "Random.h" #include "RBAC.h" #include "ScriptMgr.h" #include "SharedDefines.h" #include "SpellAuras.h" #include "SpellInfo.h" #include "SpellMgr.h" #include "WorldDatabase.h" #include "WorldPacket.h" #include "ObjectMgr.h" #include "Log.h" #include "DBCEnums.h" #include "DBCStores.h" #include #include #include #include #include #include #include #include #include #include #include using namespace Acore::ChatCommands; namespace { // Wire-format prefix shared with the ParagonAdvancement addon (Net.lua). char const* const kAddonPrefix = "PARAA"; struct ParagonCurrencyData { uint32 abilityEssence = 0; uint32 talentEssence = 0; }; std::unordered_map gParagonCurrencyCache; uint32 ComputeStartingAE(uint8 level) { uint32 base = sConfigMgr->GetOption("Paragon.Currency.AE.Start", 9); uint32 per = sConfigMgr->GetOption("Paragon.Currency.AE.PerLevel", 1); uint32 minLv = sConfigMgr->GetOption("Paragon.Currency.GrantLevelMin", 10); if (level >= minLv) base += uint32(level - minLv + 1) * per; return base; } uint32 ComputeStartingTE(uint8 level) { uint32 base = sConfigMgr->GetOption("Paragon.Currency.TE.Start", 0); uint32 per = sConfigMgr->GetOption("Paragon.Currency.TE.PerLevel", 1); uint32 minLv = sConfigMgr->GetOption("Paragon.Currency.GrantLevelMin", 10); if (level >= minLv) base += uint32(level - minLv + 1) * per; return base; } ParagonCurrencyData& GetOrCreateCacheEntry(uint32 lowGuid) { return gParagonCurrencyCache[lowGuid]; } void DbUpsertCurrency(uint32 lowGuid, uint32 ae, uint32 te) { CharacterDatabase.DirectExecute( "REPLACE INTO character_paragon_currency (guid, ability_essence, talent_essence) VALUES ({}, {}, {})", lowGuid, ae, te); } void LoadCurrencyFromDb(Player* player) { if (!player || player->getClass() != CLASS_PARAGON) return; if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return; uint32 const lowGuid = player->GetGUID().GetCounter(); if (QueryResult r = CharacterDatabase.Query( "SELECT ability_essence, talent_essence FROM character_paragon_currency WHERE guid = {}", lowGuid)) { Field const* f = r->Fetch(); ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); d.abilityEssence = f[0].Get(); d.talentEssence = f[1].Get(); return; } uint32 const ae = ComputeStartingAE(player->GetLevel()); uint32 const te = ComputeStartingTE(player->GetLevel()); DbUpsertCurrency(lowGuid, ae, te); ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); d.abilityEssence = ae; d.talentEssence = te; } void SaveCurrencyToDb(Player* player) { if (!player || player->getClass() != CLASS_PARAGON) return; if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return; uint32 const lowGuid = player->GetGUID().GetCounter(); auto itr = gParagonCurrencyCache.find(lowGuid); if (itr == gParagonCurrencyCache.end()) return; DbUpsertCurrency(lowGuid, itr->second.abilityEssence, itr->second.talentEssence); } void RemoveCacheEntry(uint32 lowGuid) { gParagonCurrencyCache.erase(lowGuid); } uint32 GetAE(Player* player) { if (!player) return 0; auto itr = gParagonCurrencyCache.find(player->GetGUID().GetCounter()); if (itr == gParagonCurrencyCache.end()) return 0; return itr->second.abilityEssence; } uint32 GetTE(Player* player) { if (!player) return 0; auto itr = gParagonCurrencyCache.find(player->GetGUID().GetCounter()); if (itr == gParagonCurrencyCache.end()) return 0; return itr->second.talentEssence; } // Pushes an addon-channel message to a single player. The 3.3.5 client // recognises CHAT_MSG_WHISPER+LANG_ADDON as an addon broadcast and fires // CHAT_MSG_ADDON locally, splitting payload on the first tab into // (prefix, body). The ParagonAdvancement addon listens for prefix "PARAA". void SendAddonMessage(Player* player, std::string const& body) { if (!player || !player->GetSession()) return; std::string payload = std::string(kAddonPrefix) + "\t" + body; WorldPacket data; ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER, LANG_ADDON, player, player, payload); player->SendDirectMessage(&data); } // Helper: send "R CURRENCY " so the client can update its // authoritative balance. Safe to call any time the cached values change. void PushCurrency(Player* player) { if (!player || player->getClass() != CLASS_PARAGON) return; if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return; // Defensive: if for any reason the in-memory cache has lost this player // (race during login, hot-reload, etc.) refuse to silently broadcast // (0, 0). Hydrate from DB first so the panel always sees real balances. if (gParagonCurrencyCache.find(player->GetGUID().GetCounter()) == gParagonCurrencyCache.end()) LoadCurrencyFromDb(player); SendAddonMessage(player, fmt::format("R CURRENCY {} {}", GetAE(player), GetTE(player))); } bool TrySpendAE(Player* player, uint32 amount) { if (!amount) return true; auto itr = gParagonCurrencyCache.find(player->GetGUID().GetCounter()); if (itr == gParagonCurrencyCache.end()) return false; if (itr->second.abilityEssence < amount) return false; itr->second.abilityEssence -= amount; return true; } bool TrySpendTE(Player* player, uint32 amount) { if (!amount) return true; auto itr = gParagonCurrencyCache.find(player->GetGUID().GetCounter()); if (itr == gParagonCurrencyCache.end()) return false; if (itr->second.talentEssence < amount) return false; itr->second.talentEssence -= amount; return true; } void GrantLevelUpEssence(Player* player, uint8 oldLevel, uint8 newLevel) { if (!player || player->getClass() != CLASS_PARAGON) return; if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return; uint32 const minLv = sConfigMgr->GetOption("Paragon.Currency.GrantLevelMin", 10); uint32 const aePer = sConfigMgr->GetOption("Paragon.Currency.AE.PerLevel", 1); uint32 const tePer = sConfigMgr->GetOption("Paragon.Currency.TE.PerLevel", 1); ParagonCurrencyData& d = GetOrCreateCacheEntry(player->GetGUID().GetCounter()); for (uint32 lvl = uint32(oldLevel) + 1; lvl <= uint32(newLevel); ++lvl) { if (lvl >= minLv) { d.abilityEssence += aePer; d.talentEssence += tePer; } } } uint32 LookupSpellAECost(uint32 spellId) { uint32 const fallback = sConfigMgr->GetOption("Paragon.Currency.AE.DefaultSpellCost", 2); QueryResult r = WorldDatabase.Query("SELECT ae_cost FROM paragon_spell_ae_cost WHERE spell_id = {}", spellId); if (!r) return fallback; return std::max(r->Fetch()[0].Get(), 1u); } // Matches client bake `lvl` (Spell.dbc SpellLevel; see _gen_paragon_advancement_spells_lua.py). uint32 GetParagonPanelSpellRequiredLevel(SpellInfo const* info) { if (!info) return 1u; uint32 lv = info->SpellLevel; if (!lv) lv = info->BaseLevel; return std::max(1u, lv); } // Reconciles the player's AE/TE cache against what they SHOULD have // based on level (ComputeStartingAE/TE) minus what they've spent through // Character Advancement (sum over character_paragon_panel_spells + // character_paragon_panel_talents). Updates the cache + DB if either // direction drifts: // * actual < expected: top up (handles per-level grants automatically; // also self-heals from admin commands / crashes that lost essence). // * actual > expected: clamp down (prevents .modify-style cheese, ghost // panel rows that were rolled back, or any path that left more // essence than the level allowed). // Logs at INFO when drift is corrected so we can spot abuse patterns. // // Cheap (two SELECTs of small per-character tables) and safe to call from // OnPlayerLogin and OnPlayerLevelChanged. SAFE TO CALL ANY TIME the panel // DB is in a steady state (i.e. NOT mid-HandleCommit). void ReconcileEssenceForPlayer(Player* pl) { if (!pl || pl->getClass() != CLASS_PARAGON) return; if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); uint8 const level = pl->GetLevel(); // Sum AE / TE spent through panel purchases. Mirrors the cost lookups // used by HandleCommit so reconciliation matches the spend math byte- // for-byte (no off-by-one if config keys are tweaked at runtime). uint32 spentAE = 0; uint32 spentTE = 0; if (QueryResult r = CharacterDatabase.Query( "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid)) { do { spentAE += LookupSpellAECost(r->Fetch()[0].Get()); } while (r->NextRow()); } uint32 const tePerRank = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); uint32 const aePerRank = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); if (QueryResult r = CharacterDatabase.Query( "SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", lowGuid)) { do { Field const* f = r->Fetch(); uint32 const tid = f[0].Get(); uint32 const rank = f[1].Get(); TalentEntry const* te = sTalentStore.LookupEntry(tid); if (!te || !rank) continue; spentTE += rank * tePerRank; if (te->addToSpellBook) spentAE += rank * aePerRank; } while (r->NextRow()); } uint32 const expectedTotalAE = ComputeStartingAE(level); uint32 const expectedTotalTE = ComputeStartingTE(level); uint32 const expectedBalAE = expectedTotalAE > spentAE ? expectedTotalAE - spentAE : 0; uint32 const expectedBalTE = expectedTotalTE > spentTE ? expectedTotalTE - spentTE : 0; ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); if (d.abilityEssence == expectedBalAE && d.talentEssence == expectedBalTE) return; LOG_INFO("module", "Paragon essence reconciled for {} (lvl {}): AE {}->{} TE {}->{} (spent AE={} TE={}, expected total AE={} TE={})", pl->GetName(), uint32(level), d.abilityEssence, expectedBalAE, d.talentEssence, expectedBalTE, spentAE, spentTE, expectedTotalAE, expectedTotalTE); d.abilityEssence = expectedBalAE; d.talentEssence = expectedBalTE; SaveCurrencyToDb(pl); } // Forward declaration: reset handlers below need PushSnapshot, which itself // is defined later (after PushSpellSnapshot / PushTalentSnapshot). void PushSnapshot(Player* pl); // Forward declarations: reset handlers below clear the active build pointer // and re-push the build catalog so the cell border drops client-side. Both // helpers live with the rest of the build catalog code further down. void SetActiveBuildId(uint32 lowGuid, uint32 buildId); void PushBuildCatalog(Player* pl); bool BuildBelongsToPlayer(uint32 lowGuid, uint32 buildId); void SnapshotBuildFromCurrent(Player* pl, uint32 buildId); // Forward declaration: the login-time scoped sweep (defined a few helpers // down) calls into the chain-walker (defined further down). void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set& out); // ---- Panel-learn tracking (Overview + resets) ------------------------------ // // Spells / talent ranks bought through Character Advancement are stored in // character_paragon_panel_spells and character_paragon_panel_talents. The // addon snapshot lists ONLY these rows (intersected with the live player), // so racial spells, trainer defaults, etc. never appear in Overview. void DbInsertPanelSpell(uint32 lowGuid, uint32 spellId) { CharacterDatabase.DirectExecute( "INSERT IGNORE INTO character_paragon_panel_spells (guid, spell_id) VALUES ({}, {})", lowGuid, spellId); } void DbInsertPanelSpellChild(uint32 lowGuid, uint32 parentSpellId, uint32 childSpellId) { CharacterDatabase.DirectExecute( "INSERT IGNORE INTO character_paragon_panel_spell_children " "(guid, parent_spell_id, child_spell_id) VALUES ({}, {}, {})", lowGuid, parentSpellId, childSpellId); } // Persist an "active dependent we revoked" so we can re-revoke it after // AC's skill cascade re-grants it on the next login. See // `RevokeBlockedSpellsForPlayer` for the redo step. void DbInsertPanelSpellRevoked(uint32 lowGuid, uint32 parentSpellId, uint32 revokedSpellId) { CharacterDatabase.DirectExecute( "INSERT IGNORE INTO character_paragon_panel_spell_revoked " "(guid, parent_spell_id, revoked_spell_id) VALUES ({}, {}, {})", lowGuid, parentSpellId, revokedSpellId); } // Drop any panel_spell_revoked rows whose `revoked_spell_id` falls inside // `chainIds`. Called from `PanelLearnSpellChain` right after the panel // purchase row is committed so a freshly bought spell can't be unlearned // on the next login by a stale (pre-purchase) revoke entry. Without this, // the very first login after the purchase would walk the revoke table, // hit the ghost row, `removeSpell` the freshly-paid-for ability, and // then `PushSpellSnapshot` (which deletes panel_spells rows whose spell // the player no longer has) would erase the purchase from the panel // record entirely -- losing both the spell and the AE refund hook. void DbDeletePanelSpellRevokedForChain(uint32 lowGuid, std::unordered_set const& chainIds) { if (chainIds.empty()) return; std::string in; in.reserve(chainIds.size() * 8); bool first = true; for (uint32 sid : chainIds) { 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); } // Build the allowlist of spell IDs the player legitimately owns through // Character Advancement: every chain rank of every spell in panel_spells, // every rank-spell-id up to the purchased rank of every talent in // panel_talents, and every recorded panel_spell_children id. Both // `RevokeUnwantedCascadeSpellsForPlayer` and `RevokeBlockedSpellsForPlayer` // need exactly this set; without the talent contribution, buying a spell // after a talent (e.g., DK Death Coil after Scourge Strike) caused the // post-commit sweep to revoke the talent-granted spell because it didn't // appear in panel_spells. See ParagonAdvancement_TalentData.lua: many // "abilities" the player perceives as spells (Scourge Strike id=2216, // Bladestorm, Starfall, ...) are panel TALENTS that grant a spell rank // via Player::LearnTalent. void BuildPanelOwnedSpellsAllowlist(uint32 lowGuid, std::unordered_set& allowed) { if (!lowGuid) return; if (QueryResult r = CharacterDatabase.Query( "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid)) { do { CollectSpellChainIds(r->Fetch()[0].Get(), allowed); } while (r->NextRow()); } if (QueryResult r = CharacterDatabase.Query( "SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", lowGuid)) { do { Field const* f = r->Fetch(); uint32 const tid = f[0].Get(); uint32 const rank = f[1].Get(); TalentEntry const* te = sTalentStore.LookupEntry(tid); if (!te || !rank) continue; // panel_talents.rank is 1-based ("1 means rank-1 owned"). Allow // every rank id from 0..rank-1 so a partial-rank purchase still // protects all lower ranks the player rolled through. uint32 const cap = std::min(rank, MAX_TALENT_RANK); for (uint32 i = 0; i < cap; ++i) { uint32 const rankSpell = te->RankID[i]; if (!rankSpell) continue; allowed.insert(rankSpell); // Some talents (Mangle, Feral Charge, Mutilate, ...) own a // *passive* RankID spell whose effects then LEARN_SPELL the // actual active abilities the player gets to use. RankID // alone isn't enough -- the granted spells live on the // class skill line, so the login cascade sweep would see // them as "not allowlisted" and revoke them, and from the // user's POV the ability vanishes on relog. // // Walk every effect of this rank's spell, expand each // SPELL_EFFECT_LEARN_SPELL trigger through CollectSpellChainIds // so the whole rank chain of the granted spell stays // protected too (e.g. Mangle Bear rank 1 33878 -> // 33986 -> ... -> 48566; we want all of them on the // allowlist so a high-level Paragon druid keeps the rank // appropriate to their level). SpellInfo const* rankInfo = sSpellMgr->GetSpellInfo(rankSpell); if (!rankInfo) continue; 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; CollectSpellChainIds(grantId, allowed); } } } while (r->NextRow()); } if (QueryResult r = CharacterDatabase.Query( "SELECT child_spell_id FROM character_paragon_panel_spell_children WHERE guid = {}", lowGuid)) { do { allowed.insert(r->Fetch()[0].Get()); } while (r->NextRow()); } } // Walk every (guid, *, revoked_spell_id) row and `removeSpell` it if the // player still has it. Call sites: // * `OnPlayerLogin` -- because `_LoadSkills` -> `learnSkillRewardedSpells` // ran during LoadFromDB and may have re-granted Blood Presence / // Death Coil / Death Grip / etc. before any of our hooks fired. // * `HandleParagonResetAbilities` is NOT a caller; reset clears the // table outright so the revoke list starts fresh on next purchase. // // Allowlist-aware: a revoked row whose `revoked_spell_id` is now part of // a panel_spells chain (or recorded as a passive child) is *stale* -- it // was inserted before the player legitimately purchased that spell, so // re-running `removeSpell` on it would zap a paid-for ability. Such rows // are skipped and dropped from the table so they can't fire again. This // is the self-heal path for the pre-fix bug where buying a spell that // had previously been caught by the login sweep left the (0, sid) ghost // row in place; on every subsequent login that ghost would unlearn the // freshly bought spell, and `PushSpellSnapshot`'s !HasSpell branch would // then delete the panel_spells row, vanishing the purchase entirely. void RevokeBlockedSpellsForPlayer(Player* pl) { if (!pl) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); // Allowlist: chain ranks of panel_spells + rank IDs of panel_talents // + panel_spell_children. Talents matter here because many Wrath // "abilities" (Scourge Strike, Bladestorm, ...) are talent-granted. std::unordered_set allowed; BuildPanelOwnedSpellsAllowlist(lowGuid, allowed); QueryResult r = CharacterDatabase.Query( "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 { 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; } // 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); continue; } if (pl->HasSpell(sid)) { pl->removeSpell(sid, SPEC_MASK_ALL, false); ++removed; } } 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. std::sort(stale.begin(), stale.end()); stale.erase(std::unique(stale.begin(), stale.end()), stale.end()); std::string in; in.reserve(stale.size() * 8); bool first = true; for (uint32 sid : stale) { 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); LOG_INFO("module", "Paragon panel: dropped {} stale revoke rows for {} " "(spell now owned via panel purchase)", stale.size(), pl->GetName()); } if (removed) 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) { return sla && (sla->AcquireMethod == SKILL_LINE_ABILITY_LEARNED_ON_SKILL_LEARN || sla->AcquireMethod == SKILL_LINE_ABILITY_LEARNED_ON_SKILL_VALUE); } // SkillLine ids from every SkillLineAbility row that lists `spellId` // (any AcquireMethod). Used as the *anchor* side of cascade detection. // // Important: the old helper only kept rows with AcquireMethod // LEARNED_ON_SKILL_* . Spells like Blood Strike are usually tied to // their skill line via a trainer/default row (AcquireMethod 0). That // meant the anchor set was empty, so `IsSpellSkillLineCascadeDependent` // never matched Forceful Deflection / Blood Presence — those were stored // as innocent "children" and never revoked. // // Dependent detection still requires the *dependent* spell to have a // LEARNED_ON_SKILL_* row on one of these lines (same as // `learnSkillRewardedSpells`). [[nodiscard]] static std::unordered_set SkillLinesLinkedToSpell(uint32 spellId) { std::unordered_set out; SkillLineAbilityMapBounds bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId); for (auto it = bounds.first; it != bounds.second; ++it) { SkillLineAbilityEntry const* sla = it->second; if (!sla) continue; out.insert(sla->SkillLine); } 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; SkillLineAbilityMapBounds db = sSpellMgr->GetSkillLineAbilityMapBounds(depSpellId); for (auto it = db.first; it != db.second; ++it) { SkillLineAbilityEntry const* sla = it->second; if (!SkillLineAbilityIsSkillCascadeSigned(sla)) continue; if (anchorLines.count(sla->SkillLine)) return true; } 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, 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) return; QueryResult r = CharacterDatabase.Query( "SELECT parent_spell_id, child_spell_id FROM character_paragon_panel_spell_children WHERE guid = {}", lowGuid); if (!r) return; do { uint32 const parent = r->Fetch()[0].Get(); uint32 const child = r->Fetch()[1].Get(); 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. CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_panel_spell_children WHERE guid = {} AND parent_spell_id = {} AND child_spell_id = {}", lowGuid, parent, child); if (pl->HasSpell(child)) { pl->removeSpell(child, SPEC_MASK_ALL, false); DbInsertPanelSpellRevoked(lowGuid, parent, child); } } while (r->NextRow()); } // Login-time scoped sweep for cascade-granted dependents that the // commit-time diff missed. // // Background: AC's `_addSpell` calls `LearnDefaultSkill(skill, 0)` once // for any SkillLineAbility-linked spell when the player doesn't yet have // the skill. `LearnDefaultSkill` -> `SetSkill` -> `learnSkillRewardedSpells` // then grants every reward of that skill as PLAYERSPELL_TEMPORARY (which // lives in m_spells but is NOT written to character_spell). On every // subsequent login `_LoadSkills` re-fires the cascade for any skill row // that exists in `character_skills`, silently re-granting Blood Presence // / Forceful Deflection / Death Coil / etc. -- including for characters // like Test whose first commit predates the per-purchase revoke list. // // Strategy: from the player's panel-purchased spells, derive the set of // SkillLines we've activated (via SkillLineAbility's SkillLine field). // Walk those SkillLines' rewards and revoke any spell (active or passive) // currently in m_spells that isn't in our allowlist (panel chain ranks + // non-cascade passive children). Persist each revoke into // `character_paragon_panel_spell_revoked` so the cheaper // `RevokeBlockedSpellsForPlayer` path handles it on every subsequent // login. // // Why this is safe (unlike the earlier blanket-temporary sweep): // * Only walks SkillLines that we caused to be activated. Racial // skills, weapon skills, Defense, etc. live in different SkillLines // and are never reached. // * Passives that are pure spell-effect side effects stay in // `character_paragon_panel_spell_children` and remain allowlisted. // * Skill-line cascade passives (Forceful Deflection, ...) are not // allowlisted children anymore; see `PruneSkillLineCascadeChildrenFromDb` // + `PanelLearnSpellChain`. void RevokeUnwantedCascadeSpellsForPlayer(Player* pl) { if (!pl) return; bool const diag = sConfigMgr->GetOption("Paragon.Diag.PanelLearn", false); uint32 const lowGuid = pl->GetGUID().GetCounter(); PruneSkillLineCascadeChildrenFromDb(pl, lowGuid); // Allowlist: chain ranks of panel_spells + rank IDs of panel_talents // + panel_spell_children. Talents matter here because many Wrath // "abilities" (Scourge Strike, Bladestorm, ...) are talent-granted: // a Death Coil purchase otherwise activates the DK skill line and // sweeps Scourge Strike (55090) out from under the talent. std::unordered_set allowed; BuildPanelOwnedSpellsAllowlist(lowGuid, allowed); if (allowed.empty()) return; // From the allowlist, derive which SkillLines we've activated. Use // every SkillLineAbility row for each spell (not only LEARNED_ON_SKILL_*), // matching `SkillLinesLinkedToSpell` / `_addSpell`'s LearnDefaultSkill path. std::unordered_set ourSkillLines; for (uint32 spellId : allowed) { SkillLineAbilityMapBounds bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId); for (auto it = bounds.first; it != bounds.second; ++it) { SkillLineAbilityEntry const* sla = it->second; if (!sla) continue; ourSkillLines.insert(sla->SkillLine); } } if (ourSkillLines.empty()) return; // 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) { for (SkillLineAbilityEntry const* ab : GetSkillLineAbilitiesBySkillLine(skillLine)) { if (!ab) continue; uint32 const sid = ab->Spell; if (!sid) continue; if (allowed.count(sid)) continue; if (!pl->HasSpell(sid)) continue; // not in m_spells right now SpellInfo const* info = sSpellMgr->GetSpellInfo(sid); if (!info) continue; if (info->IsPassive()) continue; // policy: passives always stay toRevoke.push_back(sid); } } if (toRevoke.empty()) { if (diag) LOG_INFO("module", "[paragon-diag] login sweep: no orphan cascade spells for {} " "(skillLines scanned={}, allowlist={})", pl->GetName(), ourSkillLines.size(), allowed.size()); return; } // Dedup (a spell can show up under multiple SkillLines). std::sort(toRevoke.begin(), toRevoke.end()); toRevoke.erase(std::unique(toRevoke.begin(), toRevoke.end()), toRevoke.end()); for (uint32 sid : toRevoke) { pl->removeSpell(sid, SPEC_MASK_ALL, false); // parent_spell_id = 0 -> "caught at login by scoped sweep, no // specific parent". Distinct PK from the (guid, parent, sid) // rows the commit-time diff inserts. DbInsertPanelSpellRevoked(lowGuid, 0u, sid); } LOG_INFO("module", "Paragon panel: scoped sweep revoked {} cascade spells for {} " "(skillLines scanned={}, allowlist={})", toRevoke.size(), pl->GetName(), ourSkillLines.size(), allowed.size()); } // Blood Elf racial "Arcane Torrent" is three different spell IDs in WotLK // (28730 mana, 25046 rogue energy, 50613 DK runic power), all on skill line // 756. The blanket SkillLineAbility overlay opened all three to class 12; // Paragon should keep only the mana version (matches primary power display). void RevokeDuplicateBloodElfArcaneTorrent(Player* pl) { if (!pl || pl->getRace() != RACE_BLOODELF) return; constexpr uint32 SPELL_ARCANE_TORRENT_ROGUE = 25046; constexpr uint32 SPELL_ARCANE_TORRENT_DK = 50613; for (uint32 sid : { SPELL_ARCANE_TORRENT_ROGUE, SPELL_ARCANE_TORRENT_DK }) if (pl->HasSpell(sid)) pl->removeSpell(sid, SPEC_MASK_ALL, false); } // Snapshot of currently-known spell IDs (excluding entries marked removed). // Used by PanelLearnSpellChain to detect spells that AzerothCore's // addSpell machinery auto-learns alongside the spell we asked for. std::unordered_set SnapshotKnownSpells(Player* pl) { std::unordered_set out; if (!pl) return out; PlayerSpellMap const& spells = pl->GetSpellMap(); out.reserve(spells.size()); for (auto const& kv : spells) { PlayerSpell const* ps = kv.second; if (ps && ps->State != PLAYERSPELL_REMOVED) out.insert(kv.first); } return out; } // (suppression-via-placeholder helpers were removed: AzerothCore's auto- // learn for class spells comes via `learnSkillRewardedSpells` -> `_addSpell`, // not via `SPELL_EFFECT_LEARN_SPELL` effects on the parent. Pre-blocking // by spell id list didn't intercept the right path. The chat-toast // suppression now lives client-side in ParagonAdvancement_Net.lua; // the server just tells the client which ids to silence.) // Build the full chain id set for `baseSpellId` (every rank). Used both // by PanelLearnSpellChain and by the silence-window opener so the client // knows which "you have learned X" toasts to keep visible. void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set& out) { if (!baseSpellId) return; uint32 const firstId = sSpellMgr->GetFirstSpellInChain(baseSpellId); uint32 cur = firstId ? firstId : baseSpellId; while (cur) { if (!out.insert(cur).second) break; uint32 const n = sSpellMgr->GetNextSpellInChain(cur); if (!n || n == cur) break; cur = n; } } // 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). // 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. // // Recording only the chain head (one row per panel purchase) keeps reset // accounting clean: refundAE is computed once per chain via // LookupSpellAECost(firstRankId), and Player::removeSpell already cascades // across higher ranks. If we recorded every learned rank we'd refund N x // the cost for an N-rank chain and need to dedupe in reset. // // Side effect: AC's spell-learn machinery cascades through both the // `SPELL_EFFECT_LEARN_SPELL` effects and the SkillLineAbility map (when // the player gains a new skill via `LearnDefaultSkill` -> `SetSkill` -> // `learnSkillRewardedSpells`), so learning a single class spell can // auto-grant several side spells (e.g., Plague Strike pulls Death Coil, // Death Grip, Blood Plague, Blood Presence, Forceful Deflection, Runic // Focus). We diff the player's spell list before/after `learnSpell` to // 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. 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. // * Active dependents (Death Coil, Death Grip, Blood Presence, ...) are // revoked immediately via `removeSpell` so the player only ends up // with what they actually purchased. // The "you have learned X" / "you have unlearned X" chat toasts that // fire during this dance are silenced client-side via a SILENCE // addon-channel window opened around the whole commit (see // HandleCommit + ParagonAdvancement_Net.lua). void PanelLearnSpellChain(Player* pl, uint32 baseSpellId) { if (!pl || !baseSpellId) return; uint32 const playerLevel = pl->GetLevel(); uint32 const firstId = sSpellMgr->GetFirstSpellInChain(baseSpellId); uint32 const trackId = firstId ? firstId : baseSpellId; uint32 const lowGuid = pl->GetGUID().GetCounter(); std::unordered_set chainIds; CollectSpellChainIds(trackId, chainIds); bool const diag = sConfigMgr->GetOption("Paragon.Diag.PanelLearn", false); if (diag) { LOG_INFO("module", "[paragon-diag] PanelLearnSpellChain start: player={} lvl={} class={} baseSpellId={} trackId={} chainSize={}", pl->GetName(), playerLevel, uint32(pl->getClass()), baseSpellId, trackId, chainIds.size()); } uint32 cur = trackId; while (cur) { SpellInfo const* info = sSpellMgr->GetSpellInfo(cur); if (!info) break; // Spell.dbc ranks are ordered by required level ascending, so the // first rank that exceeds the player's level terminates the walk. uint32 const reqLv = GetParagonPanelSpellRequiredLevel(info); if (playerLevel < reqLv) break; if (!pl->HasSpell(cur) && IsParagonSpellAllowedByRidingSkill(pl, cur)) { std::unordered_set before = SnapshotKnownSpells(pl); if (diag) LOG_INFO("module", "[paragon-diag] pre-learn rank={} spellMapSize={}", cur, before.size()); pl->learnSpell(cur, false); std::unordered_set after = SnapshotKnownSpells(pl); if (diag) LOG_INFO("module", "[paragon-diag] post-learn rank={} spellMapSize={} delta={}", cur, after.size(), int32(after.size()) - int32(before.size())); // Diff: classify each new spell that wasn't in the chain we // 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)) continue; // already known if (chainIds.count(spellId)) { if (diag) LOG_INFO("module", "[paragon-diag] +{} (chain rank, kept)", spellId); continue; } SpellInfo const* dep = sSpellMgr->GetSpellInfo(spellId); if (!dep) continue; // 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] +{} ({} dep, kept as child of {})", spellId, isMetaSkillCascade ? "meta-skill" : "passive", trackId); } else { pl->removeSpell(spellId, SPEC_MASK_ALL, false); DbInsertPanelSpellRevoked(lowGuid, trackId, spellId); if (diag) LOG_INFO("module", "[paragon-diag] +{} (active dep, REVOKED, parent={})", spellId, trackId); } } } uint32 const next = sSpellMgr->GetNextSpellInChain(cur); if (!next || next == cur) break; cur = next; } 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). // After the Paragon class-skill cascade guard landed in // Player::learnSkillRewardedSpells, NONE of the DK skill-line // cascade rewards are auto-granted any more — so passives that // used to ride along on a class skill cascade (Forceful // Deflection on Blood Strike, Runic Focus on Icy Touch) must be // explicitly attached here, the same way Blood Plague / Frost // Fever are. Add new entries when a panel-purchased active is // expected to come with a passive spellbook entry that no // SPELL_EFFECT_LEARN_SPELL on the parent provides. 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) { 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) // 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 // *different* purchase the user has since reset/refunded) may have // left rows that would otherwise re-fire `removeSpell` next login. DbDeletePanelSpellRevokedForChain(lowGuid, chainIds); if (diag) LOG_INFO("module", "[paragon-diag] PanelLearnSpellChain end: trackId={}", trackId); } void DbUpsertPanelTalent(uint32 lowGuid, uint32 talentId, uint32 rank) { if (!rank) { CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_panel_talents WHERE guid = {} AND talent_id = {}", lowGuid, talentId); return; } CharacterDatabase.DirectExecute( "REPLACE INTO character_paragon_panel_talents (guid, talent_id, `rank`) VALUES ({}, {}, {})", 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) return 0; TalentEntry const* te = sTalentStore.LookupEntry(talentId); if (!te) return 0; uint32 best = 0; 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; } // ---- Commit handler -------------------------------------------------------- // // Wire format from Net.lua Net:Commit: // "C COMMIT s:,,... t::,... u:,..." // The " u:" spell-unlearn section is optional (omitted by older clients). // Both s: and t: leading tags are required. Examples: // "C COMMIT s:5176,8921 t:" // "C COMMIT s: t:1234:1,5678:2" // "C COMMIT s: t: u:45477" // We parse leniently and abort on any structural error. // // On success we push: // "R OK " followed by R SPELLS / R TALENTS snapshots. // On failure (insufficient AE/TE, unknown spell, etc.): // "R ERR " -- the panel restores its pending state. constexpr size_t kCommitMaxItems = 64; std::vector ParseCsvUInt(std::string_view csv) { std::vector out; out.reserve(8); size_t i = 0; while (i < csv.size()) { size_t end = csv.find(',', i); if (end == std::string_view::npos) end = csv.size(); if (end > i) { std::string tok(csv.substr(i, end - i)); try { out.push_back(uint32(std::stoul(tok))); } catch (...) { out.push_back(0); } } i = end + 1; } return out; } // Returns true on success; on failure, *err is filled and no state is mutated. // Send a SILENCE OPEN window to the client listing every spell id whose // learn/unlearn chat toast SHOULD remain visible. The client then suppresses // every "you have learned X" / "you have unlearned X" system message during // the window whose subject isn't on the allow-list. Window auto-closes // after a short timeout client-side; we also send an explicit CLOSE at the // end of the commit to release earlier. // // Allow-list contents: // * full chain ids for every spell the player explicitly purchased (so // the player still sees "Plague Strike (Rank 1..N) learned" toasts); // * every talent rank id the player explicitly purchased (so addToSpellBook // talents like Bladestorm/Starfall still toast normally); // * chain ids + tracked passive children for spells intentionally unlearned // in this commit (so "You have unlearned …" for those stays visible). // Anything else learned/unlearned during the window -- the SkillLineAbility // cascade and our diff-revoke cleanup -- is silenced. void SendSilenceOpenForCommit(Player* pl, std::vector> const& spellsAndCosts, std::vector> const& talentDeltas, std::vector const& unlearnTrackIds = {}, std::vector const& talentUnlearnIds = {}) { if (!pl) return; std::unordered_set allow; for (auto const& kv : spellsAndCosts) CollectSpellChainIds(kv.first, allow); uint32 const lowGuid = pl->GetGUID().GetCounter(); for (uint32 trackId : unlearnTrackIds) { if (!trackId) continue; CollectSpellChainIds(trackId, allow); if (QueryResult cr = CharacterDatabase.Query( "SELECT child_spell_id FROM character_paragon_panel_spell_children " "WHERE guid = {} AND parent_spell_id = {}", lowGuid, trackId)) { do { uint32 const cid = cr->Fetch()[0].Get(); if (cid) allow.insert(cid); } while (cr->NextRow()); } } for (auto const& [tid, delta] : talentDeltas) { (void)delta; 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]); } // 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"; if (!allow.empty()) { body += ' '; bool first = true; for (uint32 id : allow) { if (!first) body += ','; body += std::to_string(id); first = false; // Stay under the addon-channel chat budget (~250 chars). When // close to full, flush this batch and start a new OPEN message. // Client treats multiple OPEN as additive. if (body.size() > 220) { SendAddonMessage(pl, body); body = "R SILENCE OPEN "; first = true; } } } SendAddonMessage(pl, body); } void SendSilenceClose(Player* pl) { if (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 // panel_* DB rows (mirrors the per-spell portion of HandleParagonResetAbilities). // `spellId` may be any rank id from the bake; normalized to GetFirstSpellInChain. bool PanelUnlearnSpellPurchase(Player* pl, uint32 spellId, std::string* err) { if (!pl || !spellId) { if (err) *err = "bad player or spell"; return false; } uint32 const lowGuid = pl->GetGUID().GetCounter(); uint32 const head = sSpellMgr->GetFirstSpellInChain(spellId); uint32 const sid = head ? head : spellId; if (!CharacterDatabase.Query( "SELECT 1 FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {} LIMIT 1", lowGuid, sid)) { if (err) *err = fmt::format("spell {} is not a panel purchase", sid); return false; } uint32 const refund = LookupSpellAECost(sid); if (QueryResult cr = CharacterDatabase.Query( "SELECT child_spell_id FROM character_paragon_panel_spell_children " "WHERE guid = {} AND parent_spell_id = {}", lowGuid, sid)) { do { uint32 const cid = cr->Fetch()[0].Get(); if (pl->HasSpell(cid)) pl->removeSpell(cid, SPEC_MASK_ALL, false); } while (cr->NextRow()); } if (pl->HasSpell(sid)) pl->removeSpell(sid, SPEC_MASK_ALL, false); CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_panel_spell_children WHERE guid = {} AND parent_spell_id = {}", lowGuid, sid); CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_panel_spell_revoked WHERE guid = {} AND parent_spell_id = {}", lowGuid, sid); std::unordered_set chainIds; CollectSpellChainIds(sid, chainIds); DbDeletePanelSpellRevokedForChain(lowGuid, chainIds); CharacterDatabase.DirectExecute( "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) constexpr std::string_view kPrefix = "C COMMIT "; std::string_view rest = body; if (rest.substr(0, kPrefix.size()) == kPrefix) rest.remove_prefix(kPrefix.size()); // Find " t:" delimiter to split spells / talents sections. auto sPos = rest.find("s:"); auto tPos = rest.find(" t:"); if (sPos != 0 || tPos == std::string_view::npos) { *err = "malformed commit"; return false; } std::string_view spellsCsv = rest.substr(2, tPos - 2); std::string_view talentsCsv; std::string_view unlearnCsv; 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 talentUnlearnRaw = ParseCsvUInt(talentUnlearnCsv); // Talents are "id:delta,id:delta,...". Parse into vector of pairs. std::vector> talentDeltas; { size_t i = 0; while (i < talentsCsv.size()) { size_t end = talentsCsv.find(',', i); if (end == std::string_view::npos) end = talentsCsv.size(); if (end > i) { std::string_view tok = talentsCsv.substr(i, end - i); size_t colon = tok.find(':'); if (colon == std::string_view::npos) { *err = "talent token missing colon"; return false; } uint32 tid = 0, delta = 0; try { tid = uint32(std::stoul(std::string(tok.substr(0, colon)))); delta = uint32(std::stoul(std::string(tok.substr(colon + 1)))); } catch (...) { *err = "talent token parse failed"; return false; } if (tid && delta) talentDeltas.emplace_back(tid, delta); } i = end + 1; } } std::unordered_set unlearnTrackSet; std::vector unlearnTracks; for (uint32 raw : unlearnRaw) { if (!raw) continue; uint32 const head = sSpellMgr->GetFirstSpellInChain(raw); uint32 const tid = head ? head : raw; if (unlearnTrackSet.insert(tid).second) unlearnTracks.push_back(tid); } // 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; } uint32 unlearnRefundAE = 0; for (uint32 tid : unlearnTracks) { if (!CharacterDatabase.Query( "SELECT 1 FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {} LIMIT 1", pl->GetGUID().GetCounter(), tid)) { *err = fmt::format("cannot unlearn {} (not a panel purchase)", tid); return false; } 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; std::vector> spellsAndCosts; spellsAndCosts.reserve(spellIds.size()); for (uint32 id : spellIds) { if (!id) continue; SpellInfo const* info = sSpellMgr->GetSpellInfo(id); if (!info) { *err = fmt::format("unknown spell {}", id); return false; } if (pl->HasSpell(id)) continue; // silently skip; not an error from the user's POV uint32 const reqLv = GetParagonPanelSpellRequiredLevel(info); if (pl->GetLevel() < reqLv) { *err = fmt::format("requires level {} for spell {}", reqLv, id); return false; } uint32 const learnHead = sSpellMgr->GetFirstSpellInChain(id); uint32 const learnTrack = learnHead ? learnHead : id; if (unlearnTrackSet.count(learnTrack)) { *err = "cannot learn and unlearn the same spell in one commit"; return false; } uint32 cost = LookupSpellAECost(id); spellsAndCosts.emplace_back(id, cost); totalAE += cost; } // (combined AE budget — spells + spell-granting talent ranks — is checked // once below after we know the talent AE total) // Pre-validate talents. addToSpellBook talents (Starfall, Bladestorm, …) // cost both AE and TE per rank; other talents cost TE only. uint32 const tePerRank = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); uint32 const aePerRank = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); uint32 talentsTE = 0; uint32 talentsAE = 0; for (auto const& [tid, delta] : talentDeltas) { TalentEntry const* te = sTalentStore.LookupEntry(tid); if (!te) { *err = fmt::format("unknown talent {}", tid); return false; } // Same tier gate as default Wrath UI: row 0 @ 10, +5 per row down. uint32 const minLevelForTier = 10u + te->Row * 5u; if (pl->GetLevel() < minLevelForTier) { *err = fmt::format("requires level {} for that talent tier", minLevelForTier); return false; } if (te->addToSpellBook) { talentsAE += delta * aePerRank; talentsTE += delta * tePerRank; } else talentsTE += delta * tePerRank; } if (GetTE(pl) + talentUnlearnRefundTE < talentsTE) { *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 + talentUnlearnRefundAE < (totalAE + talentsAE)) { *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; } uint32 const lowGuid = pl->GetGUID().GetCounter(); // Open client-side silence window so the cascade dependents AC's // 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 // (spells + talents). SendSilenceOpenForCommit(pl, spellsAndCosts, talentDeltas, unlearnTracks, talentUnlearns); // 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)) { SendSilenceClose(pl); 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 // panel matches "trainer-style" learning (e.g., buying Wrath at L60 yields // ranks 1-9 in one purchase). Only the chain head is recorded so reset // refunds the AE cost exactly once per purchase. for (auto const& [id, cost] : spellsAndCosts) { if (!TrySpendAE(pl, cost)) { *err = "AE spend failed mid-commit (race?)"; SendSilenceClose(pl); return false; } PanelLearnSpellChain(pl, id); } // Apply talents one rank at a time so each call goes through the // existing OnPlayerLearnTalents hook (which spends TE per rank, and // both AE+TE for addToSpellBook talents). // command=true bypasses talent-point and tier-spend requirements; // class-mask is bypassed in core for Paragon (Player::LearnTalent). for (auto const& [tid, delta] : talentDeltas) { TalentEntry const* te = sTalentStore.LookupEntry(tid); if (!te) continue; // Find the player's current rank in this talent (0-indexed; 0 means // none, 1 means rank-1, etc.). Compare against MAX_TALENT_RANK. uint32 currentRank = 0; for (uint8 r = 0; r < MAX_TALENT_RANK; ++r) { if (te->RankID[r] && pl->HasTalent(te->RankID[r], pl->GetActiveSpec())) { currentRank = r + 1; // keep scanning to find HIGHEST rank } } uint32 const targetRank = std::min(currentRank + delta, MAX_TALENT_RANK); for (uint32 r = currentRank; r < targetRank; ++r) { pl->LearnTalent(tid, r, /*command=*/true); // Fractured / Paragon: talents that LEARN_SPELL an ability // (Mangle, Feral Charge, Mutilate, ...) only directly grant the // rank-1 ability spell. Stock classes auto-rank-up the granted // spell via Player::learnSkillRewardedSpells on level-up, but // that path is intentionally disabled for Paragon class skill // lines (Player.cpp guard) -- so without this cascade the // ability stays at rank 1 forever (Mangle Bear 33878 instead // of 33986 / 33987 / 48563 / 48564). Walk every LEARN_SPELL // target on this rank's RankID spell and grant the highest // rank the player's level allows. if (TalentEntry const* freshTe = sTalentStore.LookupEntry(tid)) if (uint32 rankSpell = freshTe->RankID[r]) CascadeRanksForTalentLearnSpellEffects(pl, rankSpell); } } for (auto const& [tid, delta] : talentDeltas) { (void)delta; uint32 const r = ComputeTalentRankAnySpec(pl, tid); DbUpsertPanelTalent(lowGuid, tid, r); } // One scoped sweep after all panel DB rows for this commit exist. Catches // cascade dependents the per-rank diff missed when anchor SkillLine rows // used a non-LEARN_* AcquireMethod (fixed in SkillLinesLinkedToSpell, but // this is cheap insurance for mixed commits / talent side effects). RevokeUnwantedCascadeSpellsForPlayer(pl); SaveCurrencyToDb(pl); SendSilenceClose(pl); return true; } // ---- Snapshot push (R SPELLS / R TALENTS) --------------------------------- // // Sends spell IDs and talent (id, rank) pairs that were purchased through // Character Advancement only (see character_paragon_panel_* tables). // // Addon-channel messages have a generous chat-packet size budget but smaller // is friendlier, so we chunk into ~180-char payload bodies. Format: // "R SPELLS ,,..." (multiple messages OK; client appends) // "R SPELLS_END" (sentinel: rebuild list now) // "R TALENTS :,..." (chunked similarly) // "R TALENTS_END" // The client clears its buffer when it sees the first "R SPELLS" / "R TALENTS" // after a "_END", so resending is idempotent. constexpr size_t kSnapshotChunkBudget = 180; void PushSpellSnapshot(Player* pl) { if (!pl) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); std::string buf; buf.reserve(kSnapshotChunkBudget + 16); buf = "R SPELLS "; bool first = true; auto flush = [&](bool finalChunk) { if (buf.size() > 9) SendAddonMessage(pl, buf); buf = "R SPELLS "; first = true; if (finalChunk) SendAddonMessage(pl, "R SPELLS_END"); }; if (QueryResult r = CharacterDatabase.Query( "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {} ORDER BY spell_id", lowGuid)) { do { uint32 const sid = r->Fetch()[0].Get(); if (!pl->HasSpell(sid)) { CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {}", lowGuid, sid); 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) flush(/*finalChunk=*/false); buf.append(first ? std::to_string(sid) : token); first = false; } while (r->NextRow()); } flush(/*finalChunk=*/true); } void PushTalentSnapshot(Player* pl) { if (!pl) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); std::string buf = "R TALENTS "; bool first = true; auto flush = [&](bool finalChunk) { if (buf.size() > 10) SendAddonMessage(pl, buf); buf = "R TALENTS "; first = true; if (finalChunk) SendAddonMessage(pl, "R TALENTS_END"); }; if (QueryResult r = CharacterDatabase.Query( "SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", lowGuid)) { do { Field const* f = r->Fetch(); uint32 const tid = f[0].Get(); uint32 const dbRank = f[1].Get(); uint32 const actual = ComputeTalentRankAnySpec(pl, tid); if (!actual || !dbRank) { CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_panel_talents WHERE guid = {} AND talent_id = {}", lowGuid, tid); continue; } uint32 const shown = std::min(actual, dbRank); std::string token = (first ? "" : ",") + std::to_string(tid) + ":" + std::to_string(shown); if (buf.size() + token.size() > kSnapshotChunkBudget) flush(/*finalChunk=*/false); buf.append(first ? (std::to_string(tid) + ":" + std::to_string(shown)) : token); first = false; } while (r->NextRow()); } flush(/*finalChunk=*/true); } bool HandleParagonResetAbilities(Player* pl, std::string* err) { if (!pl || pl->getClass() != CLASS_PARAGON) { *err = "not a Paragon"; return false; } if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) { *err = "Paragon currency disabled"; return false; } LoadCurrencyFromDb(pl); uint32 const lowGuid = pl->GetGUID().GetCounter(); uint32 refundAE = 0; if (QueryResult r = CharacterDatabase.Query( "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {} ORDER BY spell_id", lowGuid)) { do { uint32 const sid = r->Fetch()[0].Get(); refundAE += LookupSpellAECost(sid); // Unlearn any passive dependents we tracked when this parent // was learned (Frost Fever, Blood Presence, Forceful // Deflection, ...). Done before removing the parent so the // server doesn't re-grant them via SPELL_EFFECT_LEARN_SPELL // re-application during the parent's removeSpell cascade. if (QueryResult cr = CharacterDatabase.Query( "SELECT child_spell_id FROM character_paragon_panel_spell_children " "WHERE guid = {} AND parent_spell_id = {}", lowGuid, sid)) { do { uint32 const cid = cr->Fetch()[0].Get(); if (pl->HasSpell(cid)) pl->removeSpell(cid, SPEC_MASK_ALL, false); } while (cr->NextRow()); } 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()); } // Best-effort revoke pass for active dependents the cascade may have // re-installed since last commit (Blood Presence, Death Coil, ...). // Without this, a player who upgraded to this build with orphan // dependents already in their spellbook would still see them after // Reset Abilities; with this they're swept the moment they reset. if (QueryResult rev = CharacterDatabase.Query( "SELECT revoked_spell_id FROM character_paragon_panel_spell_revoked WHERE guid = {}", lowGuid)) { do { uint32 const sid = rev->Fetch()[0].Get(); if (pl->HasSpell(sid)) pl->removeSpell(sid, SPEC_MASK_ALL, false); } while (rev->NextRow()); } CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_spells WHERE guid = {}", lowGuid); CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_spell_children WHERE guid = {}", lowGuid); CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_spell_revoked WHERE guid = {}", lowGuid); if (refundAE) { ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); d.abilityEssence += refundAE; } // Reset detaches the player from any active build. The build's // saved recipe is preserved in DB so the player can re-load it, // but until they do, the next swap MUST NOT auto-snapshot the // (now empty/partial) panel state into that build -- which is // exactly what would happen if we left the pointer set. We push // the catalog so the cell that previously had the "active" // border drops it client-side; if this reset is being invoked // mid-swap (HandleBuildLoad), the swap's final PushBuildCatalog // restores the correct activeId at the tail. SetActiveBuildId(lowGuid, 0); SaveCurrencyToDb(pl); PushCurrency(pl); PushSnapshot(pl); PushBuildCatalog(pl); LOG_INFO("module", "Paragon panel: {} reset abilities (+{} AE refund)", pl->GetName(), refundAE); return true; } // --------------------------------------------------------------------------- // 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) { *err = "not a Paragon"; return false; } if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) { *err = "Paragon currency disabled"; 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; uint32 refundTE = 0; uint32 const tePerRank = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); uint32 const aePerRank = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); if (QueryResult r = CharacterDatabase.Query( "SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", lowGuid)) { do { Field const* f = r->Fetch(); uint32 const tid = f[0].Get(); uint32 const dbRank = f[1].Get(); TalentEntry const* te = sTalentStore.LookupEntry(tid); if (!te || !dbRank) continue; uint32 const actual = ComputeTalentRankAnySpec(pl, tid); uint32 const ranks = std::min(dbRank, actual); if (!ranks) continue; if (te->addToSpellBook) { refundAE += ranks * aePerRank; refundTE += ranks * tePerRank; } else refundTE += ranks * tePerRank; } while (r->NextRow()); } uint8 const origSpec = pl->GetActiveSpec(); for (uint8 s = 0; s < pl->GetSpecsCount(); ++s) { pl->ActivateSpec(s); pl->resetTalents(true); } pl->ActivateSpec(origSpec); CharacterDatabase.DirectExecute("DELETE FROM character_paragon_panel_talents WHERE guid = {}", lowGuid); ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); d.abilityEssence += refundAE; d.talentEssence += refundTE; // See HandleParagonResetAbilities: detach from the active build // so the next swap doesn't overwrite its saved recipe with the // (post-reset) state. SetActiveBuildId(lowGuid, 0); SaveCurrencyToDb(pl); 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; } bool HandleParagonResetAll(Player* pl, std::string* err) { std::string sub; if (!HandleParagonResetTalents(pl, err)) return false; if (!HandleParagonResetAbilities(pl, &sub)) { *err = sub; return false; } LOG_INFO("module", "Paragon panel: {} reset everything", pl->GetName()); return true; } void PushSnapshot(Player* pl) { if (!pl || pl->getClass() != CLASS_PARAGON) return; PushSpellSnapshot(pl); PushTalentSnapshot(pl); } // ============================================================================ // Build catalog (saved Character Advancement loadouts). // ---------------------------------------------------------------------------- // See data/sql/db-characters/updates/2026_05_10_03.sql for the schema and // the architectural overview. The "active build" pointer is a per-character // row in `character_paragon_active_build`; if no row exists the player has // no active build (free-floating loadout, default state). // // Wire format (PARAA addon channel): // // Q BUILDS -- request catalog // C BUILD NEW \t -- create empty build // C BUILD SAVE_CURRENT \t -- create build from current // panel state and set active // C BUILD EDIT \t\t -- rename / re-icon // C BUILD DELETE -- delete + drop parked pet; // if is the active build, // also full panel reset (unlearn // + AE/TE refund) like RESET ALL // C BUILD LOAD -- swap to this build // C BUILD UNLOAD -- clear active pointer // C BUILD IMPORT -- copy a shared build // into our own catalog // // Server replies push `R BUILDS` after every mutation. Format: // // R BUILDS active=\t::::::; ... // // `sharecode` is a 6-character random alphanumeric token unique across // the realm, generated at build creation. It's how players exchange // builds: paste the code into the BuildsPane share box on a friend's // client and the IMPORT command copies the recipe (name + icon + spell // rows + talent rows) into the friend's catalog as a new build with a // fresh sharecode (so the imported copy can be re-shared independently). // // `remainAE` / `remainTE` are SIGNED int32s representing the AE / TE // the player would have unspent IF they loaded this build right now // (load = refund all currently-learned panel spells/talents, then // re-spend on the target recipe). Negative means the recipe costs // more than the player has earned -- the client renders that case in // red so the player knows they can't afford the load. // // Names and icon paths are sanitized server-side: name = ASCII printable // up to 32 chars (no '\t', '\r', '\n', ';', ':' since those are wire // separators); icon = filename suffix only (no slashes), capped at 64 // chars. The client renders the icon as // "Interface\\Icons\\". // ---------------------------------------------------------------------------- constexpr char const* kDefaultBuildIcon = "INV_Misc_QuestionMark"; constexpr std::size_t kBuildNameMaxLen = 32; constexpr std::size_t kBuildIconMaxLen = 64; // Share code charset: upper-case alphanumeric minus visually-ambiguous // glyphs (I, O, 0, 1) so codes spoken aloud or copied by hand are // unambiguous. 31 chars ^ 6 positions = ~887M unique codes; collision // retry on insert keeps practical collision probability vanishingly // small for any realistic per-realm catalog. constexpr std::size_t kBuildShareCodeLen = 6; constexpr char const kBuildShareCharset[] = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; constexpr std::size_t kBuildShareCharsetN = sizeof(kBuildShareCharset) - 1; std::string GenerateBuildShareCode() { std::string code; code.reserve(kBuildShareCodeLen); for (std::size_t i = 0; i < kBuildShareCodeLen; ++i) code += kBuildShareCharset[urand(0, static_cast(kBuildShareCharsetN) - 1)]; return code; } // Strict whitelist on incoming `C BUILD IMPORT ` payloads. The // SQL we'd feed into the lookup query interpolates the value via // fmt::format (matching the rest of this file's style), so we vet // length and charset up front and reject anything that isn't 6 // characters drawn from the same alphabet GenerateBuildShareCode emits. bool IsValidShareCode(std::string const& s) { if (s.size() != kBuildShareCodeLen) return false; for (char c : s) { bool ok = false; for (std::size_t i = 0; i < kBuildShareCharsetN; ++i) { if (c == kBuildShareCharset[i]) { ok = true; break; } } if (!ok) return false; } return true; } // Generate a fresh share code, retrying on collision against the // existing rows. With a 31^6 alphabet and even 1M rows the probability // of a single random pick colliding is < 0.001%, so 8 retries is far // more than enough headroom; the loop is purely defensive. std::string GenerateUniqueShareCode() { for (int attempt = 0; attempt < 8; ++attempt) { std::string code = GenerateBuildShareCode(); if (QueryResult r = CharacterDatabase.Query( "SELECT 1 FROM character_paragon_builds WHERE share_code = '{}'", code)) continue; if (QueryResult r2 = CharacterDatabase.Query( "SELECT 1 FROM character_paragon_build_share_archive WHERE share_code = '{}'", code)) continue; return code; } // Worst-case fallback: append a numeric uniquifier from build_id // sequence. We can't produce a guaranteed-unique 6-char code if // ~887M codes are taken (impossible at any realistic scale), so // collapse to the last attempt and let the unique index reject // duplicates if the universe is broken. return GenerateBuildShareCode(); } // After a successful Learn All while a build is active: freeze the // previous share_code + recipe into character_paragon_build_share_archive* // (so Discord-posted codes keep importing that exact loadout), then // snapshot the panel into the live build rows and assign a fresh code // for the owner's current recipe. void PersistActiveBuildSnapshotAfterLearnAllCommit(Player* pl, uint32 buildId) { if (!pl || !buildId) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); if (!BuildBelongsToPlayer(lowGuid, buildId)) return; std::string oldCode; if (QueryResult row = CharacterDatabase.Query( "SELECT COALESCE(NULLIF(share_code, ''), '') AS sc " "FROM character_paragon_builds WHERE build_id = {} AND guid = {}", buildId, lowGuid)) oldCode = row->Fetch()[0].Get(); if (!oldCode.empty()) { CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_build_share_archive_spells WHERE share_code = '{}'", oldCode); CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_build_share_archive_talents WHERE share_code = '{}'", oldCode); CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_build_share_archive WHERE share_code = '{}'", oldCode); CharacterDatabase.DirectExecute( "INSERT INTO character_paragon_build_share_archive (share_code, name, icon) " "SELECT share_code, name, icon FROM character_paragon_builds " "WHERE build_id = {} AND guid = {}", buildId, lowGuid); CharacterDatabase.DirectExecute( "INSERT INTO character_paragon_build_share_archive_spells (share_code, spell_id) " "SELECT '{}', spell_id FROM character_paragon_build_spells WHERE build_id = {}", oldCode, buildId); CharacterDatabase.DirectExecute( "INSERT INTO character_paragon_build_share_archive_talents (share_code, spec, talent_id, `rank`) " "SELECT '{}', spec, talent_id, `rank` FROM character_paragon_build_talents WHERE build_id = {}", oldCode, buildId); } SnapshotBuildFromCurrent(pl, buildId); std::string const newCode = GenerateUniqueShareCode(); CharacterDatabase.DirectExecute( "UPDATE character_paragon_builds SET share_code = '{}' WHERE build_id = {} AND guid = {}", newCode, buildId, lowGuid); LOG_INFO("module", "Paragon build: {} persisted active build {} after commit (share now {})", pl->GetName(), buildId, newCode); } std::string SanitizeBuildName(std::string s) { std::string out; out.reserve(s.size()); for (char c : s) { // Reject wire separators and control characters. Keep printable // ASCII + space; everything else dropped silently. WoW's font // engine handles UTF-8 input but our wire format is ; : \t so // we conservatively limit to ASCII to keep the serializer // simple and the parser unambiguous. if (c == '\t' || c == '\r' || c == '\n' || c == ';' || c == ':') continue; if (c < 0x20 || c == 0x7F) continue; out += c; } if (out.size() > kBuildNameMaxLen) out.resize(kBuildNameMaxLen); // Trim leading/trailing whitespace. auto notSpace = [](unsigned char c) { return !std::isspace(c); }; auto first = std::find_if(out.begin(), out.end(), notSpace); auto last = std::find_if(out.rbegin(), out.rend(), notSpace).base(); if (first >= last) return std::string(); return std::string(first, last); } std::string SanitizeBuildIcon(std::string s) { std::string out; out.reserve(s.size()); for (char c : s) { // Icon file paths are alnum + underscore + hyphen + dot only. // No slashes (we always prepend "Interface\\Icons\\" client-side). if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.') out += c; } if (out.size() > kBuildIconMaxLen) out.resize(kBuildIconMaxLen); if (out.empty()) out = kDefaultBuildIcon; return out; } uint32 GetActiveBuildId(uint32 lowGuid) { if (QueryResult r = CharacterDatabase.Query( "SELECT build_id FROM character_paragon_active_build WHERE guid = {}", lowGuid)) return r->Fetch()[0].Get(); return 0; } void SetActiveBuildId(uint32 lowGuid, uint32 buildId) { if (buildId) CharacterDatabase.DirectExecute( "REPLACE INTO character_paragon_active_build (guid, build_id) VALUES ({}, {})", lowGuid, buildId); else CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_active_build WHERE guid = {}", lowGuid); } bool BuildBelongsToPlayer(uint32 lowGuid, uint32 buildId) { if (!buildId) return false; QueryResult r = CharacterDatabase.Query( "SELECT 1 FROM character_paragon_builds WHERE build_id = {} AND guid = {}", buildId, lowGuid); return r != nullptr; } // ---------------------------------------------------------------------------- // Cost / remaining-AE-TE helpers (used by PushBuildCatalog tooltip data). // ---------------------------------------------------------------------------- // "Remaining if loaded" = (total earned by this player so far) // - (cost of recipe stored in this build). // // The wire push computes both halves and ships the *remaining* numbers // per build; the client just renders them. Computing total_earned from // (current pool + currently-spent on panel) keeps us from having to // add a dedicated "earned" counter to character_paragon_currency. // // Note on per-spec accuracy: character_paragon_panel_talents is keyed // (guid, talent_id) -- it stores the highest rank in any spec, not a // per-spec breakdown. If a Paragon char has DIFFERENT talent allocations // in spec 0 vs spec 1 they may have paid TE multiple times for the same // talent_id, but the "spent" walk only sees one row. This undercounts // total_earned in that edge case, which makes "remaining if loaded" // show conservatively LOW for builds that include cross-spec talents. // The character_paragon_build_talents table IS keyed (build_id, spec, // talent_id) so the BUILD cost side is always accurate. Net effect: // the tooltip might say a build needs 2 more AE than it really does // for a small fraction of players. That's preferable to over-promising // and having the load fail with "not enough TE" mid-flight. struct BuildCost { uint32 ae = 0; uint32 te = 0; }; BuildCost ComputeBuildRecipeCost(uint32 buildId) { BuildCost out{}; if (!buildId) return out; uint32 const tePerRank = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); uint32 const aePerRank = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); // Spell side: each panel spell costs LookupSpellAECost. The recipe // table doesn't carry rank info because PanelLearnSpellChain grants // every higher rank of a chain in one charge, so one row = one // chain-head buy = one cost lookup. if (QueryResult r = CharacterDatabase.Query( "SELECT spell_id FROM character_paragon_build_spells WHERE build_id = {}", buildId)) { do { uint32 sid = r->Fetch()[0].Get(); if (!sid) continue; out.ae += LookupSpellAECost(sid); } while (r->NextRow()); } // Talent side: charge tePerRank * rank for every (spec, talent_id) // row. addToSpellBook talents (the few that grant an active spell // like Starfall / Bladestorm / Mirror Image) charge AE on top. if (QueryResult r = CharacterDatabase.Query( "SELECT talent_id, `rank` FROM character_paragon_build_talents " "WHERE build_id = {}", buildId)) { do { Field const* f = r->Fetch(); uint32 tid = f[0].Get(); uint32 rank = f[1].Get(); if (!tid || !rank) continue; TalentEntry const* te = sTalentStore.LookupEntry(tid); if (!te) continue; out.te += rank * tePerRank; if (te->addToSpellBook) out.ae += rank * aePerRank; } while (r->NextRow()); } return out; } // Sum the AE/TE the player has currently SPENT on panel-bought spells // and talents. Refunding everything via Reset would return exactly this // total to the unspent pool, so total_earned == current + spent. BuildCost ComputeCurrentlySpentOnPanel(uint32 lowGuid) { BuildCost out{}; uint32 const tePerRank = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); uint32 const aePerRank = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); if (QueryResult r = CharacterDatabase.Query( "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid)) { do { uint32 sid = r->Fetch()[0].Get(); if (!sid) continue; out.ae += LookupSpellAECost(sid); } while (r->NextRow()); } if (QueryResult r = CharacterDatabase.Query( "SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", lowGuid)) { do { Field const* f = r->Fetch(); uint32 tid = f[0].Get(); uint32 rank = f[1].Get(); if (!tid || !rank) continue; TalentEntry const* te = sTalentStore.LookupEntry(tid); if (!te) continue; out.te += rank * tePerRank; if (te->addToSpellBook) out.ae += rank * aePerRank; } while (r->NextRow()); } return out; } // Lazily assign share codes to any of this player's builds that still // have a NULL share_code (rows created under the pre-2026_05_10_04 // schema). Runs at the top of every PushBuildCatalog so by the time // the wire response is built every row has a non-NULL code. Cheap in // steady-state -- the SELECT returns zero rows once backfilled. void BackfillBuildShareCodes(uint32 lowGuid) { QueryResult r = CharacterDatabase.Query( "SELECT build_id FROM character_paragon_builds " "WHERE guid = {} AND (share_code IS NULL OR share_code = '')", lowGuid); if (!r) return; do { uint32 buildId = r->Fetch()[0].Get(); std::string code = GenerateUniqueShareCode(); CharacterDatabase.DirectExecute( "UPDATE character_paragon_builds SET share_code = '{}' " "WHERE build_id = {}", code, buildId); } while (r->NextRow()); } void PushBuildCatalog(Player* pl) { if (!pl || pl->getClass() != CLASS_PARAGON) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); BackfillBuildShareCodes(lowGuid); uint32 const active = GetActiveBuildId(lowGuid); // total_earned (approx) = current unspent pool + amount currently // spent on panel learns. Refunding everything via Reset would // return exactly the spent portion, so we model "remaining if // loaded" as `total_earned - build_cost`. See the long comment // on ComputeCurrentlySpentOnPanel for the per-spec edge case. BuildCost const spent = ComputeCurrentlySpentOnPanel(lowGuid); int64 const earnedAE = int64(GetAE(pl)) + int64(spent.ae); int64 const earnedTE = int64(GetTE(pl)) + int64(spent.te); std::string activeStr = active ? std::to_string(active) : std::string("-"); std::string body = fmt::format("R BUILDS active={}\t", activeStr); if (QueryResult r = CharacterDatabase.Query( "SELECT build_id, pet_number, share_code, name, icon " "FROM character_paragon_builds WHERE guid = {} " "ORDER BY build_id ASC", lowGuid)) { bool first = true; do { Field const* f = r->Fetch(); uint32 id = f[0].Get(); bool haspet = !f[1].IsNull() && f[1].Get() != 0; std::string code = f[2].IsNull() ? std::string() : f[2].Get(); std::string name = f[3].Get(); std::string icon = f[4].Get(); BuildCost const cost = ComputeBuildRecipeCost(id); // Signed: negative means the recipe costs more than the // player has earned to date (insufficient). Clamp at int32 // bounds out of paranoia though realistic catalogs stay // far inside. int32 const remainAE = static_cast(earnedAE - int64(cost.ae)); int32 const remainTE = static_cast(earnedTE - int64(cost.te)); if (!first) body += ';'; first = false; // Wire format: // :::::: // Sharecode is always 6 chars after backfill; remainAE/TE // are signed (formatted with %+d-equivalent via fmt's "{}", // which renders a leading '-' for negatives and bare digits // for non-negatives, matching the client's "%-?%d+" parse). body += fmt::format("{}:{}:{}:{}:{}:{}:{}", id, haspet ? 1 : 0, code, remainAE, remainTE, name, icon); } while (r->NextRow()); } SendAddonMessage(pl, body); } bool HandleBuildNew(Player* pl, std::string const& payload, std::string* err) { if (!pl || pl->getClass() != CLASS_PARAGON) { *err = "not a Paragon"; return false; } auto tab = payload.find('\t'); if (tab == std::string::npos) { *err = "BUILD NEW malformed"; return false; } std::string name = SanitizeBuildName(payload.substr(0, tab)); std::string icon = SanitizeBuildIcon(payload.substr(tab + 1)); if (name.empty()) { *err = "build name is empty"; return false; } uint32 const lowGuid = pl->GetGUID().GetCounter(); // Soft cap: prevent a runaway script from creating thousands of // empty builds. 64 is far above what fits in the UI grid; the // client also enforces its own limit based on visible cells. if (QueryResult cnt = CharacterDatabase.Query( "SELECT COUNT(*) FROM character_paragon_builds WHERE guid = {}", lowGuid)) { if (cnt->Fetch()[0].Get() >= 64) { *err = "build limit reached (64)"; return false; } } std::string code = GenerateUniqueShareCode(); CharacterDatabase.DirectExecute( "INSERT INTO character_paragon_builds (guid, name, icon, share_code) " "VALUES ({}, '{}', '{}', '{}')", lowGuid, name, icon, code); PushBuildCatalog(pl); LOG_INFO("module", "Paragon build: {} created build '{}' (share code {})", pl->GetName(), name, code); return true; } // "Save current loadout as a new build". Driven by the Overview pane's // "Save as Build" button. Equivalent to HandleBuildNew + an immediate // SnapshotBuildFromCurrent into the new row, plus a SetActiveBuildId // flip. Does NOT touch panel rows / currency / learned spells -- the // player's state is already what they want, we just file it under a // named slot. The previously-active build (if any) keeps its last // committed recipe; loading it later restores that snapshot exactly // as the normal swap flow does. bool HandleBuildSaveCurrent(Player* pl, std::string const& payload, std::string* err) { if (!pl || pl->getClass() != CLASS_PARAGON) { *err = "not a Paragon"; return false; } if (pl->IsInCombat()) { *err = "cannot save builds while in combat"; return false; } auto tab = payload.find('\t'); if (tab == std::string::npos) { *err = "BUILD SAVE_CURRENT malformed"; return false; } std::string name = SanitizeBuildName(payload.substr(0, tab)); std::string icon = SanitizeBuildIcon(payload.substr(tab + 1)); if (name.empty()) { *err = "build name is empty"; return false; } uint32 const lowGuid = pl->GetGUID().GetCounter(); if (QueryResult cnt = CharacterDatabase.Query( "SELECT COUNT(*) FROM character_paragon_builds WHERE guid = {}", lowGuid)) { if (cnt->Fetch()[0].Get() >= 64) { *err = "build limit reached (64)"; return false; } } std::string insertName = name; std::string insertIcon = icon; CharacterDatabase.EscapeString(insertName); CharacterDatabase.EscapeString(insertIcon); std::string const code = GenerateUniqueShareCode(); CharacterDatabase.DirectExecute( "INSERT INTO character_paragon_builds (guid, name, icon, share_code) " "VALUES ({}, '{}', '{}', '{}')", lowGuid, insertName, insertIcon, code); QueryResult idRow = CharacterDatabase.Query( "SELECT build_id FROM character_paragon_builds " "WHERE share_code = '{}'", code); if (!idRow) { *err = "save failed (could not allocate build_id)"; return false; } uint32 const newBuildId = idRow->Fetch()[0].Get(); SnapshotBuildFromCurrent(pl, newBuildId); SetActiveBuildId(lowGuid, newBuildId); PushBuildCatalog(pl); LOG_INFO("module", "Paragon build: {} saved current loadout as build {} '{}' (share {})", pl->GetName(), newBuildId, name, code); return true; } bool HandleBuildEdit(Player* pl, std::string const& payload, std::string* err) { if (!pl || pl->getClass() != CLASS_PARAGON) { *err = "not a Paragon"; return false; } // payload is "\t\t" auto t1 = payload.find('\t'); if (t1 == std::string::npos) { *err = "BUILD EDIT malformed"; return false; } auto t2 = payload.find('\t', t1 + 1); if (t2 == std::string::npos) { *err = "BUILD EDIT malformed"; return false; } uint32 buildId = static_cast(std::strtoul(payload.substr(0, t1).c_str(), nullptr, 10)); std::string name = SanitizeBuildName(payload.substr(t1 + 1, t2 - t1 - 1)); std::string icon = SanitizeBuildIcon(payload.substr(t2 + 1)); if (!buildId) { *err = "BUILD EDIT bad id"; return false; } if (name.empty()) { *err = "build name is empty"; return false; } uint32 const lowGuid = pl->GetGUID().GetCounter(); if (!BuildBelongsToPlayer(lowGuid, buildId)) { *err = "build does not belong to player"; return false; } CharacterDatabase.DirectExecute( "UPDATE character_paragon_builds SET name = '{}', icon = '{}' WHERE build_id = {}", name, icon, buildId); PushBuildCatalog(pl); return true; } // Permanently delete a parked pet (as if the player abandoned it at the // stable master). This is intentionally destructive -- the client warns // the user before reaching this code path. Mirrors the engine's // PET_SAVE_AS_DELETED behavior in DeleteFromDB but scoped to the rows // we know about (the pet itself is unsummoned and not present in // PetStable.CurrentPet, so this is purely a DB cleanup). void DeleteParkedPet(uint32 lowGuid, uint32 petNumber) { if (!petNumber) return; CharacterDatabase.DirectExecute( "DELETE FROM character_pet WHERE owner = {} AND id = {}", lowGuid, petNumber); CharacterDatabase.DirectExecute( "DELETE FROM pet_aura WHERE guid = {}", petNumber); CharacterDatabase.DirectExecute( "DELETE FROM pet_spell WHERE guid = {}", petNumber); CharacterDatabase.DirectExecute( "DELETE FROM pet_spell_cooldown WHERE guid = {}", petNumber); } bool HandleBuildDelete(Player* pl, std::string const& payload, std::string* err) { if (!pl || pl->getClass() != CLASS_PARAGON) { *err = "not a Paragon"; return false; } uint32 const buildId = static_cast(std::strtoul(payload.c_str(), nullptr, 10)); if (!buildId) { *err = "BUILD DELETE bad id"; return false; } uint32 const lowGuid = pl->GetGUID().GetCounter(); // Look up parked pet (and verify ownership) in a single query. QueryResult r = CharacterDatabase.Query( "SELECT pet_number FROM character_paragon_builds WHERE build_id = {} AND guid = {}", buildId, lowGuid); if (!r) { *err = "build not found"; return false; } Field const* f = r->Fetch(); uint32 petNumber = f[0].IsNull() ? 0 : f[0].Get(); // Deleting the *active* build is a hard reset: unlearn everything the // Character Advancement panel bought, refund all AE/TE into the // unspent pool, and clear the active pointer (same net effect as // C RESET ALL). Deleting a non-active slot only removes the saved // recipe row + any parked pet bound to that slot. uint32 const active = GetActiveBuildId(lowGuid); if (active == buildId) { std::string resetErr; if (!HandleParagonResetAll(pl, &resetErr)) { *err = resetErr; return false; } } CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_build_spells WHERE build_id = {}", buildId); CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_build_talents WHERE build_id = {}", buildId); CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_builds WHERE build_id = {} AND guid = {}", buildId, lowGuid); if (petNumber) DeleteParkedPet(lowGuid, petNumber); PushBuildCatalog(pl); LOG_INFO("module", "Paragon build: {} deleted build {} (parked pet {})", pl->GetName(), buildId, petNumber); return true; } // Import a build from another player's catalog by share code. Copies // the recipe (name + icon + per-spec talent rows + spell rows) into a // new owned build for the requester with a freshly-generated share // code. Crucially does NOT auto-load the imported build -- the player // finds it in their catalog and clicks it like any other saved build, // matching the "import_only" UX choice. The original build owner's // row is untouched. // // Errors (sent back as "R ERR ..." for the addon channel): // - malformed code (length / charset) // - code not found (neither live nor archived) // - the code points to one of the requester's own live-catalog builds // - the requester is at the 64-build cap bool HandleBuildImport(Player* pl, std::string const& payload, std::string* err) { if (!pl || pl->getClass() != CLASS_PARAGON) { *err = "not a Paragon"; return false; } // Trim whitespace and uppercase the input so users don't have to // type the code in exact case. The wire-charset is upper-only so // forcing upper preserves the lookup hit rate even if the player // typed a lower-case 'a'. std::string code = payload; auto notSpace = [](unsigned char c) { return !std::isspace(c); }; auto first = std::find_if(code.begin(), code.end(), notSpace); auto last = std::find_if(code.rbegin(), code.rend(), notSpace).base(); code = (first < last) ? std::string(first, last) : std::string(); for (char& c : code) c = static_cast(std::toupper(static_cast(c))); if (!IsValidShareCode(code)) { *err = "share code must be 6 characters (A-Z minus I/O, 2-9)"; return false; } uint32 const lowGuid = pl->GetGUID().GetCounter(); // Live catalog first, then retired codes frozen in // character_paragon_build_share_archive* (same code keeps importing // the recipe that was current when the owner last committed). QueryResult srcRow = CharacterDatabase.Query( "SELECT build_id, guid, name, icon " "FROM character_paragon_builds WHERE share_code = '{}'", code); bool fromArchive = false; uint32 srcBuildId = 0; uint32 srcOwner = 0; std::string srcName; std::string srcIcon; if (srcRow) { Field const* sf = srcRow->Fetch(); srcBuildId = sf[0].Get(); srcOwner = sf[1].Get(); srcName = sf[2].Get(); srcIcon = sf[3].Get(); } else if (QueryResult arch = CharacterDatabase.Query( "SELECT name, icon FROM character_paragon_build_share_archive WHERE share_code = '{}'", code)) { fromArchive = true; Field const* af = arch->Fetch(); srcName = af[0].Get(); srcIcon = af[1].Get(); } else { *err = "no build with that code"; return false; } if (!fromArchive && srcOwner == lowGuid) { *err = "this build is already in your catalog"; return false; } if (QueryResult cnt = CharacterDatabase.Query( "SELECT COUNT(*) FROM character_paragon_builds WHERE guid = {}", lowGuid)) { if (cnt->Fetch()[0].Get() >= 64) { *err = "build limit reached (64)"; return false; } } // Insert the new owned build first so we have its build_id to // attach the copied recipe rows to. Server-generated share code // is fresh -- the imported copy can be re-shared independently. // // Pet handling: we deliberately do NOT copy `pet_number`. A parked // hunter pet belongs to the source player's character and lives // in `character_pet` under their owner guid; cloning the row // would either steal the pet (corrupting the source player's // stable / stable-master state) or summon a pet the importer // can't legally own. The new row leaves `pet_number = NULL` // (column default), so when the importer first loads this build // and HandleBuildLoad reaches Phase 4, RestoreParkedPetForBuild // sees NULL and no-ops -- the player must tame their own pet // (Tame Beast comes via the recipe if the source build had it), // and on next swap-away ParkActivePetForBuild will bind THEIR // pet to the row exactly like a locally-created build. std::string newCode = GenerateUniqueShareCode(); std::string insertName = srcName; std::string insertIcon = srcIcon; CharacterDatabase.EscapeString(insertName); CharacterDatabase.EscapeString(insertIcon); CharacterDatabase.DirectExecute( "INSERT INTO character_paragon_builds (guid, name, icon, share_code) " "VALUES ({}, '{}', '{}', '{}')", lowGuid, insertName, insertIcon, newCode); QueryResult idRow = CharacterDatabase.Query( "SELECT build_id FROM character_paragon_builds " "WHERE share_code = '{}'", newCode); if (!idRow) { *err = "import failed (could not allocate build_id)"; return false; } uint32 newBuildId = idRow->Fetch()[0].Get(); // Copy recipe rows row-by-row via INSERT...SELECT so we don't // need to materialize them in C++. Using a literal `newBuildId` // for the copy so the foreign reference is correct on the new // owner's row. if (!fromArchive) { CharacterDatabase.DirectExecute( "INSERT INTO character_paragon_build_spells (build_id, spell_id) " "SELECT {}, spell_id FROM character_paragon_build_spells WHERE build_id = {}", newBuildId, srcBuildId); CharacterDatabase.DirectExecute( "INSERT INTO character_paragon_build_talents (build_id, spec, talent_id, `rank`) " "SELECT {}, spec, talent_id, `rank` FROM character_paragon_build_talents " "WHERE build_id = {}", newBuildId, srcBuildId); } else { CharacterDatabase.DirectExecute( "INSERT INTO character_paragon_build_spells (build_id, spell_id) " "SELECT {}, spell_id FROM character_paragon_build_share_archive_spells " "WHERE share_code = '{}'", newBuildId, code); CharacterDatabase.DirectExecute( "INSERT INTO character_paragon_build_talents (build_id, spec, talent_id, `rank`) " "SELECT {}, spec, talent_id, `rank` FROM character_paragon_build_share_archive_talents " "WHERE share_code = '{}'", newBuildId, code); } PushBuildCatalog(pl); // Hunter-pet hint: imports never carry a parked pet (see comment // before the INSERT above). If the recipe contains Tame Beast // (spell 1515) we surface a one-line system message so the player // knows they need to tame their own pet before that build feels // "complete". Other classes' pet-summon spells (Summon Imp, Raise // Dead, ...) re-summon a fresh entity each cast so they don't // need any heads-up. QueryResult petCheck = CharacterDatabase.Query( "SELECT 1 FROM character_paragon_build_spells " "WHERE build_id = {} AND spell_id = 1515 LIMIT 1", newBuildId); if (petCheck && pl->GetSession()) { std::string const msg = fmt::format( "|cffffd200[Paragon]|r Imported \"{}\" includes Tame Beast. " "Tame your own pet after loading this build -- the source " "player's pet was not transferred.", srcName); ChatHandler(pl->GetSession()).SendSysMessage(msg.c_str()); } if (fromArchive) LOG_INFO("module", "Paragon build: {} imported archived code '{}' as new build {} (share {})", pl->GetName(), code, newBuildId, newCode); else LOG_INFO("module", "Paragon build: {} imported '{}' (src build {} owner {}) " "as new build {} with code {}", pl->GetName(), srcName, srcBuildId, srcOwner, newBuildId, newCode); return true; } // Snapshot the player's current panel-purchased state into a build's // recipe rows. Wipes the build's existing recipe rows first so this is // idempotent. Reads `character_paragon_panel_spells` (already authoritative // for purchased spells) and walks `Player::m_talents` per spec for // purchased talents that ALSO appear in `character_paragon_panel_talents` // (so we don't accidentally capture talents the player learned via some // non-panel mechanism -- e.g. trainer dual-spec gift). void SnapshotBuildFromCurrent(Player* pl, uint32 buildId) { uint32 const lowGuid = pl->GetGUID().GetCounter(); CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_build_spells WHERE build_id = {}", buildId); CharacterDatabase.DirectExecute( "DELETE FROM character_paragon_build_talents WHERE build_id = {}", buildId); if (QueryResult sp = CharacterDatabase.Query( "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid)) { do { uint32 sid = sp->Fetch()[0].Get(); CharacterDatabase.DirectExecute( "INSERT IGNORE INTO character_paragon_build_spells (build_id, spell_id) VALUES ({}, {})", buildId, sid); } while (sp->NextRow()); } // Per-spec talents: query the engine's per-spec talent state via // ActivateSpec round-trips, intersected with the panel-bought set // so we only record talents the player actually paid for. std::unordered_set panelTalents; if (QueryResult pt = CharacterDatabase.Query( "SELECT talent_id FROM character_paragon_panel_talents WHERE guid = {}", lowGuid)) { do { panelTalents.insert(pt->Fetch()[0].Get()); } while (pt->NextRow()); } if (panelTalents.empty()) return; uint8 const origSpec = pl->GetActiveSpec(); for (uint8 s = 0; s < pl->GetSpecsCount(); ++s) { if (s != origSpec) pl->ActivateSpec(s); // Walk PlayerTalentMap and record the rank of each panel-known // talent. The engine stores individual rank ids in the talent // map; we resolve back to the (talentId, rank) pair by walking // sTalentStore. for (uint32 i = 0; i < sTalentStore.GetNumRows(); ++i) { TalentEntry const* te = sTalentStore.LookupEntry(i); if (!te) continue; if (!panelTalents.count(te->TalentID)) continue; uint8 rank = 0; for (int8 r = MAX_TALENT_RANK - 1; r >= 0; --r) { if (te->RankID[r] && pl->HasTalent(te->RankID[r], s)) { rank = static_cast(r + 1); break; } } if (!rank) continue; CharacterDatabase.DirectExecute( "REPLACE INTO character_paragon_build_talents " "(build_id, spec, talent_id, `rank`) VALUES ({}, {}, {}, {})", buildId, s, te->TalentID, rank); } } if (pl->GetActiveSpec() != origSpec) pl->ActivateSpec(origSpec); } // Park the currently-summoned hunter pet (if any) into PET_SAVE_NOT_IN_SLOT // and bind the resulting pet_number to `buildId` so that on swap-back the // same pet (with its name, talents, exp) can be re-summoned. Non-hunter // pets (warlock demon, DK ghoul, mage water elemental) are NOT parked // because the engine re-summons those from a fresh template each cast, // so there's nothing to preserve. void ParkActivePetForBuild(Player* pl, uint32 buildId) { if (!pl || !buildId) return; Pet* pet = pl->GetPet(); if (!pet || pet->getPetType() != HUNTER_PET) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); uint32 const petNumber = pet->GetCharmInfo() ? pet->GetCharmInfo()->GetPetNumber() : 0; pl->RemovePet(pet, PET_SAVE_NOT_IN_SLOT); if (!petNumber) return; CharacterDatabase.DirectExecute( "UPDATE character_paragon_builds SET pet_number = {} WHERE build_id = {}", petNumber, buildId); LOG_INFO("module", "Paragon build: parked pet #{} for build {} (player {})", petNumber, buildId, lowGuid); } // Reverse of ParkActivePetForBuild: if `buildId` has a parked pet, // move that pet from PET_SAVE_NOT_IN_SLOT back to PET_SAVE_AS_CURRENT // and re-summon it next to the player. Mirrors the engine's // HandleStableSwapPet flow (NPCHandler.cpp:576). void RestoreParkedPetForBuild(Player* pl, uint32 buildId) { if (!pl || !buildId) return; uint32 const lowGuid = pl->GetGUID().GetCounter(); QueryResult r = CharacterDatabase.Query( "SELECT pet_number FROM character_paragon_builds WHERE build_id = {} AND guid = {}", buildId, lowGuid); if (!r) return; uint32 petNumber = r->Fetch()[0].IsNull() ? 0 : r->Fetch()[0].Get(); if (!petNumber) return; // Refuse mid-combat / mid-instance restores -- these would race with // the worldserver's spawn replication and can leave a "ghost pet" // whose guid the client never receives. The build-load path is // already gated on combat upstream; this is a defense-in-depth // check. if (pl->IsInCombat() || pl->GetMap()->IsBattlegroundOrArena()) { LOG_INFO("module", "Paragon build: skipping pet restore for build {} (combat/arena)", buildId); return; } // If a pet is already current (shouldn't happen because the swap // path parks first, but defensively handle), park it to NOT_IN_SLOT // so we don't create two CurrentPet rows. if (Pet* existing = pl->GetPet()) pl->RemovePet(existing, PET_SAVE_NOT_IN_SLOT); // DB: flip the parked pet's slot to AS_CURRENT. CharacterDatabase.DirectExecute( "UPDATE character_pet SET slot = {} WHERE owner = {} AND id = {}", static_cast(PET_SAVE_AS_CURRENT), lowGuid, petNumber); // In-memory PetStable: move the matching UnslottedPets entry into // CurrentPet so subsequent SummonPet() calls resolve correctly. PetStable* ps = pl->GetPetStable(); if (ps) { // First, if there's a stale CurrentPet from the existing-park // step above, push it back to UnslottedPets in memory. if (ps->CurrentPet) { ps->UnslottedPets.push_back(std::move(*ps->CurrentPet)); ps->CurrentPet.reset(); } for (auto it = ps->UnslottedPets.begin(); it != ps->UnslottedPets.end(); ++it) { if (it->PetNumber == petNumber) { ps->CurrentPet = std::move(*it); ps->UnslottedPets.erase(it); break; } } } // Match HandleStableSwapPet (NPCHandler.cpp:576): when a petnumber is // specified, the `current` flag is ignored by GetLoadPetInfo, so use // false to mirror the engine convention. Pet* newPet = new Pet(pl, HUNTER_PET); if (!newPet->LoadPetFromDB(pl, 0, petNumber, false)) { delete newPet; // Revert DB on failure so we don't strand the pet in CURRENT. CharacterDatabase.DirectExecute( "UPDATE character_pet SET slot = {} WHERE owner = {} AND id = {}", static_cast(PET_SAVE_NOT_IN_SLOT), lowGuid, petNumber); LOG_INFO("module", "Paragon build: pet restore failed for build {} pet #{}", buildId, petNumber); return; } // pet_number column on the build row is now stale (the pet is // current, not parked). Clear it so a subsequent park can rewrite. CharacterDatabase.DirectExecute( "UPDATE character_paragon_builds SET pet_number = NULL WHERE build_id = {}", buildId); } bool HandleBuildLoad(Player* pl, std::string const& payload, std::string* err) { if (!pl || pl->getClass() != CLASS_PARAGON) { *err = "not a Paragon"; return false; } uint32 const targetId = static_cast(std::strtoul(payload.c_str(), nullptr, 10)); if (!targetId) { *err = "BUILD LOAD bad id"; return false; } uint32 const lowGuid = pl->GetGUID().GetCounter(); if (!BuildBelongsToPlayer(lowGuid, targetId)) { *err = "build does not belong to player"; return false; } if (pl->IsInCombat()) { *err = "cannot swap builds while in combat"; return false; } 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. // // Cross-build swap: capture the outgoing build's panel state into // its recipe rows so swapping back later restores it; park any // active hunter pet so we can re-summon the same instance. // // Same-build "revert": skip the snapshot (we WANT the saved // recipe to remain authoritative -- this command's whole purpose // is to discard pending edits), but still park the pet so the // reset+re-spend cycle below doesn't destroy it. // ------------------------------------------------------------- if (activeId) { if (!sameBuild) SnapshotBuildFromCurrent(pl, activeId); ParkActivePetForBuild(pl, activeId); } // ------------------------------------------------------------- // Phase 2: reset all panel-bought spells/talents (refunds AE/TE // 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, /*autoResetPetIfBmLost=*/false)) { *err = sub; return false; } if (!HandleParagonResetAbilities(pl, &sub)) { *err = sub; return false; } // ------------------------------------------------------------- // Phase 3: re-spend AE/TE on the target build's recipe. // ParagonResetAbilities/Talents above already set the active // build pointer to "wiped" by removing all panel rows, but it // hasn't touched character_paragon_active_build. We update it // last (after success) so a partial failure leaves the player // in "no active build" state with refunded currency. // ------------------------------------------------------------- // Recipe spells: reuse PanelLearnSpellChain to drive the same // commit path as the panel UI -- it inserts character_paragon_panel_spells // / panel_spell_children / panel_spell_revoked rows internally. We // explicitly TrySpendAE before each chain since PanelLearnSpellChain // does NOT debit currency on its own (matches HandleCommit's pattern). std::vector recipeSpells; if (QueryResult sp = CharacterDatabase.Query( "SELECT spell_id FROM character_paragon_build_spells WHERE build_id = {}", targetId)) { do { recipeSpells.push_back(sp->Fetch()[0].Get()); } while (sp->NextRow()); } // Recipe talents: collected up front so we can pre-flight AE+TE. uint32 const tePerRank = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); uint32 const aePerRank = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); struct RecipeTalent { uint8 spec; uint32 tid; uint8 rank; }; std::vector recipeTalents; uint32 talentTeCost = 0; uint32 talentAeCost = 0; if (QueryResult pt = CharacterDatabase.Query( "SELECT spec, talent_id, `rank` FROM character_paragon_build_talents " "WHERE build_id = {} ORDER BY spec ASC, talent_id ASC", targetId)) { do { Field const* f = pt->Fetch(); RecipeTalent rt{ f[0].Get(), f[1].Get(), f[2].Get() }; TalentEntry const* te = sTalentStore.LookupEntry(rt.tid); if (!te || !rt.rank) continue; recipeTalents.push_back(rt); talentTeCost += static_cast(rt.rank) * tePerRank; if (te->addToSpellBook) talentAeCost += static_cast(rt.rank) * aePerRank; } while (pt->NextRow()); } uint32 spellAeCost = 0; for (uint32 sid : recipeSpells) spellAeCost += LookupSpellAECost(sid); uint32 const totalAe = spellAeCost + talentAeCost; if (GetAE(pl) < totalAe) { *err = fmt::format("not enough AE to load build (need {} have {})", totalAe, GetAE(pl)); SetActiveBuildId(lowGuid, 0); SaveCurrencyToDb(pl); PushCurrency(pl); PushSnapshot(pl); PushBuildCatalog(pl); return false; } if (GetTE(pl) < talentTeCost) { *err = fmt::format("not enough TE to load build (need {} have {})", talentTeCost, GetTE(pl)); SetActiveBuildId(lowGuid, 0); SaveCurrencyToDb(pl); PushCurrency(pl); PushSnapshot(pl); PushBuildCatalog(pl); return false; } // Apply spells (TrySpendAE per chain mirrors HandleCommit; the // PanelLearnSpellChain call records DB rows for us). for (uint32 sid : recipeSpells) { uint32 cost = LookupSpellAECost(sid); if (!TrySpendAE(pl, cost)) break; PanelLearnSpellChain(pl, sid); } // Apply talents per spec. OnPlayerLearnTalents (this same script) // debits TE per rank and AE+TE for addToSpellBook talents -- so we // do NOT pre-deduct currency here, only invoke LearnTalent. if (!recipeTalents.empty()) { uint8 const origSpec = pl->GetActiveSpec(); uint8 lastSpec = 0xFF; for (RecipeTalent const& rt : recipeTalents) { if (rt.spec != lastSpec) { pl->ActivateSpec(rt.spec); lastSpec = rt.spec; } for (uint8 r = 0; r < rt.rank; ++r) pl->LearnTalent(rt.tid, r, /*command=*/true); // Mirror HandleCommit's panel-talent persistence (the // OnPlayerLearnTalents hook spends currency but does NOT // write to character_paragon_panel_talents). DbUpsertPanelTalent(lowGuid, rt.tid, rt.rank); } if (pl->GetActiveSpec() != origSpec) pl->ActivateSpec(origSpec); } SetActiveBuildId(lowGuid, targetId); SaveCurrencyToDb(pl); PushCurrency(pl); PushSnapshot(pl); // ------------------------------------------------------------- // Phase 4: restore the target build's parked pet (if any). // ------------------------------------------------------------- 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; } class Paragon_Essence_PlayerScript : public PlayerScript { public: Paragon_Essence_PlayerScript() : PlayerScript("Paragon_Essence_PlayerScript", { PLAYERHOOK_ON_LOGIN, PLAYERHOOK_ON_LOGOUT, PLAYERHOOK_ON_SAVE, PLAYERHOOK_ON_CREATE, PLAYERHOOK_ON_LEVEL_CHANGED, PLAYERHOOK_CAN_LEARN_TALENT, PLAYERHOOK_ON_PLAYER_LEARN_TALENTS, PLAYERHOOK_ON_BEFORE_SEND_CHAT_MESSAGE }) { } void OnPlayerLogin(Player* player) override { LoadCurrencyFromDb(player); // Verify AE/TE matches what the player's level + panel spend // permit. Self-heals admin / crash drift in either direction // and is a no-op (just two small SELECTs) when the balance is // already correct. Has to run BEFORE PushCurrency so the // client's first balance update of the session is the // reconciled one. ReconcileEssenceForPlayer(player); PushCurrency(player); PushSnapshot(player); PushBuildCatalog(player); // AC's character load sequence runs _LoadSkills (which fires // learnSkillRewardedSpells) and _LoadSpells before this hook, // so any active dependents we revoked at panel-commit time // (Blood Presence / Death Coil / Death Grip / ...) have been // silently re-granted by the skill cascade. Walk our persisted // revoke list and remove them again. // // `removeSpell` triggers SMSG_REMOVED_SPELL on the client which // generates "You have unlearned X" CHAT_MSG_SYSTEM toasts. The // chat frame buffers system messages while the loading screen // is up and only flushes them after PLAYER_ENTERING_WORLD, so // a paired OPEN/CLOSE we send here would already be CLOSED by // the time those buffered toasts reach the filter. We open the // silence window from the server side as a defensive measure, // but rely on the client to keep the window open across the // login flush via its own PA.Silence:Open({}) call in the // PLAYER_LOGIN handler. The window auto-closes via the addon's // fail-open timer (PA.Silence.WINDOW_SECONDS). if (player && player->getClass() == CLASS_PARAGON && sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) { std::vector> emptySpells; std::vector> emptyTalents; SendSilenceOpenForCommit(player, emptySpells, emptyTalents); 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: 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 entries for any // panel-purchased parent that is missing them. After the // class-skill cascade guard in // Player::learnSkillRewardedSpells, the cascade no longer // fires for Paragons, so these attachments are the ONLY // source for the disease passive icons (Blood Plague / // Frost Fever) and the small DK weapon passives (Forceful // Deflection from Blood Strike, Runic Focus from Icy // Touch). Existing characters predating the guard may // have FD/RF in their spellbook from the cascade but no // panel_spell_children row tying them to the parent; // re-running learnSpell when they already have the spell // just records the child row and is a no-op otherwise. struct LegacyFix { uint32 parent; uint32 correctChild; }; static LegacyFix const kFixup[] = { { 45462, 59879 }, // Plague Strike -> Blood Plague (passive) { 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) { 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 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 // frame buffers system messages during the loading screen and // would flush them after the CLOSE arrives. The addon's // fail-open timer (8s) closes the window after the flush. } } void OnPlayerLogout(Player* player) override { SaveCurrencyToDb(player); RemoveCacheEntry(player->GetGUID().GetCounter()); } void OnPlayerSave(Player* player) override { SaveCurrencyToDb(player); } void OnPlayerCreate(Player* player) override { if (!player || player->getClass() != CLASS_PARAGON) return; if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return; uint32 const lowGuid = player->GetGUID().GetCounter(); uint32 const ae = ComputeStartingAE(player->GetLevel()); uint32 const te = ComputeStartingTE(player->GetLevel()); DbUpsertCurrency(lowGuid, ae, te); ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid); d.abilityEssence = ae; d.talentEssence = te; // Player isn't fully in-world here; OnPlayerLogin will push. } void OnPlayerLevelChanged(Player* player, uint8 /*oldLevel*/) override { if (!player || player->getClass() != CLASS_PARAGON) return; // Cache is authoritative once OnPlayerLogin loaded it; reloading // from DB here would clobber any AE/TE the player has spent since // the last save (e.g., panel Lock-In followed by a level-up before // the next OnPlayerSave tick), effectively refunding the spend. // Hydrate ONLY if the cache is unexpectedly empty. uint32 const lowGuid = player->GetGUID().GetCounter(); if (gParagonCurrencyCache.find(lowGuid) == gParagonCurrencyCache.end()) LoadCurrencyFromDb(player); // Single source of truth: ComputeStartingAE/TE(newLevel) - spent. // Subsumes the old GrantLevelUpEssence per-level delta AND catches // drift in both directions (cheese clamp + restore-from-loss). // SaveCurrencyToDb runs inside Reconcile when drift is detected; // call it once more here so a no-drift level-up still flushes any // pending cache changes from this session. ReconcileEssenceForPlayer(player); SaveCurrencyToDb(player); PushCurrency(player); // Fractured / Paragon: rank-up cascade for level-up. Without this, // higher ranks of panel-purchased spells AND talent-LEARN_SPELL // granted abilities (Mangle, Feral Charge, Mutilate, ...) never // appear on ding because Player::learnSkillRewardedSpells is // disabled for the class skill line on Paragon (intentional, to // keep the panel as the sole authority over class abilities). // // Cheap re-walks: PanelLearnSpellChain / TeachLevelGated... both // skip ranks the player already has, so the only real work each // level-up is adding the one new rank the player just qualified // for (if any). if (QueryResult r = CharacterDatabase.Query( "SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid)) { do { uint32 const head = r->Fetch()[0].Get(); PanelLearnSpellChain(player, head); } while (r->NextRow()); } if (QueryResult tr = CharacterDatabase.Query( "SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", lowGuid)) { do { Field* f = tr->Fetch(); uint32 const talentId = f[0].Get(); uint32 const rank = f[1].Get(); TalentEntry const* te = sTalentStore.LookupEntry(talentId); if (!te) continue; uint32 const cap = std::min(rank, MAX_TALENT_RANK); for (uint32 i = 0; i < cap; ++i) if (uint32 rankSpell = te->RankID[i]) CascadeRanksForTalentLearnSpellEffects(player, rankSpell); } while (tr->NextRow()); } } bool OnPlayerCanLearnTalent(Player* player, TalentEntry const* talent, uint32 /*rank*/) override { if (!player || player->getClass() != CLASS_PARAGON) return true; if (!talent) return false; uint32 const minLevelForTier = 10u + talent->Row * 5u; if (player->GetLevel() < minLevelForTier) return false; if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return true; // addToSpellBook talents cost both AE and TE per rank. if (talent->addToSpellBook) { uint32 const aeCost = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); uint32 const teCost = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); return GetAE(player) >= aeCost && GetTE(player) >= teCost; } uint32 const cost = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); return GetTE(player) >= cost; } // ParagonAdvancement addon -> server: addon-channel chat from a Paragon // player that begins with our PARAA prefix is treated as a control // request. Supported requests: // "Q CURRENCY" -- push R CURRENCY back to refresh bars // "Q SNAPSHOT" -- push R SPELLS and R TALENTS for Overview // "C COMMIT s:... t:..." -- apply pending learns from the panel // "C RESET ABILITIES" / "C RESET TALENTS" / "C RESET ALL" / "C RESET EVERYTHING" // "C RESET PET TALENTS" -- free + instant pet talent reset (no popup, // no gold). Routes to Player::ResetPetTalents // which itself calls Pet::resetTalents and // refreshes the talent points. void OnPlayerBeforeSendChatMessage(Player* player, uint32& /*type*/, uint32& lang, std::string& msg) override { if (!player || player->getClass() != CLASS_PARAGON) return; if (lang != LANG_ADDON) return; std::string const expectedPrefix = std::string(kAddonPrefix) + "\t"; if (msg.compare(0, expectedPrefix.size(), expectedPrefix) != 0) return; std::string body = msg.substr(expectedPrefix.size()); if (body == "Q CURRENCY") { PushCurrency(player); return; } if (body == "Q SNAPSHOT") { PushSnapshot(player); return; } // Combat lockdown: any mutating control message ("C ...") is // rejected while the player is in combat. The client also // self-gates the buttons that would emit these (see // ParagonAdvancement.lua), but the authoritative check lives // here so a hand-crafted addon message can't bypass it. // Read-only "Q ..." queries above are allowed in combat so // the panel can keep its currency / snapshot displays current. if (!body.empty() && body[0] == 'C' && player->IsInCombat()) { SendAddonMessage(player, "R ERR cannot use Character Advancement while in combat"); return; } if (body.compare(0, 9, "C COMMIT ") == 0) { std::string err; if (HandleCommit(player, body, &err)) { SendAddonMessage(player, fmt::format("R OK {} {}", GetAE(player), GetTE(player))); PushCurrency(player); PushSnapshot(player); // If the player has a build loaded, the commit just // mutated their panel state -- archive the previous // share_code + recipe (so old Discord codes still // import that frozen loadout), snapshot the new panel // into the live build, assign a fresh share_code for // the new recipe, and re-push the catalog. uint32 const lowGuid = player->GetGUID().GetCounter(); uint32 const activeId = GetActiveBuildId(lowGuid); if (activeId) { PersistActiveBuildSnapshotAfterLearnAllCommit(player, activeId); PushBuildCatalog(player); } } else { SendAddonMessage(player, "R ERR " + err); LOG_INFO("module", "Paragon commit rejected for player {}: {}", player->GetName(), err); } return; } if (body == "C RESET ABILITIES") { std::string err; if (!HandleParagonResetAbilities(player, &err)) SendAddonMessage(player, "R ERR " + err); return; } if (body == "C RESET TALENTS") { std::string err; if (!HandleParagonResetTalents(player, &err)) SendAddonMessage(player, "R ERR " + err); return; } if (body == "C RESET ALL" || body == "C RESET EVERYTHING") { std::string err; if (!HandleParagonResetAll(player, &err)) SendAddonMessage(player, "R ERR " + err); return; } // --------------------------------------------------------------- // Build catalog (saved Character Advancement loadouts). // See PushBuildCatalog / HandleBuild* near the top of this file // for wire format and behavior. // --------------------------------------------------------------- if (body == "Q BUILDS") { PushBuildCatalog(player); return; } if (body.compare(0, 12, "C BUILD NEW ") == 0) { std::string err; if (!HandleBuildNew(player, body.substr(12), &err)) SendAddonMessage(player, "R ERR " + err); return; } if (body.compare(0, 21, "C BUILD SAVE_CURRENT ") == 0) { std::string err; if (!HandleBuildSaveCurrent(player, body.substr(21), &err)) SendAddonMessage(player, "R ERR " + err); return; } if (body.compare(0, 13, "C BUILD EDIT ") == 0) { std::string err; if (!HandleBuildEdit(player, body.substr(13), &err)) SendAddonMessage(player, "R ERR " + err); return; } if (body.compare(0, 15, "C BUILD DELETE ") == 0) { std::string err; if (!HandleBuildDelete(player, body.substr(15), &err)) SendAddonMessage(player, "R ERR " + err); return; } if (body.compare(0, 15, "C BUILD IMPORT ") == 0) { std::string err; if (!HandleBuildImport(player, body.substr(15), &err)) SendAddonMessage(player, "R ERR " + err); return; } if (body.compare(0, 13, "C BUILD LOAD ") == 0) { std::string err; if (!HandleBuildLoad(player, body.substr(13), &err)) SendAddonMessage(player, "R ERR " + err); return; } // "C BUILD UNLOAD" -- clears the active-build pointer without // touching learned spells/talents or parking pets. Recovery // path for a stale active pointer (e.g. a load that was // interrupted between Phase 3 and Phase 4 in HandleBuildLoad, // leaving the row pointing at a build whose recipe was already // re-applied). Player retains current learns; the catalog push // refreshes the UI so the "Active" glow + tooltip clear. if (body == "C BUILD UNLOAD") { if (player->getClass() != CLASS_PARAGON) return; uint32 const lowGuid = player->GetGUID().GetCounter(); SetActiveBuildId(lowGuid, 0); PushBuildCatalog(player); return; } if (body == "C RESET PET TALENTS") { // Pet talent reset: deliberately bypasses the engine's // gold-cost confirmation flow. Player::ResetPetTalents // wraps Pet::resetTalents (which only refunds and unlearns; // does NOT charge gold or dismiss the pet) and re-sends the // talent UI to the client. Pre-conditions: // - the player must own a HUNTER_PET (the only pet kind // with a talent tree in 3.3.5) // - the pet must have spent at least 1 talent point // If either fails Player::ResetPetTalents returns silently; // we ack with R OK so the client UI can refresh either way. Pet* pet = player->GetPet(); if (!pet || pet->getPetType() != HUNTER_PET) { SendAddonMessage(player, "R ERR No active hunter pet to reset."); return; } player->ResetPetTalents(); SendAddonMessage(player, "R OK PET TALENTS RESET"); return; } } void OnPlayerLearnTalents(Player* player, uint32 talentId, uint32 /*talentRank*/, uint32 /*spellid*/) override { if (!player || player->getClass() != CLASS_PARAGON) return; if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) return; TalentEntry const* talent = sTalentStore.LookupEntry(talentId); bool const dualCost = talent && talent->addToSpellBook; if (dualCost) { uint32 const aeCost = sConfigMgr->GetOption("Paragon.Currency.AE.TalentLearnCost", 1); uint32 const teCost = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); if (!TrySpendTE(player, teCost)) { LOG_ERROR("module", "Paragon TE spend failed post-learn for talent {} (player {}) — currency desync?", talentId, player->GetName()); return; } if (!TrySpendAE(player, aeCost)) { ParagonCurrencyData& d = GetOrCreateCacheEntry(player->GetGUID().GetCounter()); d.talentEssence += teCost; SaveCurrencyToDb(player); PushCurrency(player); LOG_ERROR("module", "Paragon AE spend failed post-learn for talent {} (player {}) — refunded TE; currency desync?", talentId, player->GetName()); return; } } else { uint32 const cost = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); if (!TrySpendTE(player, cost)) { LOG_ERROR("module", "Paragon TE spend failed post-learn for talent {} (player {}) — currency desync?", talentId, player->GetName()); return; } } PushCurrency(player); } }; // --- Paragon tester inventory helpers (ICC 25H-style curated lists; edit ids here for your fork) --- // Paragon can equip any armor weight; BiS picks below intentionally mix plate/mail/leather/cloth per slot. uint32 ParagonTesterSelectLargestUsableBagItemId(Player const* player) { static constexpr uint32 candidates[] = { 51809, 41600, 41599, 38082 }; for (uint32 id : candidates) { ItemTemplate const* proto = sObjectMgr->GetItemTemplate(id); if (!proto || proto->Class != ITEM_CLASS_CONTAINER || proto->InventoryType != INVTYPE_BAG) continue; if (player->CanUseItem(proto) == EQUIP_ERR_OK) return id; } return 0; } uint32 ParagonTesterGrantItemList(Player* target, uint32 const* ids, size_t count, ChatHandler* handler) { uint32 granted = 0; for (size_t i = 0; i < count; ++i) { uint32 const itemId = ids[i]; if (!itemId) continue; ItemTemplate const* proto = sObjectMgr->GetItemTemplate(itemId); if (!proto) { handler->PSendSysMessage("Paragon tester kit: item {} is not defined (skipped).", itemId); continue; } ItemPosCountVec dest; InventoryResult const msg = target->CanStoreNewItem(NULL_BAG, NULL_SLOT, dest, itemId, 1); if (msg != EQUIP_ERR_OK || dest.empty()) { handler->PSendSysMessage("Paragon tester kit: cannot store {} ({}) — free bag space?", itemId, uint32(msg)); continue; } if (Item* item = target->StoreNewItem(dest, itemId, true)) { item->SetBinding(false); ++granted; } } return granted; } struct ParagonTesterStackedGrant { uint32 itemId; uint32 count; }; // Grants stackable/consumable items in one StoreNewItem per line (gems, scrolls, scopes, etc.). uint32 ParagonTesterGrantStackedItemList(Player* target, ParagonTesterStackedGrant const* grants, size_t grantCount, ChatHandler* handler) { uint32 totalPieces = 0; for (size_t i = 0; i < grantCount; ++i) { uint32 const itemId = grants[i].itemId; uint32 count = grants[i].count; if (!itemId || !count) continue; ItemTemplate const* proto = sObjectMgr->GetItemTemplate(itemId); if (!proto) { handler->PSendSysMessage("Paragon tester kit: item {} is not defined (skipped).", itemId); continue; } uint32 noSpaceForCount = 0; ItemPosCountVec dest; InventoryResult const msg = target->CanStoreNewItem(NULL_BAG, NULL_SLOT, dest, itemId, count, &noSpaceForCount); if (msg != EQUIP_ERR_OK) count -= noSpaceForCount; if (!count || dest.empty()) { handler->PSendSysMessage("Paragon tester kit: cannot store {} x{} ({}) — free bag space?", itemId, grants[i].count, uint32(msg)); continue; } if (Item* item = target->StoreNewItem(dest, itemId, true)) { item->SetBinding(false); totalPieces += count; } } return totalPieces; } void ParagonTesterClearNonEquipmentInventory(Player* player) { for (uint8 bagSlot = INVENTORY_SLOT_BAG_START; bagSlot < INVENTORY_SLOT_BAG_END; ++bagSlot) { if (Bag* bag = player->GetBagByPos(bagSlot)) { for (uint32 j = 0; j < bag->GetBagSize(); ++j) { if (bag->GetItemByPos(j)) player->DestroyItem(bagSlot, j, true); } } if (player->GetItemByPos(INVENTORY_SLOT_BAG_0, bagSlot)) player->DestroyItem(INVENTORY_SLOT_BAG_0, bagSlot, true); } for (uint8 slot = INVENTORY_SLOT_ITEM_START; slot < INVENTORY_SLOT_ITEM_END; ++slot) { if (player->GetItemByPos(INVENTORY_SLOT_BAG_0, slot)) player->DestroyItem(INVENTORY_SLOT_BAG_0, slot, true); } } void ParagonTesterStringToLowerAscii(std::string& s) { for (char& c : s) c = static_cast(std::tolower(static_cast(c))); } std::string ParagonTesterNormalizeWeaponTypeKey(std::string_view raw) { std::string t; for (unsigned char ch : raw) { if (ch == ' ' || ch == '\t' || ch == '-' || ch == '_' || ch == '/') continue; t.push_back(static_cast(std::tolower(ch))); } return t; } // If any bag slot 19–22 is free and the item is a container, move it from inventory onto that slot (Player.cpp pattern). bool ParagonTesterTryEquipBagToFirstEmptySlot(Player* player, Item* bag) { if (!player || !bag) return false; ItemTemplate const* proto = bag->GetTemplate(); if (!proto || proto->Class != ITEM_CLASS_CONTAINER || proto->InventoryType != INVTYPE_BAG) return false; uint16 eDest = 0; if (player->CanEquipItem(NULL_SLOT, eDest, bag, false) != EQUIP_ERR_OK) return false; uint8 const srcBag = bag->GetBagSlot(); uint8 const srcSlot = bag->GetSlot(); player->RemoveItem(srcBag, srcSlot, true); player->EquipItem(eDest, bag, true); return true; } // Curated ICC-era ids (db_world item_template). Extend as needed for your fork. bool ParagonTesterResolveWeaponKit(std::string statRaw, std::string typeRaw, std::vector& out, std::string& err) { out.clear(); ParagonTesterStringToLowerAscii(statRaw); // trim stat while (!statRaw.empty() && statRaw.front() == ' ') statRaw.erase(statRaw.begin()); while (!statRaw.empty() && statRaw.back() == ' ') statRaw.pop_back(); std::string stat = statRaw; if (stat == "strength") stat = "str"; else if (stat == "agility" || stat == "dex" || stat == "dexterity") stat = "agi"; else if (stat == "intellect") stat = "int"; else if (stat == "spirit") stat = "spi"; else if (stat == "apsp" || stat == "spellstrike") stat = "hybrid"; std::string const wkey = ParagonTesterNormalizeWeaponTypeKey(typeRaw); if (stat.empty() || wkey.empty()) { err = "usage: .paragon tester weapons — see `.paragon tester weapons` with no args for help."; return false; } auto push = [&](std::initializer_list ids) { for (uint32 id : ids) if (id) out.push_back(id); }; if (stat == "str") { // "2h sword", "2h/sword", "2h axe" → 2hsword / 2haxe after normalize (slashes stripped like spaces). if (wkey == "2hsword" || wkey == "twohandsword") push({ 50730 }); // Glorenzelg (2H sword) else if (wkey == "2haxe" || wkey == "twohandaxe") push({ 50709 }); // Bryntroll (2H axe) else if (wkey == "2hmace" || wkey == "twohandmace") push({ 50603 }); // Cryptmaker (2H mace) else if (wkey == "1hsword" || wkey == "onehandsword") push({ 50737 }); // Havoc's Call (1H sword) else if (wkey == "1haxe" || wkey == "onehandaxe") push({ 50654 }); // Scourgeborne Waraxe (1H axe) else if (wkey == "1hmace" || wkey == "onehandmace" || wkey == "1hhammer") push({ 50738 }); // Mithrios (1H mace) else if (wkey == "2h" || wkey == "twohand" || wkey == "zwei" || wkey == "great" || wkey == "polearm") push({ 50730 }); else if (wkey == "dual" || wkey == "dw" || wkey == "dualwield" || wkey == "dualwielding") push({ 50738, 50737 }); else if (wkey == "sword" || wkey == "swords" || wkey == "1h") push({ 50737 }); else if (wkey == "mace" || wkey == "hammer") push({ 50738 }); else if (wkey == "axe") push({ 50654 }); else if (wkey == "ranged" || wkey == "bow" || wkey == "gun" || wkey == "crossbow" || wkey == "thrown") push({ 50733 }); else { err = fmt::format( "unknown STR weapon type \"{}\" (try 2h sword, 2h axe, 2h mace, 1h sword, 1h axe, 1h mace, 2h, dual, sword, mace, axe, ranged).", typeRaw); return false; } return true; } if (stat == "agi") { if (wkey == "2h" || wkey == "twohand" || wkey == "polearm") push({ 50735 }); else if (wkey == "dual" || wkey == "dw" || wkey == "dualwield" || wkey == "daggers") push({ 50736, 50676 }); else if (wkey == "dagger") push({ 50736 }); else if (wkey == "sword" || wkey == "swords" || wkey == "1h") push({ 50672 }); else if (wkey == "fist" || wkey == "fistweapon" || wkey == "claw") push({ 50676 }); else if (wkey == "staff" || wkey == "staves") push({ 50731 }); // caster staff; use as generic high-ilvl staff for testers else if (wkey == "bow") push({ 51940 }); // Windrunner's Heartseeker (hunter-style bow) else if (wkey == "ranged" || wkey == "gun" || wkey == "crossbow" || wkey == "thrown") push({ 50733 }); // Fal'inrush (BiS gun) else { err = fmt::format("unknown AGI weapon type \"{}\" (try 2h, dual, dagger, sword, fist, staff, bow, ranged).", typeRaw); return false; } return true; } if (stat == "int") { if (wkey == "staff" || wkey == "staves") push({ 50731 }); else if (wkey == "wand") push({ 50684 }); else if (wkey == "mhoh" || wkey == "ohmh" || wkey == "dual" || wkey == "dw" || wkey == "moh") push({ 50732, 50734 }); else if (wkey == "mh" || wkey == "mainhand" || wkey == "sword" || wkey == "mace" || wkey == "dagger") push({ 50732 }); else if (wkey == "oh" || wkey == "offhand") push({ 50734 }); else if (wkey == "shield") push({ 50729 }); else if (wkey == "ranged" || wkey == "bow" || wkey == "gun" || wkey == "crossbow") push({ 50733 }); else { err = fmt::format("unknown INT weapon type \"{}\" (try staff, wand, mhoh, mh, oh, shield, ranged).", typeRaw); return false; } return true; } if (stat == "spi") { if (wkey == "staff" || wkey == "staves") push({ 50725 }); else if (wkey == "wand") push({ 50684 }); else if (wkey == "mace" || wkey == "mh") push({ 50732 }); else if (wkey == "mhoh" || wkey == "ohmh") push({ 50732, 50734 }); else if (wkey == "shield") push({ 50729 }); else if (wkey == "ranged" || wkey == "bow" || wkey == "gun" || wkey == "crossbow" || wkey == "thrown") push({ 50733 }); else { err = fmt::format("unknown SPI weapon type \"{}\" (try staff, wand, mace, mhoh, shield, ranged).", typeRaw); return false; } return true; } if (stat == "tank") { if (wkey == "shield") push({ 50729 }); else if (wkey == "sword" || wkey == "swords" || wkey == "1h") push({ 50738 }); else if (wkey == "mace" || wkey == "hammer") push({ 50738 }); else if (wkey == "swordboard" || wkey == "sb" || wkey == "mit" || wkey == "1hshield" || wkey == "threat") push({ 50738, 50729 }); else if (wkey == "dual" || wkey == "dw") push({ 50738, 50737 }); else if (wkey == "2h" || wkey == "twohand") push({ 50730 }); else if (wkey == "ranged" || wkey == "bow" || wkey == "gun" || wkey == "crossbow" || wkey == "thrown") push({ 50733 }); else if (wkey == "sigil" || wkey == "relic") push({ 50462 }); else { err = fmt::format("unknown tank weapon type \"{}\" (try swordboard, shield, sword, mace, dual, 2h, ranged, sigil).", typeRaw); return false; } return true; } if (stat == "hybrid") { if (wkey == "staff") push({ 50731 }); else if (wkey == "wand") push({ 50684 }); else if (wkey == "shield") push({ 50729 }); else if (wkey == "2h" || wkey == "twohand") push({ 50735 }); else if (wkey == "dual" || wkey == "dw" || wkey == "mhoh" || wkey == "default") push({ 50732, 50734 }); else if (wkey == "ranged" || wkey == "bow" || wkey == "gun" || wkey == "crossbow" || wkey == "thrown") push({ 50733 }); else { err = fmt::format("unknown hybrid weapon type \"{}\" (try mhoh, staff, wand, shield, 2h, ranged).", typeRaw); return false; } return true; } err = fmt::format("unknown stat \"{}\" (use str, agi, int, spi, tank, hybrid).", statRaw); return false; } // Order: head, shoulders, chest, hands, legs, bracers, belt, boots, neck, cloak, weapons…, trinkets, rings. // Item ids verified against stock AC db_world item_template (3.3.5a). static constexpr uint32 kTesterBisStr[] = { 50712, 51229, 50656, 50675, 50624, // helm, shoulders (51229), chest, hands, legs — not 51211 (that id is Ymirjar legs) 54580, 50620, 54578, 54581, 50653, // Umbrage + Coldwraith + Apocalypse's Advance + Penumbra + Shadowvault 50730, 50733, 50363, 54590, 50657, 54576, }; static constexpr uint32 kTesterBisAgi[] = { 51242, 51299, 51298, 51243, 51241, // Frost Witch (mail) + Lasherweave (leather) mix 50670, 50688, 50607, 50633, 50653, 50736, 50733, 50363, 54590, 50657, 54576, }; static constexpr uint32 kTesterBisInt[] = { 51281, 51245, 51283, 51280, 51246, // Bloodmage cloth + Frost Witch (mail) shoulders/legs 50686, 50702, 50699, 50724, 50628, 50732, 50734, 50684, 50346, 50360, 50610, 50664, }; static constexpr uint32 kTesterBisSpi[] = { 51237, 51257, 51239, 51256, 51258, // Resto Frost Witch (mail) + Crimson Acolyte (cloth) 50686, 50702, 50699, 50724, 50628, 50725, 50360, 50366, 50610, 50664, }; static constexpr uint32 kTesterBisTank[] = { 51306, 51309, 51305, 51307, 51308, // Sanctified Scourgelord (plate, DK tank profile) 50611, 50620, 50625, 50609, 50677, 50738, 50729, 50462, 50364, 54591, 50404, 50657, }; // AP main-hand + SP off-hand + mail enhancer T10 (ICC); for hybrid battlemage-style testers. static constexpr uint32 kTesterBisHybrid[] = { 51242, 51240, 51244, 51243, 51241, 54580, 50620, 54578, 54581, 50653, 50732, 50734, 50363, 50346, 50657, 50610, }; // Sanctified Ahn'Kahar Blood Hunter (277) + ICC phys offsets; ranged slot only (Windrunner's Heartseeker). static constexpr uint32 kTesterBisHunter[] = { 51286, 51288, 51289, 51285, 51287, 50670, 50688, 50607, 50633, 50653, 0, 51940, 50363, 54590, 50657, 54576, }; // ICC-era gems (stacked), enchant scrolls, belt buckle, leg armor/spellthread, Sons of Hodir shoulders, Ebon Blade / Kirin Tor helms. // Item ids from db_world item_template; tweak counts for your fork. static constexpr uint32 kGemStack = 20; static constexpr uint32 kGemStackMed = 12; static constexpr uint32 kGemStackSmall = 8; static constexpr uint32 kScrollPair = 2; static constexpr uint32 kMetaCount = 3; static constexpr uint32 kBeltBuckle = 4; static constexpr uint32 kLegKit = 4; static constexpr uint32 kAugmentPair = 2; static constexpr uint32 kScopeKit = 4; static constexpr ParagonTesterStackedGrant kTesterGemsStr[] = { { 40111, kGemStack }, { 40117, kGemStack }, { 40114, kGemStack }, { 40116, kGemStackMed }, { 40118, kGemStackMed }, { 40119, kGemStackSmall }, { 40142, kGemStackMed }, { 40143, kGemStackMed }, { 40153, kGemStackMed }, { 40162, kGemStackMed }, { 41285, kMetaCount }, { 41398, 2 }, { 44493, kScrollPair }, { 44815, kScrollPair }, { 44465, kScrollPair }, { 39006, kScrollPair }, { 39003, kScrollPair }, { 44458, kScrollPair }, { 41611, kBeltBuckle }, { 38374, kLegKit }, { 50335, kAugmentPair }, { 50367, kAugmentPair }, }; static constexpr ParagonTesterStackedGrant kTesterGemsAgi[] = { { 40112, kGemStack }, { 40117, kGemStack }, { 40114, kGemStackMed }, { 40142, kGemStackMed }, { 40152, kGemStackMed }, { 40153, kGemStackMed }, { 40155, kGemStackMed }, { 40125, kGemStackMed }, { 41398, kMetaCount }, { 41285, 2 }, { 44493, kScrollPair }, { 44815, kScrollPair }, { 44465, kScrollPair }, { 39006, kScrollPair }, { 39003, kScrollPair }, { 44458, kScrollPair }, { 38986, kScrollPair }, { 41611, kBeltBuckle }, { 38373, kLegKit }, { 50335, kAugmentPair }, { 50367, kAugmentPair }, }; static constexpr ParagonTesterStackedGrant kTesterGemsInt[] = { { 40113, kGemStack }, { 40155, kGemStackMed }, { 40153, kGemStackMed }, { 40133, kGemStackMed }, { 40119, kGemStackSmall }, { 40125, kGemStackMed }, { 41285, kMetaCount }, { 44467, kScrollPair }, { 44470, kScrollPair }, { 38979, kScrollPair }, { 39003, kScrollPair }, { 39006, kScrollPair }, { 44465, kScrollPair }, { 38973, kScrollPair }, { 41611, kBeltBuckle }, { 41602, kLegKit }, { 50338, kAugmentPair }, { 50368, kAugmentPair }, }; static constexpr ParagonTesterStackedGrant kTesterGemsSpi[] = { { 40113, kGemStackMed }, { 40133, kGemStack }, { 40120, kGemStackMed }, { 40119, kGemStackSmall }, { 40155, kGemStackMed }, { 41285, kMetaCount }, { 44470, kScrollPair }, { 38853, kScrollPair }, { 38961, kScrollPair }, { 38979, kScrollPair }, { 39006, kScrollPair }, { 44465, kScrollPair }, { 41611, kBeltBuckle }, { 41601, kLegKit }, { 50336, kAugmentPair }, { 50370, kAugmentPair }, }; static constexpr ParagonTesterStackedGrant kTesterGemsTank[] = { { 40119, kGemStack }, { 40138, kGemStackMed }, { 40115, kGemStackMed }, { 40118, kGemStackMed }, { 40143, kGemStackMed }, { 41285, 2 }, { 38945, kScrollPair }, { 44489, kScrollPair }, { 38849, kScrollPair }, { 39006, kScrollPair }, { 44465, kScrollPair }, { 41611, kBeltBuckle }, { 38373, kLegKit }, { 50337, kAugmentPair }, { 50369, kAugmentPair }, }; static constexpr ParagonTesterStackedGrant kTesterGemsHybrid[] = { { 40113, kGemStackMed }, { 40111, kGemStackMed }, { 40114, kGemStackMed }, { 40153, kGemStackMed }, { 40142, kGemStackMed }, { 40155, kGemStackMed }, { 41285, kMetaCount }, { 41398, 2 }, { 44467, kScrollPair }, { 44493, kScrollPair }, { 44815, kScrollPair }, { 44470, kScrollPair }, { 44465, kScrollPair }, { 39006, kScrollPair }, { 39003, kScrollPair }, { 41611, kBeltBuckle }, { 41602, 2 }, { 38373, 2 }, { 38374, 2 }, { 50338, kAugmentPair }, { 50335, kAugmentPair }, { 50368, 1 }, { 50367, 1 }, }; // Hunter / physical ranged: scopes (engineering attach) + hit/agi gems + physical scrolls. static constexpr ParagonTesterStackedGrant kTesterGemsRanged[] = { { 44739, kScopeKit }, { 41167, kScopeKit }, { 41146, 2 }, { 40112, kGemStack }, { 40117, kGemStack }, { 40125, kGemStack }, { 40142, kGemStackMed }, { 40152, kGemStackMed }, { 40153, kGemStackMed }, { 41398, kMetaCount }, { 41285, 2 }, { 44493, kScrollPair }, { 44815, kScrollPair }, { 44465, kScrollPair }, { 39006, kScrollPair }, { 39003, kScrollPair }, { 44458, kScrollPair }, { 38986, kScrollPair }, { 41611, kBeltBuckle }, { 38373, kLegKit }, { 50335, kAugmentPair }, { 50367, kAugmentPair }, }; class Paragon_Essence_CommandScript : public CommandScript { public: Paragon_Essence_CommandScript() : CommandScript("Paragon_Essence_CommandScript") { } ChatCommandTable GetCommands() const override { static ChatCommandTable testerBisGemsTable = { { "str", HandleTesterBisGemsStr, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "agi", HandleTesterBisGemsAgi, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "int", HandleTesterBisGemsInt, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "spi", HandleTesterBisGemsSpi, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "tank", HandleTesterBisGemsTank, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "hybrid", HandleTesterBisGemsHybrid, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "ranged", HandleTesterBisGemsRanged, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "", HandleTesterBisGemsHelp, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, }; static ChatCommandTable testerBisTable = { { "str", HandleTesterBisStr, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "agi", HandleTesterBisAgi, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "int", HandleTesterBisInt, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "spi", HandleTesterBisSpi, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "tank", HandleTesterBisTank, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "hybrid", HandleTesterBisHybrid, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "hunter", HandleTesterBisHunter, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "gems", testerBisGemsTable }, }; static ChatCommandTable testerTable = { { "bis", testerBisTable }, { "bags", HandleTesterBags, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "weapons", HandleTesterWeapons, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, { "clearinv", HandleTesterClearInv, rbac::RBAC_PERM_COMMAND_ADDITEM, Console::No }, }; 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 }, { "recalibrate", HandlePanelRecalibrate, rbac::RBAC_PERM_COMMAND_MODIFY, Console::No }, { "tester", testerTable }, }; static ChatCommandTable commandTable = { { "paragon", paragonSubTable }, }; return commandTable; } static bool HandleTesterBisKit(ChatHandler* handler, uint32 const* ids, size_t count, char const* label) { Player* target = handler->getSelectedPlayerOrSelf(); if (!target) { handler->SendErrorMessage(LANG_PLAYER_NOT_FOUND); return false; } uint32 const n = ParagonTesterGrantItemList(target, ids, count, handler); handler->PSendSysMessage("Paragon tester {} BiS: granted {} items to {}.", label, n, target->GetName()); return n > 0; } static bool HandleTesterBisStr(ChatHandler* handler) { return HandleTesterBisKit(handler, kTesterBisStr, sizeof(kTesterBisStr) / sizeof(kTesterBisStr[0]), "STR"); } static bool HandleTesterBisAgi(ChatHandler* handler) { return HandleTesterBisKit(handler, kTesterBisAgi, sizeof(kTesterBisAgi) / sizeof(kTesterBisAgi[0]), "AGI"); } static bool HandleTesterBisInt(ChatHandler* handler) { return HandleTesterBisKit(handler, kTesterBisInt, sizeof(kTesterBisInt) / sizeof(kTesterBisInt[0]), "INT"); } static bool HandleTesterBisSpi(ChatHandler* handler) { return HandleTesterBisKit(handler, kTesterBisSpi, sizeof(kTesterBisSpi) / sizeof(kTesterBisSpi[0]), "SPI"); } static bool HandleTesterBisTank(ChatHandler* handler) { return HandleTesterBisKit(handler, kTesterBisTank, sizeof(kTesterBisTank) / sizeof(kTesterBisTank[0]), "tank"); } static bool HandleTesterBisHybrid(ChatHandler* handler) { return HandleTesterBisKit(handler, kTesterBisHybrid, sizeof(kTesterBisHybrid) / sizeof(kTesterBisHybrid[0]), "hybrid"); } static bool HandleTesterBisHunter(ChatHandler* handler) { return HandleTesterBisKit(handler, kTesterBisHunter, sizeof(kTesterBisHunter) / sizeof(kTesterBisHunter[0]), "hunter (ranged)"); } static bool HandleTesterBisGemsHelp(ChatHandler* handler) { handler->SendSysMessage( "Paragon tester gems: .paragon tester bis gems \n" " category: str | agi | int | spi | tank | hybrid | ranged\n" " Grants stacked ICC-era gems, enchant scrolls, Eternal Belt Buckle, leg armor/spellthread, " "Sons of Hodir shoulder inscriptions, and helm arcanums. " "ranged adds engineering scopes (Diamond-cut Refractor, Heartseeker, Sun) plus AGI/hit gems."); return false; } static bool HandleTesterBisGemsKit(ChatHandler* handler, ParagonTesterStackedGrant const* grants, size_t count, char const* label) { Player* target = handler->getSelectedPlayerOrSelf(); if (!target) { handler->SendErrorMessage(LANG_PLAYER_NOT_FOUND); return false; } uint32 const n = ParagonTesterGrantStackedItemList(target, grants, count, handler); handler->PSendSysMessage("Paragon tester {} gems/enchants: granted {} item pieces (stacked lines) to {}.", label, n, target->GetName()); return n > 0; } static bool HandleTesterBisGemsStr(ChatHandler* handler) { return HandleTesterBisGemsKit(handler, kTesterGemsStr, sizeof(kTesterGemsStr) / sizeof(kTesterGemsStr[0]), "STR"); } static bool HandleTesterBisGemsAgi(ChatHandler* handler) { return HandleTesterBisGemsKit(handler, kTesterGemsAgi, sizeof(kTesterGemsAgi) / sizeof(kTesterGemsAgi[0]), "AGI"); } static bool HandleTesterBisGemsInt(ChatHandler* handler) { return HandleTesterBisGemsKit(handler, kTesterGemsInt, sizeof(kTesterGemsInt) / sizeof(kTesterGemsInt[0]), "INT"); } static bool HandleTesterBisGemsSpi(ChatHandler* handler) { return HandleTesterBisGemsKit(handler, kTesterGemsSpi, sizeof(kTesterGemsSpi) / sizeof(kTesterGemsSpi[0]), "SPI"); } static bool HandleTesterBisGemsTank(ChatHandler* handler) { return HandleTesterBisGemsKit(handler, kTesterGemsTank, sizeof(kTesterGemsTank) / sizeof(kTesterGemsTank[0]), "tank"); } static bool HandleTesterBisGemsHybrid(ChatHandler* handler) { return HandleTesterBisGemsKit(handler, kTesterGemsHybrid, sizeof(kTesterGemsHybrid) / sizeof(kTesterGemsHybrid[0]), "hybrid"); } static bool HandleTesterBisGemsRanged(ChatHandler* handler) { return HandleTesterBisGemsKit(handler, kTesterGemsRanged, sizeof(kTesterGemsRanged) / sizeof(kTesterGemsRanged[0]), "RANGED"); } static bool HandleTesterBags(ChatHandler* handler) { Player* target = handler->getSelectedPlayerOrSelf(); if (!target) { handler->SendErrorMessage(LANG_PLAYER_NOT_FOUND); return false; } uint32 const bagId = ParagonTesterSelectLargestUsableBagItemId(target); if (!bagId) { handler->SendSysMessage("Paragon tester bags: no bag template this character can use (check item ids)."); return false; } uint32 granted = 0; uint32 equipped = 0; for (int i = 0; i < 4; ++i) { ItemPosCountVec dest; if (target->CanStoreNewItem(NULL_BAG, NULL_SLOT, dest, bagId, 1) != EQUIP_ERR_OK || dest.empty()) { handler->PSendSysMessage("Paragon tester bags: could only add {} bag(s); inventory full?", granted); break; } if (Item* item = target->StoreNewItem(dest, bagId, true)) { item->SetBinding(false); ++granted; if (ParagonTesterTryEquipBagToFirstEmptySlot(target, item)) ++equipped; } } ItemTemplate const* proto = sObjectMgr->GetItemTemplate(bagId); handler->PSendSysMessage("Paragon tester bags: added {} x {} ({}); auto-equipped {} to bag bar for {}.", granted, bagId, proto ? proto->Name1.c_str() : "?", equipped, target->GetName()); return granted > 0; } static bool HandleTesterWeapons(ChatHandler* handler, Tail tail) { std::string_view sv = tail; while (!sv.empty() && sv.front() == ' ') sv.remove_prefix(1); if (sv.empty()) { handler->SendSysMessage( "Paragon tester weapons: .paragon tester weapons \n" " stat: str | agi | int | spi | tank | hybrid (aliases: strength, agility, intellect, spirit, apsp)\n" " type: depends on stat — e.g. str: 2h sword, 2h axe, 2h mace, 1h sword, 1h axe, 1h mace (or 2h/sword), dual, ranged | " "agi: … bow (hunter bow) or ranged/gun/crossbow (Fal'inrush) | int: staff, wand, mhoh, shield, ranged | " "spi/hybrid: … ranged"); return false; } std::size_t const sp = sv.find(' '); if (sp == std::string_view::npos) { handler->SendSysMessage("Paragon tester weapons: need both and (see help with no args)."); return false; } std::string stat(sv.substr(0, sp)); sv.remove_prefix(sp + 1); while (!sv.empty() && sv.front() == ' ') sv.remove_prefix(1); if (sv.empty()) { handler->SendSysMessage("Paragon tester weapons: missing after stat."); return false; } std::string weaponType(sv.begin(), sv.end()); Player* target = handler->getSelectedPlayerOrSelf(); if (!target) { handler->SendErrorMessage(LANG_PLAYER_NOT_FOUND); return false; } std::vector ids; std::string err; if (!ParagonTesterResolveWeaponKit(stat, weaponType, ids, err)) { handler->PSendSysMessage("Paragon tester weapons: {}", err); return false; } uint32 const n = ParagonTesterGrantItemList(target, ids.data(), ids.size(), handler); handler->PSendSysMessage("Paragon tester weapons [{} / {}]: granted {} item(s) to {}.", stat, weaponType, n, target->GetName()); return n > 0; } static bool HandleTesterClearInv(ChatHandler* handler) { Player* target = handler->getSelectedPlayerOrSelf(); if (!target) { handler->SendErrorMessage(LANG_PLAYER_NOT_FOUND); return false; } ParagonTesterClearNonEquipmentInventory(target); handler->PSendSysMessage("Paragon tester clearinv: removed backpack + bag contents (equipment untouched) for {}.", target->GetName()); return true; } // 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(); if (!pl || pl->getClass() != CLASS_PARAGON) { handler->SendErrorMessage("Paragon currency is only tracked for Paragon characters.", false); return false; } if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) { handler->SendErrorMessage("Paragon.Currency.Enabled is off.", false); return false; } LoadCurrencyFromDb(pl); handler->PSendSysMessage("Ability Essence: {} | Talent Essence: {}", GetAE(pl), GetTE(pl)); return true; } // .paragon runes — diagnostic dump of rune state (server-side truth) and // forced ResyncRunes packet to the client. Helps diagnose visual-vs-server // desync where the rune pip animates to ready but the spell cast still // returns SPELL_FAILED_NO_POWER because GetRuneCooldown(i) > 0 server-side. static bool HandleRunes(ChatHandler* handler) { Player* pl = handler->GetPlayer(); if (!pl || pl->getClass() != CLASS_PARAGON) { handler->SendErrorMessage("Only Paragon characters have a rune block to inspect.", false); return false; } for (uint8 i = 0; i < MAX_RUNES; ++i) { handler->PSendSysMessage("rune[{}] base={} cur={} cd={}ms grace={}ms", i, uint32(pl->GetBaseRune(i)), uint32(pl->GetCurrentRune(i)), uint32(pl->GetRuneCooldown(i)), uint32(pl->GetGracePeriod(i))); } for (uint8 t = 0; t < NUM_RUNE_TYPES; ++t) { handler->PSendSysMessage("regen[{}] = {} (1/cooldown_ms)", uint32(t), pl->GetFloatValue(PLAYER_RUNE_REGEN_1 + t)); } // Force a fresh client snapshot so we can see if visual catches up. pl->ResyncRunes(MAX_RUNES); handler->PSendSysMessage("|cff00ff00ResyncRunes packet sent.|r"); return true; } static bool HandleLearn(ChatHandler* handler, SpellInfo const* spell) { Player* pl = handler->GetPlayer(); if (!pl || pl->getClass() != CLASS_PARAGON) { handler->SendErrorMessage("Only Paragon characters use AE for this command.", false); return false; } if (!sConfigMgr->GetOption("Paragon.Currency.Enabled", true)) { handler->SendErrorMessage("Paragon.Currency.Enabled is off.", false); return false; } if (!spell) return false; LoadCurrencyFromDb(pl); uint32 const cost = LookupSpellAECost(spell->Id); if (GetAE(pl) < cost) { handler->PSendSysMessage("Not enough Ability Essence (need {}, have {}).", cost, GetAE(pl)); return false; } if (!SpellMgr::IsSpellValid(spell)) { handler->SendErrorMessage("Invalid spell.", false); return false; } if (pl->HasSpell(spell->Id)) { handler->SendErrorMessage("You already know this spell.", false); return false; } if (!TrySpendAE(pl, cost)) return false; PanelLearnSpellChain(pl, spell->Id); SaveCurrencyToDb(pl); PushCurrency(pl); handler->PSendSysMessage("Learned spell {} ({} AE spent, {} AE remaining).", spell->Id, cost, GetAE(pl)); return true; } // .paragon hat — diagnostic for Honor Among Thieves on a Paragon. // Prints HasTalent, HasAura, HasSpell, EquippedItemClass, ProcEvent // hookup, and re-applies the talent's passive aura if missing. // // HAT in 3.3.5: rank ids 51698 (R1) / 51700 (R2) / 51701 (R3). // Effect 0 is APPLY_AREA_AURA_PARTY -> SPELL_AURA_PROC_TRIGGER_SPELL // (52916) which targets the rogue (the original caster) when ANY // party member crits, and 52916 in turn casts 51699 to grant +1 CP. static bool HandleHat(ChatHandler* handler) { Player* pl = handler->GetPlayer(); if (!pl) return false; constexpr std::array kHatRanks = { 51698u, 51700u, 51701u }; constexpr uint32 kHatProc = 52916u; constexpr uint32 kHatTrigger = 51699u; handler->PSendSysMessage("|cff00ffff[paragon hat]|r class={} lvl={} group={}", uint32(pl->getClass()), pl->GetLevel(), pl->GetGroup() ? "yes" : "no"); uint32 ownedRank = 0; uint32 ownedRankId = 0; for (uint32 i = 0; i < kHatRanks.size(); ++i) { uint32 const id = kHatRanks[i]; bool hasTalent = pl->HasTalent(id, pl->GetActiveSpec()); bool hasAura = pl->HasAura(id); SpellInfo const* info = sSpellMgr->GetSpellInfo(id); handler->PSendSysMessage( " R{} ({}): HasTalent={} HasAura={} HasSpell={} info={}", i + 1, id, hasTalent ? "Y" : "n", hasAura ? "Y" : "n", pl->HasSpell(id) ? "Y" : "n", info ? "ok" : "MISSING"); if (hasTalent && !ownedRank) { ownedRank = i + 1; ownedRankId = id; } } SpellInfo const* procInfo = sSpellMgr->GetSpellInfo(kHatProc); SpellInfo const* trigInfo = sSpellMgr->GetSpellInfo(kHatTrigger); handler->PSendSysMessage(" proc52916 info={} trig51699 info={}", procInfo ? "ok" : "MISSING", trigInfo ? "ok" : "MISSING"); if (!ownedRank) { handler->PSendSysMessage("|cffff8000No HAT rank in active spec.|r"); return true; } SpellInfo const* rank = sSpellMgr->GetSpellInfo(ownedRankId); if (!rank) return true; // Inspect the talent rank effects so we can see exactly what AC // thinks should be applied. for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i) { SpellEffectInfo const& eff = rank->Effects[i]; if (eff.Effect == 0) continue; handler->PSendSysMessage( " eff[{}] effect={} aura={} targetA={} trigger={} basePts={}", i, uint32(eff.Effect), uint32(eff.ApplyAuraName), uint32(eff.TargetA.GetTarget()), eff.TriggerSpell, eff.BasePoints); } // Force-apply the rank passive aura if it isn't already there. We // use Aura::TryRefreshStackOrCreate so we don't go through CheckCast // (which can fail silently for various reasons). if (!pl->HasAura(ownedRankId)) { Aura* a = Aura::TryRefreshStackOrCreate(rank, MAX_EFFECT_MASK, pl, pl); handler->PSendSysMessage(" force-apply rank aura -> {}", a ? "OK" : "FAILED"); } else { handler->PSendSysMessage(" rank aura already on player."); } // Dump the live SpellProcEntry the engine will use when checking // "should this aura proc". Confirms the ProcFlags fallback from // SpellInfo, and the SpellPhase / Hit gates. if (SpellProcEntry const* pe = sSpellMgr->GetSpellProcEntry(ownedRankId)) { handler->PSendSysMessage( " ProcEntry({}): ProcFlags=0x{:X} SpellTypeMask=0x{:X} SpellPhaseMask=0x{:X} HitMask=0x{:X} chance={} cd={}ms", ownedRankId, pe->ProcFlags, pe->SpellTypeMask, pe->SpellPhaseMask, pe->HitMask, pe->Chance, uint32(pe->Cooldown.count())); } else { handler->PSendSysMessage( " ProcEntry({}): NONE (proc system will not fire this aura)", ownedRankId); } // Walk the player's applied auras and report whether the engine // currently sees an effect-mask flag turned on for the rank's // PROC_TRIGGER_SPELL effect. If the aura is up but the flag is // off, the engine won't proc it. if (AuraApplication* aa = pl->GetAuraApplication(ownedRankId)) { handler->PSendSysMessage( " AuraApp: effectMask=0x{:X} effectsToApply=0x{:X} flags=0x{:X}", uint32(aa->GetEffectMask()), uint32(aa->GetEffectsToApply()), uint32(aa->GetFlags())); } else { handler->PSendSysMessage(" AuraApp: NONE on player"); } // Force-fire the trigger chain (52916 -> dummy -> 51699 -> +CP) on // the player's current target. If this works, the proc machinery // is at fault. If THIS fails, the trigger spells are at fault. if (Unit* tgt = pl->GetSelectedUnit()) { uint32 const cpBefore = pl->GetComboPoints(tgt); // Mimic spell_rog_honor_among_thieves::HandleProc: cast 52916 // FROM tgt -> tgt with the rogue/Paragon as original caster. tgt->CastSpell(tgt, kHatProc, true, nullptr, nullptr, pl->GetGUID()); uint32 const cpAfter = pl->GetComboPoints(tgt); handler->PSendSysMessage( " force-fire 52916 on {}: CP {} -> {}", tgt->GetName(), cpBefore, cpAfter); } else { handler->PSendSysMessage( " no target selected: pick a unit and run again to force-fire 52916."); } return true; } }; } // namespace (anonymous) void AddSC_paragon_essence() { new Paragon_Essence_PlayerScript(); new Paragon_Essence_CommandScript(); }