Paragon: Save-Current build, archive retired share codes, reset clears active

Server side of the v0.7.10 Builds drop. Squashes a few footguns from
the original Builds catalog and adds a one-click "save what I have
right now" path the Overview pane can hook directly into.

- HandleBuildSaveCurrent: new C BUILD SAVE_CURRENT verb. Inserts a
  fresh build row, snapshots the live panel into its recipe, sets it
  active. No AE/TE motion, no relearning -- just a named slot for
  whatever the player already has.
- Reset abilities / Reset talents now SetActiveBuildId(0) and re-push
  the catalog. Without this, the next swap silently overwrote the
  active build's saved recipe with the (now empty/partial) post-reset
  state -- effectively erasing the build.
- Delete of the *active* build is now a hard reset (HandleParagonResetAll):
  unlearn everything the panel bought, refund all AE/TE. Deleting a
  non-active slot still just removes the saved recipe row + parked pet.
- Load of the currently-active build is now a "revert to last snapshot"
  instead of a no-op refresh: keeps the saved recipe authoritative,
  parks the pet, resets, re-applies. Useful for discarding pending
  edits.
- After a successful Learn All while a build is active: archive the
  build's previous share_code + recipe into
  character_paragon_build_share_archive* (so codes already posted to
  Discord keep importing the frozen loadout), snapshot the new panel
  into the live build, assign a fresh share_code, push catalog.
- HandleBuildImport now falls back to the archive tables when a code
  isn't in the live catalog -- old shared codes resurrect the recipe
  they pointed at when they were retired.
- Imports never copy pet_number (the parked pet belongs to the source
  player); if the imported recipe contains Tame Beast we hint that the
  importer needs to tame their own pet.
- BuildPanelOwnedSpellsAllowlist now walks SPELL_EFFECT_LEARN_SPELL
  effects on talent rank spells (Mangle, Feral Charge, Mutilate, ...)
  so the login cascade sweep stops revoking talent-granted active
  abilities.

Schema: new mod-paragon migration 2026_05_10_05.sql adds
character_paragon_build_share_archive (+ _spells / _talents).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Docker Build
2026-05-10 15:11:07 -04:00
parent 377927b878
commit ef02839ea0
2 changed files with 392 additions and 50 deletions
@@ -0,0 +1,34 @@
-- mod-paragon: preserve superseded share codes as importable snapshots.
-- ----------------------------------------------------------------------------
-- When an active build is updated (Learn All), the live row gets a new
-- share_code and a fresh recipe. Older codes the player posted to Discord
-- must keep working: each retired code is frozen here with its spell/talent
-- recipe so `C BUILD IMPORT <code>` still materializes that exact loadout.
-- ----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `character_paragon_build_share_archive` (
`share_code` CHAR(6) NOT NULL COMMENT 'retired code (same charset as live builds)',
`name` VARCHAR(32) NOT NULL,
`icon` VARCHAR(64) NOT NULL DEFAULT 'INV_Misc_QuestionMark',
`archived_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`share_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='mod-paragon: frozen build metadata for retired share codes';
CREATE TABLE IF NOT EXISTS `character_paragon_build_share_archive_spells` (
`share_code` CHAR(6) NOT NULL,
`spell_id` INT UNSIGNED NOT NULL,
PRIMARY KEY (`share_code`, `spell_id`),
KEY `idx_share` (`share_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='mod-paragon: spell recipe rows for an archived share code';
CREATE TABLE IF NOT EXISTS `character_paragon_build_share_archive_talents` (
`share_code` CHAR(6) NOT NULL,
`spec` TINYINT UNSIGNED NOT NULL,
`talent_id` SMALLINT UNSIGNED NOT NULL,
`rank` TINYINT UNSIGNED NOT NULL,
PRIMARY KEY (`share_code`, `spec`, `talent_id`),
KEY `idx_share` (`share_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='mod-paragon: talent recipe rows for an archived share code';
+343 -35
View File
@@ -261,6 +261,14 @@ uint32 GetParagonPanelSpellRequiredLevel(SpellInfo const* info)
// is defined later (after PushSpellSnapshot / PushTalentSnapshot).
void PushSnapshot(Player* pl);
// Forward declarations: reset handlers below clear the active build pointer
// and re-push the build catalog so the cell border drops client-side. Both
// helpers live with the rest of the build catalog code further down.
void SetActiveBuildId(uint32 lowGuid, uint32 buildId);
void PushBuildCatalog(Player* pl);
bool BuildBelongsToPlayer(uint32 lowGuid, uint32 buildId);
void SnapshotBuildFromCurrent(Player* pl, uint32 buildId);
// Forward declaration: the login-time scoped sweep (defined a few helpers
// down) calls into the chain-walker (defined further down).
void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set<uint32>& out);
@@ -374,8 +382,40 @@ void BuildPanelOwnedSpellsAllowlist(uint32 lowGuid, std::unordered_set<uint32>&
// 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]);
{
uint32 const rankSpell = te->RankID[i];
if (!rankSpell)
continue;
allowed.insert(rankSpell);
// Some talents (Mangle, Feral Charge, Mutilate, ...) own a
// *passive* RankID spell whose effects then LEARN_SPELL the
// actual active abilities the player gets to use. RankID
// alone isn't enough -- the granted spells live on the
// class skill line, so the login cascade sweep would see
// them as "not allowlisted" and revoke them, and from the
// user's POV the ability vanishes on relog.
//
// Walk every effect of this rank's spell, expand each
// SPELL_EFFECT_LEARN_SPELL trigger through CollectSpellChainIds
// so the whole rank chain of the granted spell stays
// protected too (e.g. Mangle Bear rank 1 33878 ->
// 33986 -> ... -> 48566; we want all of them on the
// allowlist so a high-level Paragon druid keeps the rank
// appropriate to their level).
SpellInfo const* rankInfo = sSpellMgr->GetSpellInfo(rankSpell);
if (!rankInfo)
continue;
for (uint8 e = 0; e < MAX_SPELL_EFFECTS; ++e)
{
if (rankInfo->Effects[e].Effect != SPELL_EFFECT_LEARN_SPELL)
continue;
uint32 const grantId = rankInfo->Effects[e].TriggerSpell;
if (!grantId)
continue;
CollectSpellChainIds(grantId, allowed);
}
}
} while (r->NextRow());
}
@@ -1447,9 +1487,21 @@ bool HandleParagonResetAbilities(Player* pl, std::string* err)
d.abilityEssence += refundAE;
}
// Reset detaches the player from any active build. The build's
// saved recipe is preserved in DB so the player can re-load it,
// but until they do, the next swap MUST NOT auto-snapshot the
// (now empty/partial) panel state into that build -- which is
// exactly what would happen if we left the pointer set. We push
// the catalog so the cell that previously had the "active"
// border drops it client-side; if this reset is being invoked
// mid-swap (HandleBuildLoad), the swap's final PushBuildCatalog
// restores the correct activeId at the tail.
SetActiveBuildId(lowGuid, 0);
SaveCurrencyToDb(pl);
PushCurrency(pl);
PushSnapshot(pl);
PushBuildCatalog(pl);
LOG_INFO("module", "Paragon panel: {} reset abilities (+{} AE refund)", pl->GetName(), refundAE);
return true;
}
@@ -1514,9 +1566,15 @@ bool HandleParagonResetTalents(Player* pl, std::string* err)
d.abilityEssence += refundAE;
d.talentEssence += refundTE;
// See HandleParagonResetAbilities: detach from the active build
// so the next swap doesn't overwrite its saved recipe with the
// (post-reset) state.
SetActiveBuildId(lowGuid, 0);
SaveCurrencyToDb(pl);
PushCurrency(pl);
PushSnapshot(pl);
PushBuildCatalog(pl);
LOG_INFO("module", "Paragon panel: {} reset talents (+{} AE +{} TE refund)", pl->GetName(), refundAE, refundTE);
return true;
}
@@ -1555,8 +1613,13 @@ void PushSnapshot(Player* pl)
//
// Q BUILDS -- request catalog
// C BUILD NEW <name>\t<icon> -- create empty build
// C BUILD SAVE_CURRENT <name>\t<icon> -- create build from current
// panel state and set active
// 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;
// if <id> is the active build,
// also full panel reset (unlearn
// + AE/TE refund) like RESET ALL
// C BUILD LOAD <id> -- swap to this build
// C BUILD UNLOAD -- clear active pointer
// C BUILD IMPORT <sharecode> -- copy a shared build
@@ -1644,9 +1707,12 @@ 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)
if (QueryResult r = CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_builds WHERE share_code = '{}'", code))
continue;
if (QueryResult r2 = CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_build_share_archive WHERE share_code = '{}'", code))
continue;
return code;
}
// Worst-case fallback: append a numeric uniquifier from build_id
@@ -1657,6 +1723,64 @@ std::string GenerateUniqueShareCode()
return GenerateBuildShareCode();
}
// After a successful Learn All while a build is active: freeze the
// previous share_code + recipe into character_paragon_build_share_archive*
// (so Discord-posted codes keep importing that exact loadout), then
// snapshot the panel into the live build rows and assign a fresh code
// for the owner's current recipe.
void PersistActiveBuildSnapshotAfterLearnAllCommit(Player* pl, uint32 buildId)
{
if (!pl || !buildId)
return;
uint32 const lowGuid = pl->GetGUID().GetCounter();
if (!BuildBelongsToPlayer(lowGuid, buildId))
return;
std::string oldCode;
if (QueryResult row = CharacterDatabase.Query(
"SELECT COALESCE(NULLIF(share_code, ''), '') AS sc "
"FROM character_paragon_builds WHERE build_id = {} AND guid = {}",
buildId, lowGuid))
oldCode = row->Fetch()[0].Get<std::string>();
if (!oldCode.empty())
{
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_build_share_archive_spells WHERE share_code = '{}'", oldCode);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_build_share_archive_talents WHERE share_code = '{}'", oldCode);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_build_share_archive WHERE share_code = '{}'", oldCode);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_share_archive (share_code, name, icon) "
"SELECT share_code, name, icon FROM character_paragon_builds "
"WHERE build_id = {} AND guid = {}", buildId, lowGuid);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_share_archive_spells (share_code, spell_id) "
"SELECT '{}', spell_id FROM character_paragon_build_spells WHERE build_id = {}",
oldCode, buildId);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_share_archive_talents (share_code, spec, talent_id, `rank`) "
"SELECT '{}', spec, talent_id, `rank` FROM character_paragon_build_talents WHERE build_id = {}",
oldCode, buildId);
}
SnapshotBuildFromCurrent(pl, buildId);
std::string const newCode = GenerateUniqueShareCode();
CharacterDatabase.DirectExecute(
"UPDATE character_paragon_builds SET share_code = '{}' WHERE build_id = {} AND guid = {}",
newCode, buildId, lowGuid);
LOG_INFO("module",
"Paragon build: {} persisted active build {} after commit (share now {})",
pl->GetName(), buildId, newCode);
}
std::string SanitizeBuildName(std::string s)
{
std::string out;
@@ -1994,6 +2118,81 @@ bool HandleBuildNew(Player* pl, std::string const& payload, std::string* err)
return true;
}
// "Save current loadout as a new build". Driven by the Overview pane's
// "Save as Build" button. Equivalent to HandleBuildNew + an immediate
// SnapshotBuildFromCurrent into the new row, plus a SetActiveBuildId
// flip. Does NOT touch panel rows / currency / learned spells -- the
// player's state is already what they want, we just file it under a
// named slot. The previously-active build (if any) keeps its last
// committed recipe; loading it later restores that snapshot exactly
// as the normal swap flow does.
bool HandleBuildSaveCurrent(Player* pl, std::string const& payload, std::string* err)
{
if (!pl || pl->getClass() != CLASS_PARAGON)
{
*err = "not a Paragon";
return false;
}
if (pl->IsInCombat())
{
*err = "cannot save builds while in combat";
return false;
}
auto tab = payload.find('\t');
if (tab == std::string::npos)
{
*err = "BUILD SAVE_CURRENT malformed";
return false;
}
std::string name = SanitizeBuildName(payload.substr(0, tab));
std::string icon = SanitizeBuildIcon(payload.substr(tab + 1));
if (name.empty())
{
*err = "build name is empty";
return false;
}
uint32 const lowGuid = pl->GetGUID().GetCounter();
if (QueryResult cnt = CharacterDatabase.Query(
"SELECT COUNT(*) FROM character_paragon_builds WHERE guid = {}", lowGuid))
{
if (cnt->Fetch()[0].Get<uint32>() >= 64)
{
*err = "build limit reached (64)";
return false;
}
}
std::string insertName = name;
std::string insertIcon = icon;
CharacterDatabase.EscapeString(insertName);
CharacterDatabase.EscapeString(insertIcon);
std::string const code = GenerateUniqueShareCode();
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_builds (guid, name, icon, share_code) "
"VALUES ({}, '{}', '{}', '{}')",
lowGuid, insertName, insertIcon, code);
QueryResult idRow = CharacterDatabase.Query(
"SELECT build_id FROM character_paragon_builds "
"WHERE share_code = '{}'", code);
if (!idRow)
{
*err = "save failed (could not allocate build_id)";
return false;
}
uint32 const newBuildId = idRow->Fetch()[0].Get<uint32>();
SnapshotBuildFromCurrent(pl, newBuildId);
SetActiveBuildId(lowGuid, newBuildId);
PushBuildCatalog(pl);
LOG_INFO("module",
"Paragon build: {} saved current loadout as build {} '{}' (share {})",
pl->GetName(), newBuildId, name, code);
return true;
}
bool HandleBuildEdit(Player* pl, std::string const& payload, std::string* err)
{
if (!pl || pl->getClass() != CLASS_PARAGON)
@@ -2092,14 +2291,21 @@ bool HandleBuildDelete(Player* pl, std::string const& payload, std::string* err)
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).
// Deleting the *active* build is a hard reset: unlearn everything the
// Character Advancement panel bought, refund all AE/TE into the
// unspent pool, and clear the active pointer (same net effect as
// C RESET ALL). Deleting a non-active slot only removes the saved
// recipe row + any parked pet bound to that slot.
uint32 const active = GetActiveBuildId(lowGuid);
if (active == buildId)
SetActiveBuildId(lowGuid, 0);
{
std::string resetErr;
if (!HandleParagonResetAll(pl, &resetErr))
{
*err = resetErr;
return false;
}
}
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_build_spells WHERE build_id = {}", buildId);
@@ -2128,8 +2334,8 @@ bool HandleBuildDelete(Player* pl, std::string const& payload, std::string* err)
//
// 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
// - code not found (neither live nor archived)
// - the code points to one of the requester's own live-catalog builds
// - the requester is at the 64-build cap
bool HandleBuildImport(Player* pl, std::string const& payload, std::string* err)
{
@@ -2159,24 +2365,42 @@ bool HandleBuildImport(Player* pl, std::string const& payload, std::string* err)
uint32 const lowGuid = pl->GetGUID().GetCounter();
// 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.
// Live catalog first, then retired codes frozen in
// character_paragon_build_share_archive* (same code keeps importing
// the recipe that was current when the owner last committed).
QueryResult srcRow = CharacterDatabase.Query(
"SELECT build_id, guid, name, icon "
"FROM character_paragon_builds WHERE share_code = '{}'", code);
if (!srcRow)
bool fromArchive = false;
uint32 srcBuildId = 0;
uint32 srcOwner = 0;
std::string srcName;
std::string srcIcon;
if (srcRow)
{
Field const* sf = srcRow->Fetch();
srcBuildId = sf[0].Get<uint32>();
srcOwner = sf[1].Get<uint32>();
srcName = sf[2].Get<std::string>();
srcIcon = sf[3].Get<std::string>();
}
else if (QueryResult arch = CharacterDatabase.Query(
"SELECT name, icon FROM character_paragon_build_share_archive WHERE share_code = '{}'", code))
{
fromArchive = true;
Field const* af = arch->Fetch();
srcName = af[0].Get<std::string>();
srcIcon = af[1].Get<std::string>();
}
else
{
*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)
if (!fromArchive && srcOwner == lowGuid)
{
*err = "this build is already in your catalog";
return false;
@@ -2195,11 +2419,28 @@ bool HandleBuildImport(Player* pl, std::string const& payload, std::string* err)
// Insert the new owned build first so we have its build_id to
// attach the copied recipe rows to. Server-generated share code
// is fresh -- the imported copy can be re-shared independently.
//
// Pet handling: we deliberately do NOT copy `pet_number`. A parked
// hunter pet belongs to the source player's character and lives
// in `character_pet` under their owner guid; cloning the row
// would either steal the pet (corrupting the source player's
// stable / stable-master state) or summon a pet the importer
// can't legally own. The new row leaves `pet_number = NULL`
// (column default), so when the importer first loads this build
// and HandleBuildLoad reaches Phase 4, RestoreParkedPetForBuild
// sees NULL and no-ops -- the player must tame their own pet
// (Tame Beast comes via the recipe if the source build had it),
// and on next swap-away ParkActivePetForBuild will bind THEIR
// pet to the row exactly like a locally-created build.
std::string newCode = GenerateUniqueShareCode();
std::string insertName = srcName;
std::string insertIcon = srcIcon;
CharacterDatabase.EscapeString(insertName);
CharacterDatabase.EscapeString(insertIcon);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_builds (guid, name, icon, share_code) "
"VALUES ({}, '{}', '{}', '{}')",
lowGuid, srcName, srcIcon, newCode);
lowGuid, insertName, insertIcon, newCode);
QueryResult idRow = CharacterDatabase.Query(
"SELECT build_id FROM character_paragon_builds "
@@ -2215,6 +2456,8 @@ bool HandleBuildImport(Player* pl, std::string const& payload, std::string* err)
// need to materialize them in C++. Using a literal `newBuildId`
// for the copy so the foreign reference is correct on the new
// owner's row.
if (!fromArchive)
{
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_spells (build_id, spell_id) "
"SELECT {}, spell_id FROM character_paragon_build_spells WHERE build_id = {}",
@@ -2225,8 +2468,48 @@ bool HandleBuildImport(Player* pl, std::string const& payload, std::string* err)
"SELECT {}, spec, talent_id, `rank` FROM character_paragon_build_talents "
"WHERE build_id = {}",
newBuildId, srcBuildId);
}
else
{
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_spells (build_id, spell_id) "
"SELECT {}, spell_id FROM character_paragon_build_share_archive_spells "
"WHERE share_code = '{}'",
newBuildId, code);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_talents (build_id, spec, talent_id, `rank`) "
"SELECT {}, spec, talent_id, `rank` FROM character_paragon_build_share_archive_talents "
"WHERE share_code = '{}'",
newBuildId, code);
}
PushBuildCatalog(pl);
// Hunter-pet hint: imports never carry a parked pet (see comment
// before the INSERT above). If the recipe contains Tame Beast
// (spell 1515) we surface a one-line system message so the player
// knows they need to tame their own pet before that build feels
// "complete". Other classes' pet-summon spells (Summon Imp, Raise
// Dead, ...) re-summon a fresh entity each cast so they don't
// need any heads-up.
QueryResult petCheck = CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_build_spells "
"WHERE build_id = {} AND spell_id = 1515 LIMIT 1", newBuildId);
if (petCheck && pl->GetSession())
{
std::string const msg = fmt::format(
"|cffffd200[Paragon]|r Imported \"{}\" includes Tame Beast. "
"Tame your own pet after loading this build -- the source "
"player's pet was not transferred.", srcName);
ChatHandler(pl->GetSession()).SendSysMessage(msg.c_str());
}
if (fromArchive)
LOG_INFO("module",
"Paragon build: {} imported archived code '{}' as new build {} (share {})",
pl->GetName(), code, newBuildId, newCode);
else
LOG_INFO("module",
"Paragon build: {} imported '{}' (src build {} owner {}) "
"as new build {} with code {}",
@@ -2459,20 +2742,23 @@ bool HandleBuildLoad(Player* pl, std::string const& payload, std::string* err)
}
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;
}
bool const sameBuild = (activeId == targetId);
// -------------------------------------------------------------
// Phase 1: snapshot + park the current build's state, if any.
// Phase 1: snapshot + park the current build's state.
//
// Cross-build swap: capture the outgoing build's panel state into
// its recipe rows so swapping back later restores it; park any
// active hunter pet so we can re-summon the same instance.
//
// Same-build "revert": skip the snapshot (we WANT the saved
// recipe to remain authoritative -- this command's whole purpose
// is to discard pending edits), but still park the pet so the
// reset+re-spend cycle below doesn't destroy it.
// -------------------------------------------------------------
if (activeId)
{
if (!sameBuild)
SnapshotBuildFromCurrent(pl, activeId);
ParkActivePetForBuild(pl, activeId);
}
@@ -2616,7 +2902,8 @@ bool HandleBuildLoad(Player* pl, std::string const& payload, std::string* err)
RestoreParkedPetForBuild(pl, targetId);
PushBuildCatalog(pl);
LOG_INFO("module", "Paragon build: {} loaded build {}", pl->GetName(), targetId);
LOG_INFO("module", "Paragon build: {} {} build {}",
pl->GetName(), sameBuild ? "reverted to snapshot of" : "loaded", targetId);
return true;
}
@@ -2821,6 +3108,20 @@ public:
SendAddonMessage(player, fmt::format("R OK {} {}", GetAE(player), GetTE(player)));
PushCurrency(player);
PushSnapshot(player);
// If the player has a build loaded, the commit just
// mutated their panel state -- archive the previous
// share_code + recipe (so old Discord codes still
// import that frozen loadout), snapshot the new panel
// into the live build, assign a fresh share_code for
// the new recipe, and re-push the catalog.
uint32 const lowGuid = player->GetGUID().GetCounter();
uint32 const activeId = GetActiveBuildId(lowGuid);
if (activeId)
{
PersistActiveBuildSnapshotAfterLearnAllCommit(player, activeId);
PushBuildCatalog(player);
}
}
else
{
@@ -2868,6 +3169,13 @@ public:
SendAddonMessage(player, "R ERR " + err);
return;
}
if (body.compare(0, 21, "C BUILD SAVE_CURRENT ") == 0)
{
std::string err;
if (!HandleBuildSaveCurrent(player, body.substr(21), &err))
SendAddonMessage(player, "R ERR " + err);
return;
}
if (body.compare(0, 13, "C BUILD EDIT ") == 0)
{
std::string err;