Paragon: Builds QoL -- share codes, unload, remaining AE/TE on hover

- Replace the "favorite" toggle with import-by-share-code: every build
  gets a 6-char realm-unique alphanumeric code on creation; pasting one
  into the BuildsPane share box copies the recipe (name + icon + spells
  + talents) into the importer's catalog as a new build, with a fresh
  share code so the imported copy can be re-shared independently.
- Add C BUILD UNLOAD verb so the client can clear a stale active-build
  pointer without forcing a swap. Wired to a new "Unload (clear active)"
  right-click context menu entry on the active build.
- Per-build tooltip now shows "Remaining if loaded: X AE / Y TE",
  computed server-side as total_earned - recipe_cost. Negative renders
  red so the player sees insufficient-currency cases before clicking
  Load. Suppressed for the active build (HandleBuildLoad short-circuits
  on target == active so the line would be misleading).
- Schema migration 2026_05_10_04.sql: drop is_favorite from
  character_paragon_builds and add share_code CHAR(6) UNIQUE NULL with
  lazy backfill on every PushBuildCatalog (so pre-migration rows pick
  up codes the first time the player opens the panel).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Docker Build
2026-05-10 04:15:11 -04:00
parent 7de018f7eb
commit a251e56c59
2 changed files with 416 additions and 29 deletions
@@ -0,0 +1,30 @@
-- mod-paragon Character Advancement: Builds catalog schema cleanup.
-- ----------------------------------------------------------------------------
-- Two changes:
-- 1. Drop `is_favorite` -- the favorite flag and shift-click-to-favorite
-- flow are removed. Builds are now ordered solely by build_id ASC.
-- 2. Add `share_code` CHAR(6) -- a random alphanumeric token generated
-- server-side at build creation that uniquely identifies a saved
-- build across the realm. Players exchange codes out-of-band and
-- use the BuildsPane "Load Build!" share box to import a copy of
-- the build (name + icon + spell + talent recipe) into their own
-- catalog. The copy gets a fresh share_code so re-sharing is
-- always traceable to the latest owner; the original isn't touched.
--
-- The column is NULL-tolerant so any rows that pre-date this migration
-- (created under 2026_05_10_03's schema) coexist cleanly. The server
-- backfills NULLs lazily in PushBuildCatalog -- the next time a player
-- opens the BuildsPane on a Paragon character, any of their builds that
-- still have a NULL share_code will get one generated and persisted.
--
-- Charset: 31 unambiguous chars (A-Z minus I/O minus 0/1) gives 31^6 ~=
-- 887M codes; collision retry on insert keeps probability of a duplicate
-- vanishing for any realistic catalog size.
-- ----------------------------------------------------------------------------
ALTER TABLE `character_paragon_builds`
DROP COLUMN `is_favorite`,
ADD COLUMN `share_code` CHAR(6) NULL DEFAULT NULL
COMMENT 'random alphanumeric token for import-by-code; lazily generated'
AFTER `icon`,
ADD UNIQUE INDEX `uk_share_code` (`share_code`);
+386 -29
View File
@@ -14,6 +14,7 @@
#include "Config.h" #include "Config.h"
#include "Pet.h" #include "Pet.h"
#include "Player.h" #include "Player.h"
#include "Random.h"
#include "RBAC.h" #include "RBAC.h"
#include "ScriptMgr.h" #include "ScriptMgr.h"
#include "SharedDefines.h" #include "SharedDefines.h"
@@ -1556,12 +1557,28 @@ void PushSnapshot(Player* pl)
// C BUILD NEW <name>\t<icon> -- create empty build // C BUILD NEW <name>\t<icon> -- create empty build
// C BUILD EDIT <id>\t<name>\t<icon> -- rename / re-icon // C BUILD EDIT <id>\t<name>\t<icon> -- rename / re-icon
// C BUILD DELETE <id> -- delete + drop parked pet // 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 // C BUILD LOAD <id> -- swap to this build
// C BUILD UNLOAD -- clear active pointer
// C BUILD IMPORT <sharecode> -- copy a shared build
// into our own catalog
// //
// Server replies push `R BUILDS` after every mutation. Format: // Server replies push `R BUILDS` after every mutation. Format:
// //
// R BUILDS active=<id|->\t<id>:<fav>:<haspet>:<name>:<icon>; ... // R BUILDS active=<id|->\t<id>:<haspet>:<sharecode>:<remainAE>:<remainTE>:<name>:<icon>; ...
//
// `sharecode` is a 6-character random alphanumeric token unique across
// the realm, generated at build creation. It's how players exchange
// builds: paste the code into the BuildsPane share box on a friend's
// client and the IMPORT command copies the recipe (name + icon + spell
// rows + talent rows) into the friend's catalog as a new build with a
// fresh sharecode (so the imported copy can be re-shared independently).
//
// `remainAE` / `remainTE` are SIGNED int32s representing the AE / TE
// the player would have unspent IF they loaded this build right now
// (load = refund all currently-learned panel spells/talents, then
// re-spend on the target recipe). Negative means the recipe costs
// more than the player has earned -- the client renders that case in
// red so the player knows they can't afford the load.
// //
// Names and icon paths are sanitized server-side: name = ASCII printable // Names and icon paths are sanitized server-side: name = ASCII printable
// up to 32 chars (no '\t', '\r', '\n', ';', ':' since those are wire // up to 32 chars (no '\t', '\r', '\n', ';', ':' since those are wire
@@ -1574,6 +1591,72 @@ constexpr char const* kDefaultBuildIcon = "INV_Misc_QuestionMark";
constexpr std::size_t kBuildNameMaxLen = 32; constexpr std::size_t kBuildNameMaxLen = 32;
constexpr std::size_t kBuildIconMaxLen = 64; constexpr std::size_t kBuildIconMaxLen = 64;
// Share code charset: upper-case alphanumeric minus visually-ambiguous
// glyphs (I, O, 0, 1) so codes spoken aloud or copied by hand are
// unambiguous. 31 chars ^ 6 positions = ~887M unique codes; collision
// retry on insert keeps practical collision probability vanishingly
// small for any realistic per-realm catalog.
constexpr std::size_t kBuildShareCodeLen = 6;
constexpr char const kBuildShareCharset[] = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
constexpr std::size_t kBuildShareCharsetN = sizeof(kBuildShareCharset) - 1;
std::string GenerateBuildShareCode()
{
std::string code;
code.reserve(kBuildShareCodeLen);
for (std::size_t i = 0; i < kBuildShareCodeLen; ++i)
code += kBuildShareCharset[urand(0, static_cast<uint32>(kBuildShareCharsetN) - 1)];
return code;
}
// Strict whitelist on incoming `C BUILD IMPORT <code>` payloads. The
// SQL we'd feed into the lookup query interpolates the value via
// fmt::format (matching the rest of this file's style), so we vet
// length and charset up front and reject anything that isn't 6
// characters drawn from the same alphabet GenerateBuildShareCode emits.
bool IsValidShareCode(std::string const& s)
{
if (s.size() != kBuildShareCodeLen)
return false;
for (char c : s)
{
bool ok = false;
for (std::size_t i = 0; i < kBuildShareCharsetN; ++i)
{
if (c == kBuildShareCharset[i])
{
ok = true;
break;
}
}
if (!ok)
return false;
}
return true;
}
// Generate a fresh share code, retrying on collision against the
// existing rows. With a 31^6 alphabet and even 1M rows the probability
// of a single random pick colliding is < 0.001%, so 8 retries is far
// more than enough headroom; the loop is purely defensive.
std::string GenerateUniqueShareCode()
{
for (int attempt = 0; attempt < 8; ++attempt)
{
std::string code = GenerateBuildShareCode();
QueryResult r = CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_builds WHERE share_code = '{}'", code);
if (!r)
return code;
}
// Worst-case fallback: append a numeric uniquifier from build_id
// sequence. We can't produce a guaranteed-unique 6-char code if
// ~887M codes are taken (impossible at any realistic scale), so
// collapse to the last attempt and let the unique index reject
// duplicates if the universe is broken.
return GenerateBuildShareCode();
}
std::string SanitizeBuildName(std::string s) std::string SanitizeBuildName(std::string s)
{ {
std::string out; std::string out;
@@ -1650,37 +1733,212 @@ bool BuildBelongsToPlayer(uint32 lowGuid, uint32 buildId)
return r != nullptr; return r != nullptr;
} }
// ----------------------------------------------------------------------------
// Cost / remaining-AE-TE helpers (used by PushBuildCatalog tooltip data).
// ----------------------------------------------------------------------------
// "Remaining if loaded" = (total earned by this player so far)
// - (cost of recipe stored in this build).
//
// The wire push computes both halves and ships the *remaining* numbers
// per build; the client just renders them. Computing total_earned from
// (current pool + currently-spent on panel) keeps us from having to
// add a dedicated "earned" counter to character_paragon_currency.
//
// Note on per-spec accuracy: character_paragon_panel_talents is keyed
// (guid, talent_id) -- it stores the highest rank in any spec, not a
// per-spec breakdown. If a Paragon char has DIFFERENT talent allocations
// in spec 0 vs spec 1 they may have paid TE multiple times for the same
// talent_id, but the "spent" walk only sees one row. This undercounts
// total_earned in that edge case, which makes "remaining if loaded"
// show conservatively LOW for builds that include cross-spec talents.
// The character_paragon_build_talents table IS keyed (build_id, spec,
// talent_id) so the BUILD cost side is always accurate. Net effect:
// the tooltip might say a build needs 2 more AE than it really does
// for a small fraction of players. That's preferable to over-promising
// and having the load fail with "not enough TE" mid-flight.
struct BuildCost
{
uint32 ae = 0;
uint32 te = 0;
};
BuildCost ComputeBuildRecipeCost(uint32 buildId)
{
BuildCost out{};
if (!buildId)
return out;
uint32 const tePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
uint32 const aePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.TalentLearnCost", 1);
// Spell side: each panel spell costs LookupSpellAECost. The recipe
// table doesn't carry rank info because PanelLearnSpellChain grants
// every higher rank of a chain in one charge, so one row = one
// chain-head buy = one cost lookup.
if (QueryResult r = CharacterDatabase.Query(
"SELECT spell_id FROM character_paragon_build_spells WHERE build_id = {}",
buildId))
{
do
{
uint32 sid = r->Fetch()[0].Get<uint32>();
if (!sid)
continue;
out.ae += LookupSpellAECost(sid);
} while (r->NextRow());
}
// Talent side: charge tePerRank * rank for every (spec, talent_id)
// row. addToSpellBook talents (the few that grant an active spell
// like Starfall / Bladestorm / Mirror Image) charge AE on top.
if (QueryResult r = CharacterDatabase.Query(
"SELECT talent_id, `rank` FROM character_paragon_build_talents "
"WHERE build_id = {}", buildId))
{
do
{
Field const* f = r->Fetch();
uint32 tid = f[0].Get<uint32>();
uint32 rank = f[1].Get<uint8>();
if (!tid || !rank)
continue;
TalentEntry const* te = sTalentStore.LookupEntry(tid);
if (!te)
continue;
out.te += rank * tePerRank;
if (te->addToSpellBook)
out.ae += rank * aePerRank;
} while (r->NextRow());
}
return out;
}
// Sum the AE/TE the player has currently SPENT on panel-bought spells
// and talents. Refunding everything via Reset would return exactly this
// total to the unspent pool, so total_earned == current + spent.
BuildCost ComputeCurrentlySpentOnPanel(uint32 lowGuid)
{
BuildCost out{};
uint32 const tePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
uint32 const aePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.TalentLearnCost", 1);
if (QueryResult r = CharacterDatabase.Query(
"SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}",
lowGuid))
{
do
{
uint32 sid = r->Fetch()[0].Get<uint32>();
if (!sid)
continue;
out.ae += LookupSpellAECost(sid);
} while (r->NextRow());
}
if (QueryResult r = CharacterDatabase.Query(
"SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}",
lowGuid))
{
do
{
Field const* f = r->Fetch();
uint32 tid = f[0].Get<uint32>();
uint32 rank = f[1].Get<uint8>();
if (!tid || !rank)
continue;
TalentEntry const* te = sTalentStore.LookupEntry(tid);
if (!te)
continue;
out.te += rank * tePerRank;
if (te->addToSpellBook)
out.ae += rank * aePerRank;
} while (r->NextRow());
}
return out;
}
// Lazily assign share codes to any of this player's builds that still
// have a NULL share_code (rows created under the pre-2026_05_10_04
// schema). Runs at the top of every PushBuildCatalog so by the time
// the wire response is built every row has a non-NULL code. Cheap in
// steady-state -- the SELECT returns zero rows once backfilled.
void BackfillBuildShareCodes(uint32 lowGuid)
{
QueryResult r = CharacterDatabase.Query(
"SELECT build_id FROM character_paragon_builds "
"WHERE guid = {} AND (share_code IS NULL OR share_code = '')", lowGuid);
if (!r)
return;
do
{
uint32 buildId = r->Fetch()[0].Get<uint32>();
std::string code = GenerateUniqueShareCode();
CharacterDatabase.DirectExecute(
"UPDATE character_paragon_builds SET share_code = '{}' "
"WHERE build_id = {}", code, buildId);
} while (r->NextRow());
}
void PushBuildCatalog(Player* pl) void PushBuildCatalog(Player* pl)
{ {
if (!pl || pl->getClass() != CLASS_PARAGON) if (!pl || pl->getClass() != CLASS_PARAGON)
return; return;
uint32 const lowGuid = pl->GetGUID().GetCounter(); uint32 const lowGuid = pl->GetGUID().GetCounter();
BackfillBuildShareCodes(lowGuid);
uint32 const active = GetActiveBuildId(lowGuid); uint32 const active = GetActiveBuildId(lowGuid);
// total_earned (approx) = current unspent pool + amount currently
// spent on panel learns. Refunding everything via Reset would
// return exactly the spent portion, so we model "remaining if
// loaded" as `total_earned - build_cost`. See the long comment
// on ComputeCurrentlySpentOnPanel for the per-spec edge case.
BuildCost const spent = ComputeCurrentlySpentOnPanel(lowGuid);
int64 const earnedAE = int64(GetAE(pl)) + int64(spent.ae);
int64 const earnedTE = int64(GetTE(pl)) + int64(spent.te);
std::string activeStr = active ? std::to_string(active) : std::string("-"); std::string activeStr = active ? std::to_string(active) : std::string("-");
std::string body = fmt::format("R BUILDS active={}\t", activeStr); std::string body = fmt::format("R BUILDS active={}\t", activeStr);
if (QueryResult r = CharacterDatabase.Query( if (QueryResult r = CharacterDatabase.Query(
"SELECT build_id, is_favorite, pet_number, name, icon " "SELECT build_id, pet_number, share_code, name, icon "
"FROM character_paragon_builds WHERE guid = {} " "FROM character_paragon_builds WHERE guid = {} "
"ORDER BY is_favorite DESC, build_id ASC", lowGuid)) "ORDER BY build_id ASC", lowGuid))
{ {
bool first = true; bool first = true;
do do
{ {
Field const* f = r->Fetch(); Field const* f = r->Fetch();
uint32 id = f[0].Get<uint32>(); uint32 id = f[0].Get<uint32>();
uint8 fav = f[1].Get<uint8>(); bool haspet = !f[1].IsNull() && f[1].Get<uint32>() != 0;
bool haspet = !f[2].IsNull() && f[2].Get<uint32>() != 0; std::string code = f[2].IsNull() ? std::string() : f[2].Get<std::string>();
std::string name = f[3].Get<std::string>(); std::string name = f[3].Get<std::string>();
std::string icon = f[4].Get<std::string>(); std::string icon = f[4].Get<std::string>();
BuildCost const cost = ComputeBuildRecipeCost(id);
// Signed: negative means the recipe costs more than the
// player has earned to date (insufficient). Clamp at int32
// bounds out of paranoia though realistic catalogs stay
// far inside.
int32 const remainAE = static_cast<int32>(earnedAE - int64(cost.ae));
int32 const remainTE = static_cast<int32>(earnedTE - int64(cost.te));
if (!first) if (!first)
body += ';'; body += ';';
first = false; first = false;
body += fmt::format("{}:{}:{}:{}:{}", id, // Wire format:
static_cast<unsigned>(fav), // <id>:<haspet>:<sharecode>:<remainAE>:<remainTE>:<name>:<icon>
// Sharecode is always 6 chars after backfill; remainAE/TE
// are signed (formatted with %+d-equivalent via fmt's "{}",
// which renders a leading '-' for negatives and bare digits
// for non-negatives, matching the client's "%-?%d+" parse).
body += fmt::format("{}:{}:{}:{}:{}:{}:{}", id,
haspet ? 1 : 0, haspet ? 1 : 0,
code,
remainAE, remainTE,
name, icon); name, icon);
} while (r->NextRow()); } while (r->NextRow());
} }
@@ -1724,12 +1982,15 @@ bool HandleBuildNew(Player* pl, std::string const& payload, std::string* err)
} }
} }
std::string code = GenerateUniqueShareCode();
CharacterDatabase.DirectExecute( CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_builds (guid, name, icon) VALUES ({}, '{}', '{}')", "INSERT INTO character_paragon_builds (guid, name, icon, share_code) "
lowGuid, name, icon); "VALUES ({}, '{}', '{}', '{}')",
lowGuid, name, icon, code);
PushBuildCatalog(pl); PushBuildCatalog(pl);
LOG_INFO("module", "Paragon build: {} created build '{}'", pl->GetName(), name); LOG_INFO("module", "Paragon build: {} created build '{}' (share code {})",
pl->GetName(), name, code);
return true; return true;
} }
@@ -1857,39 +2118,119 @@ bool HandleBuildDelete(Player* pl, std::string const& payload, std::string* err)
return true; return true;
} }
bool HandleBuildFavorite(Player* pl, std::string const& payload, std::string* err) // Import a build from another player's catalog by share code. Copies
// the recipe (name + icon + per-spec talent rows + spell rows) into a
// new owned build for the requester with a freshly-generated share
// code. Crucially does NOT auto-load the imported build -- the player
// finds it in their catalog and clicks it like any other saved build,
// matching the "import_only" UX choice. The original build owner's
// row is untouched.
//
// Errors (sent back as "R ERR ..." for the addon channel):
// - malformed code (length / charset)
// - code not found
// - the code points to one of the requester's own builds
// - the requester is at the 64-build cap
bool HandleBuildImport(Player* pl, std::string const& payload, std::string* err)
{ {
if (!pl || pl->getClass() != CLASS_PARAGON) if (!pl || pl->getClass() != CLASS_PARAGON)
{ {
*err = "not a Paragon"; *err = "not a Paragon";
return false; return false;
} }
auto sp = payload.find(' ');
if (sp == std::string::npos) // Trim whitespace and uppercase the input so users don't have to
// type the code in exact case. The wire-charset is upper-only so
// forcing upper preserves the lookup hit rate even if the player
// typed a lower-case 'a'.
std::string code = payload;
auto notSpace = [](unsigned char c) { return !std::isspace(c); };
auto first = std::find_if(code.begin(), code.end(), notSpace);
auto last = std::find_if(code.rbegin(), code.rend(), notSpace).base();
code = (first < last) ? std::string(first, last) : std::string();
for (char& c : code)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
if (!IsValidShareCode(code))
{ {
*err = "BUILD FAVORITE malformed"; *err = "share code must be 6 characters (A-Z minus I/O, 2-9)";
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; return false;
} }
uint32 const lowGuid = pl->GetGUID().GetCounter(); uint32 const lowGuid = pl->GetGUID().GetCounter();
if (!BuildBelongsToPlayer(lowGuid, buildId))
// Look up the source build by share_code. We need the source's
// owner guid so we can short-circuit "import your own build"
// before consuming a slot or copying recipe rows.
QueryResult srcRow = CharacterDatabase.Query(
"SELECT build_id, guid, name, icon "
"FROM character_paragon_builds WHERE share_code = '{}'", code);
if (!srcRow)
{ {
*err = "build does not belong to player"; *err = "no build with that code";
return false;
}
Field const* sf = srcRow->Fetch();
uint32 srcBuildId = sf[0].Get<uint32>();
uint32 srcOwner = sf[1].Get<uint32>();
std::string srcName = sf[2].Get<std::string>();
std::string srcIcon = sf[3].Get<std::string>();
if (srcOwner == lowGuid)
{
*err = "this build is already in your catalog";
return false; return false;
} }
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;
}
}
// Insert the new owned build first so we have its build_id to
// attach the copied recipe rows to. Server-generated share code
// is fresh -- the imported copy can be re-shared independently.
std::string newCode = GenerateUniqueShareCode();
CharacterDatabase.DirectExecute( CharacterDatabase.DirectExecute(
"UPDATE character_paragon_builds SET is_favorite = {} WHERE build_id = {}", "INSERT INTO character_paragon_builds (guid, name, icon, share_code) "
flag ? 1 : 0, buildId); "VALUES ({}, '{}', '{}', '{}')",
lowGuid, srcName, srcIcon, newCode);
QueryResult idRow = CharacterDatabase.Query(
"SELECT build_id FROM character_paragon_builds "
"WHERE share_code = '{}'", newCode);
if (!idRow)
{
*err = "import failed (could not allocate build_id)";
return false;
}
uint32 newBuildId = idRow->Fetch()[0].Get<uint32>();
// Copy recipe rows row-by-row via INSERT...SELECT so we don't
// need to materialize them in C++. Using a literal `newBuildId`
// for the copy so the foreign reference is correct on the new
// owner's row.
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_spells (build_id, spell_id) "
"SELECT {}, spell_id FROM character_paragon_build_spells WHERE build_id = {}",
newBuildId, srcBuildId);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_talents (build_id, spec, talent_id, `rank`) "
"SELECT {}, spec, talent_id, `rank` FROM character_paragon_build_talents "
"WHERE build_id = {}",
newBuildId, srcBuildId);
PushBuildCatalog(pl); PushBuildCatalog(pl);
LOG_INFO("module",
"Paragon build: {} imported '{}' (src build {} owner {}) "
"as new build {} with code {}",
pl->GetName(), srcName, srcBuildId, srcOwner, newBuildId, newCode);
return true; return true;
} }
@@ -2541,10 +2882,10 @@ public:
SendAddonMessage(player, "R ERR " + err); SendAddonMessage(player, "R ERR " + err);
return; return;
} }
if (body.compare(0, 17, "C BUILD FAVORITE ") == 0) if (body.compare(0, 15, "C BUILD IMPORT ") == 0)
{ {
std::string err; std::string err;
if (!HandleBuildFavorite(player, body.substr(17), &err)) if (!HandleBuildImport(player, body.substr(15), &err))
SendAddonMessage(player, "R ERR " + err); SendAddonMessage(player, "R ERR " + err);
return; return;
} }
@@ -2555,6 +2896,22 @@ public:
SendAddonMessage(player, "R ERR " + err); SendAddonMessage(player, "R ERR " + err);
return; return;
} }
// "C BUILD UNLOAD" -- clears the active-build pointer without
// touching learned spells/talents or parking pets. Recovery
// path for a stale active pointer (e.g. a load that was
// interrupted between Phase 3 and Phase 4 in HandleBuildLoad,
// leaving the row pointing at a build whose recipe was already
// re-applied). Player retains current learns; the catalog push
// refreshes the UI so the "Active" glow + tooltip clear.
if (body == "C BUILD UNLOAD")
{
if (player->getClass() != CLASS_PARAGON)
return;
uint32 const lowGuid = player->GetGUID().GetCounter();
SetActiveBuildId(lowGuid, 0);
PushBuildCatalog(player);
return;
}
if (body == "C RESET PET TALENTS") if (body == "C RESET PET TALENTS")
{ {