Paragon: spell unlearn queue + AE/TE reconciliation

Two related additions to mod-paragon:

  * HandleCommit gains a third payload section, " u:<id>,...", carrying
    spell IDs the player wants to refund + unlearn in the same commit
    that learns / talents through. The protocol stays backward-compat
    (older clients omit the section). PanelUnlearnSpellPurchase mirrors
    the per-spell branch of HandleParagonResetAbilities: tracked passive
    children are removed first, then the chain head, then panel_spells /
    panel_spell_children / panel_spell_revoked rows for that purchase
    are dropped, then LookupSpellAECost(head) is refunded into the
    cache. Unlearns are applied before learns inside the commit so the
    refund covers the same-commit spends. Allow-list for the silence
    window now includes chain ranks + panel_spell_children for the
    intentional unlearns so "You have unlearned X" toasts stay visible
    for the targeted spell while cascade dependents stay silenced.

  * ReconcileEssenceForPlayer reads panel_spells + panel_talents and
    sets the cache to ComputeStartingAE/TE(level) - sum-of-spends.
    Self-heals drift in either direction: clamps the cache down when
    the player has more essence than their level + spends allow
    (cheese clamp), and tops up when they have less (admin-tweak /
    crash recovery). Wired into OnPlayerLogin (after LoadCurrencyFromDb,
    before PushCurrency so the first balance the client sees is the
    reconciled one) and OnPlayerLevelChanged (replaces the old
    GrantLevelUpEssence delta -- Reconcile sets the absolute correct
    balance from level + spend, so it subsumes the per-level grant and
    the cheese clamp in one call). Costs come from the same
    paragon_spell_ae_cost / config keys HandleCommit uses so the math
    stays in lockstep across any future cost rebalance.

Both features ship in patch-enUS-6.MPQ v0.9.16: right-click a learned
spell row to queue an unlearn (header shows +N AE refund preview) and
hit Learn All to apply. The icon picker also got two fixes -- the
leading INV_Misc_QuestionMark is no longer duplicated, and the
selection ring is now a tooltip-border Frame anchored to the cell
bounds (the prior UI-ActionButton-Border texture rendered nearly
invisible at non-native sizes).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Docker Build
2026-05-10 19:57:14 -04:00
parent 7028258084
commit 9fb80102c8
+249 -15
View File
@@ -257,6 +257,87 @@ uint32 GetParagonPanelSpellRequiredLevel(SpellInfo const* info)
return std::max(1u, lv); return std::max(1u, lv);
} }
// Reconciles the player's AE/TE cache against what they SHOULD have
// based on level (ComputeStartingAE/TE) minus what they've spent through
// Character Advancement (sum over character_paragon_panel_spells +
// character_paragon_panel_talents). Updates the cache + DB if either
// direction drifts:
// * actual < expected: top up (handles per-level grants automatically;
// also self-heals from admin commands / crashes that lost essence).
// * actual > expected: clamp down (prevents .modify-style cheese, ghost
// panel rows that were rolled back, or any path that left more
// essence than the level allowed).
// Logs at INFO when drift is corrected so we can spot abuse patterns.
//
// Cheap (two SELECTs of small per-character tables) and safe to call from
// OnPlayerLogin and OnPlayerLevelChanged. SAFE TO CALL ANY TIME the panel
// DB is in a steady state (i.e. NOT mid-HandleCommit).
void ReconcileEssenceForPlayer(Player* pl)
{
if (!pl || pl->getClass() != CLASS_PARAGON)
return;
if (!sConfigMgr->GetOption<bool>("Paragon.Currency.Enabled", true))
return;
uint32 const lowGuid = pl->GetGUID().GetCounter();
uint8 const level = pl->GetLevel();
// Sum AE / TE spent through panel purchases. Mirrors the cost lookups
// used by HandleCommit so reconciliation matches the spend math byte-
// for-byte (no off-by-one if config keys are tweaked at runtime).
uint32 spentAE = 0;
uint32 spentTE = 0;
if (QueryResult r = CharacterDatabase.Query(
"SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid))
{
do
{
spentAE += LookupSpellAECost(r->Fetch()[0].Get<uint32>());
} while (r->NextRow());
}
uint32 const tePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
uint32 const aePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.TalentLearnCost", 1);
if (QueryResult r = CharacterDatabase.Query(
"SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", lowGuid))
{
do
{
Field const* f = r->Fetch();
uint32 const tid = f[0].Get<uint32>();
uint32 const rank = f[1].Get<uint32>();
TalentEntry const* te = sTalentStore.LookupEntry(tid);
if (!te || !rank)
continue;
spentTE += rank * tePerRank;
if (te->addToSpellBook)
spentAE += rank * aePerRank;
} while (r->NextRow());
}
uint32 const expectedTotalAE = ComputeStartingAE(level);
uint32 const expectedTotalTE = ComputeStartingTE(level);
uint32 const expectedBalAE = expectedTotalAE > spentAE ? expectedTotalAE - spentAE : 0;
uint32 const expectedBalTE = expectedTotalTE > spentTE ? expectedTotalTE - spentTE : 0;
ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid);
if (d.abilityEssence == expectedBalAE && d.talentEssence == expectedBalTE)
return;
LOG_INFO("module",
"Paragon essence reconciled for {} (lvl {}): AE {}->{} TE {}->{} (spent AE={} TE={}, expected total AE={} TE={})",
pl->GetName(), uint32(level),
d.abilityEssence, expectedBalAE,
d.talentEssence, expectedBalTE,
spentAE, spentTE,
expectedTotalAE, expectedTotalTE);
d.abilityEssence = expectedBalAE;
d.talentEssence = expectedBalTE;
SaveCurrencyToDb(pl);
}
// Forward declaration: reset handlers below need PushSnapshot, which itself // Forward declaration: reset handlers below need PushSnapshot, which itself
// is defined later (after PushSpellSnapshot / PushTalentSnapshot). // is defined later (after PushSpellSnapshot / PushTalentSnapshot).
void PushSnapshot(Player* pl); void PushSnapshot(Player* pl);
@@ -989,10 +1070,12 @@ uint32 ComputeTalentRankAnySpec(Player* pl, uint32 talentId)
// ---- Commit handler -------------------------------------------------------- // ---- Commit handler --------------------------------------------------------
// //
// Wire format from Net.lua Net:Commit: // Wire format from Net.lua Net:Commit:
// "C COMMIT s:<id1>,<id2>,...<empty allowed> t:<talentId>:<delta>,..." // "C COMMIT s:<id1>,<id2>,...<empty allowed> t:<talentId>:<delta>,... u:<id>,..."
// Both sub-lists are optional but the leading tags are not. Examples: // The " u:" spell-unlearn section is optional (omitted by older clients).
// Both s: and t: leading tags are required. Examples:
// "C COMMIT s:5176,8921 t:" // "C COMMIT s:5176,8921 t:"
// "C COMMIT s: t:1234:1,5678:2" // "C COMMIT s: t:1234:1,5678:2"
// "C COMMIT s: t: u:45477"
// We parse leniently and abort on any structural error. // We parse leniently and abort on any structural error.
// //
// On success we push: // On success we push:
@@ -1035,12 +1118,15 @@ std::vector<uint32> ParseCsvUInt(std::string_view csv)
// * full chain ids for every spell the player explicitly purchased (so // * full chain ids for every spell the player explicitly purchased (so
// the player still sees "Plague Strike (Rank 1..N) learned" toasts); // the player still sees "Plague Strike (Rank 1..N) learned" toasts);
// * every talent rank id the player explicitly purchased (so addToSpellBook // * every talent rank id the player explicitly purchased (so addToSpellBook
// talents like Bladestorm/Starfall still toast normally). // talents like Bladestorm/Starfall still toast normally);
// * chain ids + tracked passive children for spells intentionally unlearned
// in this commit (so "You have unlearned …" for those stays visible).
// Anything else learned/unlearned during the window -- the SkillLineAbility // Anything else learned/unlearned during the window -- the SkillLineAbility
// cascade and our diff-revoke cleanup -- is silenced. // cascade and our diff-revoke cleanup -- is silenced.
void SendSilenceOpenForCommit(Player* pl, void SendSilenceOpenForCommit(Player* pl,
std::vector<std::pair<uint32, uint32>> const& spellsAndCosts, std::vector<std::pair<uint32, uint32>> const& spellsAndCosts,
std::vector<std::pair<uint32, uint32>> const& talentDeltas) std::vector<std::pair<uint32, uint32>> const& talentDeltas,
std::vector<uint32> const& unlearnTrackIds = {})
{ {
if (!pl) if (!pl)
return; return;
@@ -1049,6 +1135,26 @@ void SendSilenceOpenForCommit(Player* pl,
for (auto const& kv : spellsAndCosts) for (auto const& kv : spellsAndCosts)
CollectSpellChainIds(kv.first, allow); CollectSpellChainIds(kv.first, allow);
uint32 const lowGuid = pl->GetGUID().GetCounter();
for (uint32 trackId : unlearnTrackIds)
{
if (!trackId)
continue;
CollectSpellChainIds(trackId, allow);
if (QueryResult cr = CharacterDatabase.Query(
"SELECT child_spell_id FROM character_paragon_panel_spell_children "
"WHERE guid = {} AND parent_spell_id = {}",
lowGuid, trackId))
{
do
{
uint32 const cid = cr->Fetch()[0].Get<uint32>();
if (cid)
allow.insert(cid);
} while (cr->NextRow());
}
}
for (auto const& [tid, delta] : talentDeltas) for (auto const& [tid, delta] : talentDeltas)
{ {
(void)delta; (void)delta;
@@ -1094,6 +1200,71 @@ void SendSilenceClose(Player* pl)
SendAddonMessage(pl, "R SILENCE CLOSE"); SendAddonMessage(pl, "R SILENCE CLOSE");
} }
// Removes one Character Advancement spell purchase (chain head in
// character_paragon_panel_spells). Refunds that row's AE cost, unlearns
// tracked passive children then the parent chain, and clears matching
// panel_* DB rows (mirrors the per-spell portion of HandleParagonResetAbilities).
// `spellId` may be any rank id from the bake; normalized to GetFirstSpellInChain.
bool PanelUnlearnSpellPurchase(Player* pl, uint32 spellId, std::string* err)
{
if (!pl || !spellId)
{
if (err)
*err = "bad player or spell";
return false;
}
uint32 const lowGuid = pl->GetGUID().GetCounter();
uint32 const head = sSpellMgr->GetFirstSpellInChain(spellId);
uint32 const sid = head ? head : spellId;
if (!CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {} LIMIT 1",
lowGuid, sid))
{
if (err)
*err = fmt::format("spell {} is not a panel purchase", sid);
return false;
}
uint32 const refund = LookupSpellAECost(sid);
if (QueryResult cr = CharacterDatabase.Query(
"SELECT child_spell_id FROM character_paragon_panel_spell_children "
"WHERE guid = {} AND parent_spell_id = {}",
lowGuid, sid))
{
do
{
uint32 const cid = cr->Fetch()[0].Get<uint32>();
if (pl->HasSpell(cid))
pl->removeSpell(cid, SPEC_MASK_ALL, false);
} while (cr->NextRow());
}
if (pl->HasSpell(sid))
pl->removeSpell(sid, SPEC_MASK_ALL, false);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_panel_spell_children WHERE guid = {} AND parent_spell_id = {}",
lowGuid, sid);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_panel_spell_revoked WHERE guid = {} AND parent_spell_id = {}",
lowGuid, sid);
std::unordered_set<uint32> chainIds;
CollectSpellChainIds(sid, chainIds);
DbDeletePanelSpellRevokedForChain(lowGuid, chainIds);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {}",
lowGuid, sid);
ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid);
d.abilityEssence += refund;
return true;
}
bool HandleCommit(Player* pl, std::string const& body, std::string* err) bool HandleCommit(Player* pl, std::string const& body, std::string* err)
{ {
// Strip leading "C COMMIT " (already stripped by caller, but be defensive) // Strip leading "C COMMIT " (already stripped by caller, but be defensive)
@@ -1112,9 +1283,19 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err)
} }
std::string_view spellsCsv = rest.substr(2, tPos - 2); std::string_view spellsCsv = rest.substr(2, tPos - 2);
std::string_view talentsCsv = rest.substr(tPos + 3); std::string_view talentsCsv;
std::string_view unlearnCsv;
size_t const uPos = rest.find(" u:", tPos);
if (uPos != std::string_view::npos)
{
talentsCsv = rest.substr(tPos + 3, uPos - (tPos + 3));
unlearnCsv = rest.substr(uPos + 3);
}
else
talentsCsv = rest.substr(tPos + 3);
std::vector<uint32> spellIds = ParseCsvUInt(spellsCsv); std::vector<uint32> spellIds = ParseCsvUInt(spellsCsv);
std::vector<uint32> unlearnRaw = ParseCsvUInt(unlearnCsv);
// Talents are "id:delta,id:delta,...". Parse into vector of pairs. // Talents are "id:delta,id:delta,...". Parse into vector of pairs.
std::vector<std::pair<uint32, uint32>> talentDeltas; std::vector<std::pair<uint32, uint32>> talentDeltas;
@@ -1152,12 +1333,37 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err)
} }
} }
if (spellIds.size() + talentDeltas.size() > kCommitMaxItems) std::unordered_set<uint32> unlearnTrackSet;
std::vector<uint32> unlearnTracks;
for (uint32 raw : unlearnRaw)
{
if (!raw)
continue;
uint32 const head = sSpellMgr->GetFirstSpellInChain(raw);
uint32 const tid = head ? head : raw;
if (unlearnTrackSet.insert(tid).second)
unlearnTracks.push_back(tid);
}
if (spellIds.size() + talentDeltas.size() + unlearnTracks.size() > kCommitMaxItems)
{ {
*err = "commit exceeds size cap"; *err = "commit exceeds size cap";
return false; return false;
} }
uint32 unlearnRefundAE = 0;
for (uint32 tid : unlearnTracks)
{
if (!CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {} LIMIT 1",
pl->GetGUID().GetCounter(), tid))
{
*err = fmt::format("cannot unlearn {} (not a panel purchase)", tid);
return false;
}
unlearnRefundAE += LookupSpellAECost(tid);
}
// Pre-validate spells: must be valid SpellInfo, not already learned, // Pre-validate spells: must be valid SpellInfo, not already learned,
// and afford their combined AE cost. // and afford their combined AE cost.
uint32 totalAE = 0; uint32 totalAE = 0;
@@ -1181,6 +1387,13 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err)
*err = fmt::format("requires level {} for spell {}", reqLv, id); *err = fmt::format("requires level {} for spell {}", reqLv, id);
return false; return false;
} }
uint32 const learnHead = sSpellMgr->GetFirstSpellInChain(id);
uint32 const learnTrack = learnHead ? learnHead : id;
if (unlearnTrackSet.count(learnTrack))
{
*err = "cannot learn and unlearn the same spell in one commit";
return false;
}
uint32 cost = LookupSpellAECost(id); uint32 cost = LookupSpellAECost(id);
spellsAndCosts.emplace_back(id, cost); spellsAndCosts.emplace_back(id, cost);
totalAE += cost; totalAE += cost;
@@ -1222,9 +1435,10 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err)
*err = fmt::format("not enough TE (need {} have {})", talentsTE, GetTE(pl)); *err = fmt::format("not enough TE (need {} have {})", talentsTE, GetTE(pl));
return false; return false;
} }
if (GetAE(pl) < (totalAE + talentsAE)) if (GetAE(pl) + unlearnRefundAE < (totalAE + talentsAE))
{ {
*err = fmt::format("not enough AE (need {} have {})", totalAE + talentsAE, GetAE(pl)); *err = fmt::format("not enough AE (need {} total; you have {} plus {} from unlearns in this commit)",
totalAE + talentsAE, GetAE(pl), unlearnRefundAE);
return false; return false;
} }
@@ -1234,8 +1448,18 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err)
// learnSpell drags along (Death Coil/Death Grip/Blood Plague/Blood // learnSpell drags along (Death Coil/Death Grip/Blood Plague/Blood
// Presence/Forceful Deflection/Runic Focus/...) don't spam learn/ // Presence/Forceful Deflection/Runic Focus/...) don't spam learn/
// unlearn toasts. Allow list = chain ranks of explicitly purchased // unlearn toasts. Allow list = chain ranks of explicitly purchased
// spells + talent rank ids. Closed below at the end of the commit. // spells + talent rank ids + chains/children for intentional unlearns.
SendSilenceOpenForCommit(pl, spellsAndCosts, talentDeltas); SendSilenceOpenForCommit(pl, spellsAndCosts, talentDeltas, unlearnTracks);
// Apply spell unlearns first so refunded AE is available for spends.
for (uint32 tid : unlearnTracks)
{
if (!PanelUnlearnSpellPurchase(pl, tid, err))
{
SendSilenceClose(pl);
return false;
}
}
// Apply spells: each consumes its individual AE cost. PanelLearnSpellChain // Apply spells: each consumes its individual AE cost. PanelLearnSpellChain
// also grants every higher rank up to the player's current level so the // also grants every higher rank up to the player's current level so the
@@ -2924,6 +3148,13 @@ public:
void OnPlayerLogin(Player* player) override void OnPlayerLogin(Player* player) override
{ {
LoadCurrencyFromDb(player); LoadCurrencyFromDb(player);
// Verify AE/TE matches what the player's level + panel spend
// permit. Self-heals admin / crash drift in either direction
// and is a no-op (just two small SELECTs) when the balance is
// already correct. Has to run BEFORE PushCurrency so the
// client's first balance update of the session is the
// reconciled one.
ReconcileEssenceForPlayer(player);
PushCurrency(player); PushCurrency(player);
PushSnapshot(player); PushSnapshot(player);
PushBuildCatalog(player); PushBuildCatalog(player);
@@ -3003,7 +3234,7 @@ public:
// Player isn't fully in-world here; OnPlayerLogin will push. // Player isn't fully in-world here; OnPlayerLogin will push.
} }
void OnPlayerLevelChanged(Player* player, uint8 oldLevel) override void OnPlayerLevelChanged(Player* player, uint8 /*oldLevel*/) override
{ {
if (!player || player->getClass() != CLASS_PARAGON) if (!player || player->getClass() != CLASS_PARAGON)
return; return;
@@ -3017,10 +3248,13 @@ public:
if (gParagonCurrencyCache.find(lowGuid) == gParagonCurrencyCache.end()) if (gParagonCurrencyCache.find(lowGuid) == gParagonCurrencyCache.end())
LoadCurrencyFromDb(player); LoadCurrencyFromDb(player);
GrantLevelUpEssence(player, oldLevel, player->GetLevel()); // Single source of truth: ComputeStartingAE/TE(newLevel) - spent.
// Subsumes the old GrantLevelUpEssence per-level delta AND catches
// Persist the grant immediately so a crash before next save doesn't // drift in both directions (cheese clamp + restore-from-loss).
// lose freshly-awarded essence. Cheap (single REPLACE). // SaveCurrencyToDb runs inside Reconcile when drift is detected;
// call it once more here so a no-drift level-up still flushes any
// pending cache changes from this session.
ReconcileEssenceForPlayer(player);
SaveCurrencyToDb(player); SaveCurrencyToDb(player);
PushCurrency(player); PushCurrency(player);
} }