7de018f7eb
Server-side Character Advancement now stores named, icon-tagged build
recipes (panel-purchased spells + per-spec talent ranks) and atomically
swaps between them by snapshotting the active build, refunding AE/TE
through HandleParagonReset{Talents,Abilities}, and re-spending on the
target recipe. Hunter pets attached to a build are parked to
PET_SAVE_NOT_IN_SLOT (mirroring HandleStableSwapPet) so name, talents,
and exp survive swaps; non-hunter pets (warlock demon, DK ghoul, mage
water elemental) are NOT parked because the engine resummons them from
a fresh template each cast.
New PARAA verbs: Q BUILDS / C BUILD NEW / C BUILD EDIT / C BUILD
DELETE / C BUILD FAVORITE / C BUILD LOAD. The catalog is pushed on
login and after every mutation as a single addon message.
Schema (mod-paragon migration 2026_05_10_03.sql):
- character_paragon_builds (build_id PK, guid, name, icon, is_favorite,
pet_number, created_at)
- character_paragon_build_spells (build_id, spell_id)
- character_paragon_build_talents (build_id, spec, talent_id, rank)
- character_paragon_active_build (guid PK, build_id)
The talent recipe table is spec-keyed so a build remembers tank/dps
dual-spec layouts independently. Swaps are blocked while in combat.
Co-authored-by: Cursor <cursoragent@cursor.com>
2916 lines
107 KiB
C++
2916 lines
107 KiB
C++
/*
|
|
* 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 "CharacterDatabase.h"
|
|
#include "Chat.h"
|
|
#include "CommandScript.h"
|
|
#include "Config.h"
|
|
#include "Pet.h"
|
|
#include "Player.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 "Log.h"
|
|
#include "DBCEnums.h"
|
|
#include "DBCStores.h"
|
|
|
|
#include <fmt/format.h>
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cctype>
|
|
#include <cstdlib>
|
|
#include <string_view>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
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<uint32, ParagonCurrencyData> gParagonCurrencyCache;
|
|
|
|
uint32 ComputeStartingAE(uint8 level)
|
|
{
|
|
uint32 base = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.Start", 9);
|
|
uint32 per = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.PerLevel", 1);
|
|
uint32 minLv = sConfigMgr->GetOption<uint32>("Paragon.Currency.GrantLevelMin", 10);
|
|
if (level >= minLv)
|
|
base += uint32(level - minLv + 1) * per;
|
|
return base;
|
|
}
|
|
|
|
uint32 ComputeStartingTE(uint8 level)
|
|
{
|
|
uint32 base = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.Start", 0);
|
|
uint32 per = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.PerLevel", 1);
|
|
uint32 minLv = sConfigMgr->GetOption<uint32>("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<bool>("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<uint32>();
|
|
d.talentEssence = f[1].Get<uint32>();
|
|
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<bool>("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 <ae> <te>" 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<bool>("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<bool>("Paragon.Currency.Enabled", true))
|
|
return;
|
|
|
|
uint32 const minLv = sConfigMgr->GetOption<uint32>("Paragon.Currency.GrantLevelMin", 10);
|
|
uint32 const aePer = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.PerLevel", 1);
|
|
uint32 const tePer = sConfigMgr->GetOption<uint32>("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<uint32>("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<uint32>(r->Fetch()[0].Get<uint32>(), 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);
|
|
}
|
|
|
|
// Forward declaration: reset handlers below need PushSnapshot, which itself
|
|
// is defined later (after PushSpellSnapshot / PushTalentSnapshot).
|
|
void PushSnapshot(Player* pl);
|
|
|
|
// 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<uint32>& 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<uint32> 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<uint32>& 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<uint32>(), 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>();
|
|
uint32 const rank = f[1].Get<uint32>();
|
|
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<uint32>(rank, MAX_TALENT_RANK);
|
|
for (uint32 i = 0; i < cap; ++i)
|
|
if (te->RankID[i])
|
|
allowed.insert(te->RankID[i]);
|
|
} 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<uint32>());
|
|
} 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<uint32> allowed;
|
|
BuildPanelOwnedSpellsAllowlist(lowGuid, allowed);
|
|
|
|
QueryResult r = CharacterDatabase.Query(
|
|
"SELECT revoked_spell_id FROM character_paragon_panel_spell_revoked WHERE guid = {}",
|
|
lowGuid);
|
|
if (!r)
|
|
return;
|
|
|
|
uint32 removed = 0;
|
|
std::vector<uint32> stale; // allowlisted -> drop the row
|
|
do
|
|
{
|
|
uint32 const sid = r->Fetch()[0].Get<uint32>();
|
|
if (allowed.count(sid))
|
|
{
|
|
stale.push_back(sid);
|
|
continue;
|
|
}
|
|
if (pl->HasSpell(sid))
|
|
{
|
|
pl->removeSpell(sid, SPEC_MASK_ALL, false);
|
|
++removed;
|
|
}
|
|
} while (r->NextRow());
|
|
|
|
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());
|
|
}
|
|
|
|
[[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<uint32> SkillLinesLinkedToSpell(uint32 spellId)
|
|
{
|
|
std::unordered_set<uint32> 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;
|
|
}
|
|
|
|
// 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.
|
|
[[nodiscard]] static bool IsSpellSkillLineCascadeDependent(uint32 anchorSpellId, uint32 depSpellId)
|
|
{
|
|
std::unordered_set<uint32> 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;
|
|
}
|
|
|
|
// Older builds recorded skill-line cascade passives (Forceful Deflection,
|
|
// Runic Focus, ...) as `panel_spell_children`. Strip those rows and
|
|
// revoke the spell so the login sweep + reset logic match current policy.
|
|
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>();
|
|
uint32 const child = r->Fetch()[1].Get<uint32>();
|
|
if (!IsSpellSkillLineCascadeDependent(parent, child))
|
|
continue;
|
|
|
|
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<bool>("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<uint32> 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<uint32> 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.
|
|
std::vector<uint32> 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;
|
|
|
|
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<uint32> SnapshotKnownSpells(Player* pl)
|
|
{
|
|
std::unordered_set<uint32> 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<uint32>& 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;
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
// * 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<uint32> chainIds;
|
|
CollectSpellChainIds(trackId, chainIds);
|
|
|
|
bool const diag = sConfigMgr->GetOption<bool>("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))
|
|
{
|
|
std::unordered_set<uint32> before = SnapshotKnownSpells(pl);
|
|
if (diag)
|
|
LOG_INFO("module",
|
|
"[paragon-diag] pre-learn rank={} spellMapSize={}",
|
|
cur, before.size());
|
|
|
|
pl->learnSpell(cur, false);
|
|
std::unordered_set<uint32> 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. Pure spell-effect passives stick (children); skill-
|
|
// line cascade passives and actives get revoked.
|
|
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;
|
|
|
|
if (dep->IsPassive())
|
|
{
|
|
if (IsSpellSkillLineCascadeDependent(cur, spellId))
|
|
{
|
|
pl->removeSpell(spellId, SPEC_MASK_ALL, false);
|
|
DbInsertPanelSpellRevoked(lowGuid, trackId, spellId);
|
|
if (diag)
|
|
LOG_INFO("module",
|
|
"[paragon-diag] +{} (skill-line passive dep, REVOKED, parent={})",
|
|
spellId, trackId);
|
|
}
|
|
else
|
|
{
|
|
DbInsertPanelSpellChild(lowGuid, trackId, spellId);
|
|
if (diag)
|
|
LOG_INFO("module",
|
|
"[paragon-diag] +{} (passive dep, kept as child of {})",
|
|
spellId, trackId);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
pl->removeSpell(spellId, SPEC_MASK_ALL, false);
|
|
// Persist so we can re-revoke after the next login --
|
|
// AC's _LoadSkills -> learnSkillRewardedSpells will
|
|
// re-grant skill-rewarded actives (Blood Presence,
|
|
// Death Coil, Death Grip, ...) every time the player
|
|
// logs in.
|
|
DbInsertPanelSpellRevoked(lowGuid, trackId, spellId);
|
|
if (diag)
|
|
LOG_INFO("module",
|
|
"[paragon-diag] +{} (active dep, REVOKED, parent={})",
|
|
spellId, trackId);
|
|
}
|
|
}
|
|
}
|
|
|
|
uint32 const next = sSpellMgr->GetNextSpellInChain(cur);
|
|
if (!next || next == cur)
|
|
break;
|
|
cur = next;
|
|
}
|
|
|
|
DbInsertPanelSpell(lowGuid, 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);
|
|
}
|
|
|
|
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 spec = 0; spec < pl->GetSpecsCount(); ++spec)
|
|
for (uint8 r = 0; r < MAX_TALENT_RANK; ++r)
|
|
if (te->RankID[r] && pl->HasTalent(te->RankID[r], spec))
|
|
best = std::max(best, uint32(r + 1));
|
|
|
|
return best;
|
|
}
|
|
|
|
// ---- Commit handler --------------------------------------------------------
|
|
//
|
|
// Wire format from Net.lua Net:Commit:
|
|
// "C COMMIT s:<id1>,<id2>,...<empty allowed> t:<talentId>:<delta>,..."
|
|
// Both sub-lists are optional but the leading tags are not. Examples:
|
|
// "C COMMIT s:5176,8921 t:"
|
|
// "C COMMIT s: t:1234:1,5678:2"
|
|
// We parse leniently and abort on any structural error.
|
|
//
|
|
// On success we push:
|
|
// "R OK <ae> <te>" followed by R SPELLS / R TALENTS snapshots.
|
|
// On failure (insufficient AE/TE, unknown spell, etc.):
|
|
// "R ERR <reason>" -- the panel restores its pending state.
|
|
|
|
constexpr size_t kCommitMaxItems = 64;
|
|
|
|
std::vector<uint32> ParseCsvUInt(std::string_view csv)
|
|
{
|
|
std::vector<uint32> 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).
|
|
// Anything else learned/unlearned during the window -- the SkillLineAbility
|
|
// cascade and our diff-revoke cleanup -- is silenced.
|
|
void SendSilenceOpenForCommit(Player* pl,
|
|
std::vector<std::pair<uint32, uint32>> const& spellsAndCosts,
|
|
std::vector<std::pair<uint32, uint32>> const& talentDeltas)
|
|
{
|
|
if (!pl)
|
|
return;
|
|
|
|
std::unordered_set<uint32> allow;
|
|
for (auto const& kv : spellsAndCosts)
|
|
CollectSpellChainIds(kv.first, allow);
|
|
|
|
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]);
|
|
}
|
|
|
|
// 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");
|
|
}
|
|
|
|
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 = rest.substr(tPos + 3);
|
|
|
|
std::vector<uint32> spellIds = ParseCsvUInt(spellsCsv);
|
|
|
|
// Talents are "id:delta,id:delta,...". Parse into vector of pairs.
|
|
std::vector<std::pair<uint32, uint32>> 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;
|
|
}
|
|
}
|
|
|
|
if (spellIds.size() + talentDeltas.size() > kCommitMaxItems)
|
|
{
|
|
*err = "commit exceeds size cap";
|
|
return false;
|
|
}
|
|
|
|
// Pre-validate spells: must be valid SpellInfo, not already learned,
|
|
// and afford their combined AE cost.
|
|
uint32 totalAE = 0;
|
|
std::vector<std::pair<uint32, uint32>> 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 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<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
|
|
uint32 const aePerRank = sConfigMgr->GetOption<uint32>("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) < talentsTE)
|
|
{
|
|
*err = fmt::format("not enough TE (need {} have {})", talentsTE, GetTE(pl));
|
|
return false;
|
|
}
|
|
if (GetAE(pl) < (totalAE + talentsAE))
|
|
{
|
|
*err = fmt::format("not enough AE (need {} have {})", totalAE + talentsAE, GetAE(pl));
|
|
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. Closed below at the end of the commit.
|
|
SendSilenceOpenForCommit(pl, spellsAndCosts, talentDeltas);
|
|
|
|
// 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<uint32>(currentRank + delta, MAX_TALENT_RANK);
|
|
for (uint32 r = currentRank; r < targetRank; ++r)
|
|
pl->LearnTalent(tid, r, /*command=*/true);
|
|
}
|
|
|
|
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 <id>,<id>,..." (multiple messages OK; client appends)
|
|
// "R SPELLS_END" (sentinel: rebuild list now)
|
|
// "R TALENTS <tid>:<rank>,..." (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<uint32>();
|
|
if (!pl->HasSpell(sid))
|
|
{
|
|
CharacterDatabase.DirectExecute(
|
|
"DELETE FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {}",
|
|
lowGuid, sid);
|
|
continue;
|
|
}
|
|
|
|
SpellInfo const* info = sSpellMgr->GetSpellInfo(sid);
|
|
if (info && info->HasAttribute(SPELL_ATTR0_DO_NOT_DISPLAY))
|
|
continue;
|
|
|
|
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>();
|
|
uint32 const dbRank = f[1].Get<uint32>();
|
|
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<bool>("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<uint32>();
|
|
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<uint32>();
|
|
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);
|
|
} 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<uint32>();
|
|
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;
|
|
}
|
|
|
|
SaveCurrencyToDb(pl);
|
|
PushCurrency(pl);
|
|
PushSnapshot(pl);
|
|
LOG_INFO("module", "Paragon panel: {} reset abilities (+{} AE refund)", pl->GetName(), refundAE);
|
|
return true;
|
|
}
|
|
|
|
bool HandleParagonResetTalents(Player* pl, std::string* err)
|
|
{
|
|
if (!pl || pl->getClass() != CLASS_PARAGON)
|
|
{
|
|
*err = "not a Paragon";
|
|
return false;
|
|
}
|
|
|
|
if (!sConfigMgr->GetOption<bool>("Paragon.Currency.Enabled", true))
|
|
{
|
|
*err = "Paragon currency disabled";
|
|
return false;
|
|
}
|
|
|
|
LoadCurrencyFromDb(pl);
|
|
uint32 const lowGuid = pl->GetGUID().GetCounter();
|
|
uint32 refundAE = 0;
|
|
uint32 refundTE = 0;
|
|
uint32 const tePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
|
|
uint32 const aePerRank = sConfigMgr->GetOption<uint32>("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>();
|
|
uint32 const dbRank = f[1].Get<uint32>();
|
|
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;
|
|
|
|
SaveCurrencyToDb(pl);
|
|
PushCurrency(pl);
|
|
PushSnapshot(pl);
|
|
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 <name>\t<icon> -- create empty build
|
|
// C BUILD EDIT <id>\t<name>\t<icon> -- rename / re-icon
|
|
// C BUILD DELETE <id> -- delete + drop parked pet
|
|
// C BUILD FAVORITE <id> <0|1> -- toggle favorite flag
|
|
// C BUILD LOAD <id> -- swap to this build
|
|
//
|
|
// Server replies push `R BUILDS` after every mutation. Format:
|
|
//
|
|
// R BUILDS active=<id|->\t<id>:<fav>:<haspet>:<name>:<icon>; ...
|
|
//
|
|
// 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\\<icon>".
|
|
// ----------------------------------------------------------------------------
|
|
|
|
constexpr char const* kDefaultBuildIcon = "INV_Misc_QuestionMark";
|
|
constexpr std::size_t kBuildNameMaxLen = 32;
|
|
constexpr std::size_t kBuildIconMaxLen = 64;
|
|
|
|
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<uint32>();
|
|
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;
|
|
}
|
|
|
|
void PushBuildCatalog(Player* pl)
|
|
{
|
|
if (!pl || pl->getClass() != CLASS_PARAGON)
|
|
return;
|
|
|
|
uint32 const lowGuid = pl->GetGUID().GetCounter();
|
|
uint32 const active = GetActiveBuildId(lowGuid);
|
|
|
|
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, is_favorite, pet_number, name, icon "
|
|
"FROM character_paragon_builds WHERE guid = {} "
|
|
"ORDER BY is_favorite DESC, build_id ASC", lowGuid))
|
|
{
|
|
bool first = true;
|
|
do
|
|
{
|
|
Field const* f = r->Fetch();
|
|
uint32 id = f[0].Get<uint32>();
|
|
uint8 fav = f[1].Get<uint8>();
|
|
bool haspet = !f[2].IsNull() && f[2].Get<uint32>() != 0;
|
|
std::string name = f[3].Get<std::string>();
|
|
std::string icon = f[4].Get<std::string>();
|
|
if (!first)
|
|
body += ';';
|
|
first = false;
|
|
body += fmt::format("{}:{}:{}:{}:{}", id,
|
|
static_cast<unsigned>(fav),
|
|
haspet ? 1 : 0,
|
|
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<uint32>() >= 64)
|
|
{
|
|
*err = "build limit reached (64)";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
CharacterDatabase.DirectExecute(
|
|
"INSERT INTO character_paragon_builds (guid, name, icon) VALUES ({}, '{}', '{}')",
|
|
lowGuid, name, icon);
|
|
|
|
PushBuildCatalog(pl);
|
|
LOG_INFO("module", "Paragon build: {} created build '{}'", pl->GetName(), name);
|
|
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 "<id>\t<name>\t<icon>"
|
|
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<uint32>(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<uint32>(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<uint32>();
|
|
|
|
// If the build being deleted is currently active, clear the
|
|
// active pointer first so the player ends up in the "no active
|
|
// build" state. Their currently-learned spells/talents are
|
|
// preserved (the client warns them about this -- they keep the
|
|
// loadout but lose the named slot and the parked pet).
|
|
uint32 const active = GetActiveBuildId(lowGuid);
|
|
if (active == buildId)
|
|
SetActiveBuildId(lowGuid, 0);
|
|
|
|
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;
|
|
}
|
|
|
|
bool HandleBuildFavorite(Player* pl, std::string const& payload, std::string* err)
|
|
{
|
|
if (!pl || pl->getClass() != CLASS_PARAGON)
|
|
{
|
|
*err = "not a Paragon";
|
|
return false;
|
|
}
|
|
auto sp = payload.find(' ');
|
|
if (sp == std::string::npos)
|
|
{
|
|
*err = "BUILD FAVORITE malformed";
|
|
return false;
|
|
}
|
|
uint32 buildId = static_cast<uint32>(std::strtoul(payload.substr(0, sp).c_str(), nullptr, 10));
|
|
int flag = std::atoi(payload.substr(sp + 1).c_str());
|
|
if (!buildId)
|
|
{
|
|
*err = "BUILD FAVORITE bad id";
|
|
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 is_favorite = {} WHERE build_id = {}",
|
|
flag ? 1 : 0, buildId);
|
|
|
|
PushBuildCatalog(pl);
|
|
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<uint32>();
|
|
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<uint32> panelTalents;
|
|
if (QueryResult pt = CharacterDatabase.Query(
|
|
"SELECT talent_id FROM character_paragon_panel_talents WHERE guid = {}", lowGuid))
|
|
{
|
|
do { panelTalents.insert(pt->Fetch()[0].Get<uint32>()); } 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<uint8>(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<uint32>();
|
|
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<int>(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<int>(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<uint32>(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);
|
|
|
|
// No-op swap: target is already active. Refresh catalog so client
|
|
// UI re-syncs and bail.
|
|
if (activeId == targetId)
|
|
{
|
|
PushBuildCatalog(pl);
|
|
return true;
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// Phase 1: snapshot + park the current build's state, if any.
|
|
// -------------------------------------------------------------
|
|
if (activeId)
|
|
{
|
|
SnapshotBuildFromCurrent(pl, activeId);
|
|
ParkActivePetForBuild(pl, activeId);
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// Phase 2: reset all panel-bought spells/talents (refunds AE/TE
|
|
// through the existing reset path).
|
|
// -------------------------------------------------------------
|
|
std::string sub;
|
|
if (!HandleParagonResetTalents(pl, &sub))
|
|
{
|
|
*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<uint32> 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<uint32>()); }
|
|
while (sp->NextRow());
|
|
}
|
|
|
|
// Recipe talents: collected up front so we can pre-flight AE+TE.
|
|
uint32 const tePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
|
|
uint32 const aePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.TalentLearnCost", 1);
|
|
|
|
struct RecipeTalent { uint8 spec; uint32 tid; uint8 rank; };
|
|
std::vector<RecipeTalent> 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<uint8>(), f[1].Get<uint32>(), f[2].Get<uint8>() };
|
|
TalentEntry const* te = sTalentStore.LookupEntry(rt.tid);
|
|
if (!te || !rt.rank)
|
|
continue;
|
|
recipeTalents.push_back(rt);
|
|
talentTeCost += static_cast<uint32>(rt.rank) * tePerRank;
|
|
if (te->addToSpellBook)
|
|
talentAeCost += static_cast<uint32>(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);
|
|
LOG_INFO("module", "Paragon build: {} loaded build {}", pl->GetName(), 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);
|
|
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<bool>("Paragon.Currency.Enabled", true))
|
|
{
|
|
std::vector<std::pair<uint32, uint32>> emptySpells;
|
|
std::vector<std::pair<uint32, uint32>> emptyTalents;
|
|
SendSilenceOpenForCommit(player, emptySpells, emptyTalents);
|
|
|
|
// Step 1: re-revoke spells we explicitly recorded last commit
|
|
// (cheap, exact: just walks the persisted revoke table).
|
|
RevokeBlockedSpellsForPlayer(player);
|
|
// Step 2: scoped sweep across SkillLines we activated via panel
|
|
// purchases. Catches cascade-granted active spells that the
|
|
// commit-time diff missed -- e.g., legacy characters whose
|
|
// first commit predates the per-purchase revoke list, or any
|
|
// cascade re-fire on relog. Only walks our own SkillLines, so
|
|
// racials / weapon skills / Defense rewards are never touched.
|
|
RevokeUnwantedCascadeSpellsForPlayer(player);
|
|
// Step 3: Blood Elf only -- strip rogue/DK Arcane Torrent clones
|
|
// (skill-line overlay taught all three; see 2026_05_10_03.sql).
|
|
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<bool>("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);
|
|
|
|
GrantLevelUpEssence(player, oldLevel, player->GetLevel());
|
|
|
|
// Persist the grant immediately so a crash before next save doesn't
|
|
// lose freshly-awarded essence. Cheap (single REPLACE).
|
|
SaveCurrencyToDb(player);
|
|
PushCurrency(player);
|
|
}
|
|
|
|
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<bool>("Paragon.Currency.Enabled", true))
|
|
return true;
|
|
|
|
// addToSpellBook talents cost both AE and TE per rank.
|
|
if (talent->addToSpellBook)
|
|
{
|
|
uint32 const aeCost = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.TalentLearnCost", 1);
|
|
uint32 const teCost = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
|
|
return GetAE(player) >= aeCost && GetTE(player) >= teCost;
|
|
}
|
|
uint32 const cost = sConfigMgr->GetOption<uint32>("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);
|
|
}
|
|
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, 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, 17, "C BUILD FAVORITE ") == 0)
|
|
{
|
|
std::string err;
|
|
if (!HandleBuildFavorite(player, body.substr(17), &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;
|
|
}
|
|
|
|
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<bool>("Paragon.Currency.Enabled", true))
|
|
return;
|
|
|
|
TalentEntry const* talent = sTalentStore.LookupEntry(talentId);
|
|
bool const dualCost = talent && talent->addToSpellBook;
|
|
|
|
if (dualCost)
|
|
{
|
|
uint32 const aeCost = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.TalentLearnCost", 1);
|
|
uint32 const teCost = sConfigMgr->GetOption<uint32>("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<uint32>("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);
|
|
}
|
|
};
|
|
|
|
class Paragon_Essence_CommandScript : public CommandScript
|
|
{
|
|
public:
|
|
Paragon_Essence_CommandScript() : CommandScript("Paragon_Essence_CommandScript") { }
|
|
|
|
ChatCommandTable GetCommands() const override
|
|
{
|
|
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 },
|
|
};
|
|
|
|
static ChatCommandTable commandTable =
|
|
{
|
|
{ "paragon", paragonSubTable },
|
|
};
|
|
|
|
return commandTable;
|
|
}
|
|
|
|
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<bool>("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<bool>("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<uint32, 3> 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();
|
|
}
|