From ef02839ea0e7172b9851a3554452d6ac1df1fe9c Mon Sep 17 00:00:00 2001 From: Docker Build Date: Sun, 10 May 2026 15:11:07 -0400 Subject: [PATCH] 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 --- .../db-characters/updates/2026_05_10_05.sql | 34 ++ modules/mod-paragon/src/Paragon_Essence.cpp | 408 +++++++++++++++--- 2 files changed, 392 insertions(+), 50 deletions(-) create mode 100644 modules/mod-paragon/data/sql/db-characters/updates/2026_05_10_05.sql diff --git a/modules/mod-paragon/data/sql/db-characters/updates/2026_05_10_05.sql b/modules/mod-paragon/data/sql/db-characters/updates/2026_05_10_05.sql new file mode 100644 index 0000000..8f2d4f4 --- /dev/null +++ b/modules/mod-paragon/data/sql/db-characters/updates/2026_05_10_05.sql @@ -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 ` 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'; diff --git a/modules/mod-paragon/src/Paragon_Essence.cpp b/modules/mod-paragon/src/Paragon_Essence.cpp index 383b265..cfca94f 100644 --- a/modules/mod-paragon/src/Paragon_Essence.cpp +++ b/modules/mod-paragon/src/Paragon_Essence.cpp @@ -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& out); @@ -374,8 +382,40 @@ void BuildPanelOwnedSpellsAllowlist(uint32 lowGuid, std::unordered_set& // protects all lower ranks the player rolled through. uint32 const cap = std::min(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 \t -- create empty build +// C BUILD SAVE_CURRENT \t -- create build from current +// panel state and set active // C BUILD EDIT \t\t -- rename / re-icon -// C BUILD DELETE -- delete + drop parked pet +// C BUILD DELETE -- delete + drop parked pet; +// if is the active build, +// also full panel reset (unlearn +// + AE/TE refund) like RESET ALL // C BUILD LOAD -- swap to this build // C BUILD UNLOAD -- clear active pointer // C BUILD IMPORT -- copy a shared build @@ -1644,10 +1707,13 @@ 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; + if (QueryResult r = CharacterDatabase.Query( + "SELECT 1 FROM character_paragon_builds WHERE share_code = '{}'", code)) + continue; + if (QueryResult r2 = CharacterDatabase.Query( + "SELECT 1 FROM character_paragon_build_share_archive WHERE share_code = '{}'", code)) + continue; + return code; } // Worst-case fallback: append a numeric uniquifier from build_id // sequence. We can't produce a guaranteed-unique 6-char code if @@ -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(); + + 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() >= 64) + { + *err = "build limit reached (64)"; + return false; + } + } + + std::string insertName = name; + std::string insertIcon = icon; + CharacterDatabase.EscapeString(insertName); + CharacterDatabase.EscapeString(insertIcon); + std::string const code = GenerateUniqueShareCode(); + CharacterDatabase.DirectExecute( + "INSERT INTO character_paragon_builds (guid, name, icon, share_code) " + "VALUES ({}, '{}', '{}', '{}')", + lowGuid, insertName, insertIcon, code); + + QueryResult idRow = CharacterDatabase.Query( + "SELECT build_id FROM character_paragon_builds " + "WHERE share_code = '{}'", code); + if (!idRow) + { + *err = "save failed (could not allocate build_id)"; + return false; + } + uint32 const newBuildId = idRow->Fetch()[0].Get(); + + SnapshotBuildFromCurrent(pl, newBuildId); + SetActiveBuildId(lowGuid, newBuildId); + + PushBuildCatalog(pl); + LOG_INFO("module", + "Paragon build: {} saved current loadout as build {} '{}' (share {})", + pl->GetName(), newBuildId, name, code); + return true; +} + bool HandleBuildEdit(Player* pl, std::string const& payload, std::string* err) { if (!pl || pl->getClass() != CLASS_PARAGON) @@ -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(); - // 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(); + srcOwner = sf[1].Get(); + srcName = sf[2].Get(); + srcIcon = sf[3].Get(); + } + else if (QueryResult arch = CharacterDatabase.Query( + "SELECT name, icon FROM character_paragon_build_share_archive WHERE share_code = '{}'", code)) + { + fromArchive = true; + Field const* af = arch->Fetch(); + srcName = af[0].Get(); + srcIcon = af[1].Get(); + } + else { *err = "no build with that code"; return false; } - Field const* sf = srcRow->Fetch(); - uint32 srcBuildId = sf[0].Get(); - uint32 srcOwner = sf[1].Get(); - std::string srcName = sf[2].Get(); - std::string srcIcon = sf[3].Get(); - 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,22 +2456,64 @@ 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. - 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); + if (!fromArchive) + { + CharacterDatabase.DirectExecute( + "INSERT INTO character_paragon_build_spells (build_id, spell_id) " + "SELECT {}, spell_id FROM character_paragon_build_spells WHERE build_id = {}", + newBuildId, srcBuildId); - CharacterDatabase.DirectExecute( - "INSERT INTO character_paragon_build_talents (build_id, spec, talent_id, `rank`) " - "SELECT {}, spec, talent_id, `rank` FROM character_paragon_build_talents " - "WHERE build_id = {}", - newBuildId, srcBuildId); + CharacterDatabase.DirectExecute( + "INSERT INTO character_paragon_build_talents (build_id, spec, talent_id, `rank`) " + "SELECT {}, spec, talent_id, `rank` FROM character_paragon_build_talents " + "WHERE build_id = {}", + newBuildId, srcBuildId); + } + else + { + CharacterDatabase.DirectExecute( + "INSERT INTO character_paragon_build_spells (build_id, spell_id) " + "SELECT {}, spell_id FROM character_paragon_build_share_archive_spells " + "WHERE share_code = '{}'", + newBuildId, code); + + CharacterDatabase.DirectExecute( + "INSERT INTO character_paragon_build_talents (build_id, spec, talent_id, `rank`) " + "SELECT {}, spec, talent_id, `rank` FROM character_paragon_build_share_archive_talents " + "WHERE share_code = '{}'", + newBuildId, code); + } PushBuildCatalog(pl); - LOG_INFO("module", - "Paragon build: {} imported '{}' (src build {} owner {}) " - "as new build {} with code {}", - pl->GetName(), srcName, srcBuildId, srcOwner, newBuildId, newCode); + + // Hunter-pet hint: imports never carry a parked pet (see comment + // before the INSERT above). If the recipe contains Tame Beast + // (spell 1515) we surface a one-line system message so the player + // knows they need to tame their own pet before that build feels + // "complete". Other classes' pet-summon spells (Summon Imp, Raise + // Dead, ...) re-summon a fresh entity each cast so they don't + // need any heads-up. + QueryResult petCheck = CharacterDatabase.Query( + "SELECT 1 FROM character_paragon_build_spells " + "WHERE build_id = {} AND spell_id = 1515 LIMIT 1", newBuildId); + if (petCheck && pl->GetSession()) + { + std::string const msg = fmt::format( + "|cffffd200[Paragon]|r Imported \"{}\" includes Tame Beast. " + "Tame your own pet after loading this build -- the source " + "player's pet was not transferred.", srcName); + ChatHandler(pl->GetSession()).SendSysMessage(msg.c_str()); + } + + if (fromArchive) + LOG_INFO("module", + "Paragon build: {} imported archived code '{}' as new build {} (share {})", + pl->GetName(), code, newBuildId, newCode); + else + LOG_INFO("module", + "Paragon build: {} imported '{}' (src build {} owner {}) " + "as new build {} with code {}", + pl->GetName(), srcName, srcBuildId, srcOwner, newBuildId, newCode); return true; } @@ -2459,21 +2742,24 @@ 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) { - SnapshotBuildFromCurrent(pl, 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;