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:
@@ -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';
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user