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:
@@ -257,6 +257,87 @@ uint32 GetParagonPanelSpellRequiredLevel(SpellInfo const* info)
|
||||
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
|
||||
// is defined later (after PushSpellSnapshot / PushTalentSnapshot).
|
||||
void PushSnapshot(Player* pl);
|
||||
@@ -989,10 +1070,12 @@ uint32 ComputeTalentRankAnySpec(Player* pl, uint32 talentId)
|
||||
// ---- Commit handler --------------------------------------------------------
|
||||
//
|
||||
// Wire format from Net.lua Net:Commit:
|
||||
// "C COMMIT s:<id1>,<id2>,...<empty allowed> t:<talentId>:<delta>,..."
|
||||
// Both sub-lists are optional but the leading tags are not. Examples:
|
||||
// "C COMMIT s:<id1>,<id2>,...<empty allowed> t:<talentId>:<delta>,... u:<id>,..."
|
||||
// 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: t:1234:1,5678:2"
|
||||
// "C COMMIT s: t: u:45477"
|
||||
// We parse leniently and abort on any structural error.
|
||||
//
|
||||
// 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
|
||||
// the player still sees "Plague Strike (Rank 1..N) learned" toasts);
|
||||
// * 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
|
||||
// cascade and our diff-revoke cleanup -- is silenced.
|
||||
void SendSilenceOpenForCommit(Player* pl,
|
||||
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)
|
||||
return;
|
||||
@@ -1049,6 +1135,26 @@ void SendSilenceOpenForCommit(Player* pl,
|
||||
for (auto const& kv : spellsAndCosts)
|
||||
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)
|
||||
{
|
||||
(void)delta;
|
||||
@@ -1094,6 +1200,71 @@ void SendSilenceClose(Player* pl)
|
||||
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)
|
||||
{
|
||||
// Strip leading "C COMMIT " (already stripped by caller, but be defensive)
|
||||
@@ -1111,10 +1282,20 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err)
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string_view spellsCsv = rest.substr(2, tPos - 2);
|
||||
std::string_view talentsCsv = rest.substr(tPos + 3);
|
||||
std::string_view spellsCsv = rest.substr(2, tPos - 2);
|
||||
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> unlearnRaw = ParseCsvUInt(unlearnCsv);
|
||||
|
||||
// Talents are "id:delta,id:delta,...". Parse into vector of pairs.
|
||||
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";
|
||||
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,
|
||||
// and afford their combined AE cost.
|
||||
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);
|
||||
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);
|
||||
spellsAndCosts.emplace_back(id, 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));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// Presence/Forceful Deflection/Runic Focus/...) don't spam learn/
|
||||
// unlearn toasts. Allow list = chain ranks of explicitly purchased
|
||||
// spells + talent rank ids. Closed below at the end of the commit.
|
||||
SendSilenceOpenForCommit(pl, spellsAndCosts, talentDeltas);
|
||||
// spells + talent rank ids + chains/children for intentional unlearns.
|
||||
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
|
||||
// also grants every higher rank up to the player's current level so the
|
||||
@@ -2924,6 +3148,13 @@ public:
|
||||
void OnPlayerLogin(Player* player) override
|
||||
{
|
||||
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);
|
||||
PushSnapshot(player);
|
||||
PushBuildCatalog(player);
|
||||
@@ -3003,7 +3234,7 @@ public:
|
||||
// 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)
|
||||
return;
|
||||
@@ -3017,10 +3248,13 @@ public:
|
||||
if (gParagonCurrencyCache.find(lowGuid) == gParagonCurrencyCache.end())
|
||||
LoadCurrencyFromDb(player);
|
||||
|
||||
GrantLevelUpEssence(player, oldLevel, player->GetLevel());
|
||||
|
||||
// Persist the grant immediately so a crash before next save doesn't
|
||||
// lose freshly-awarded essence. Cheap (single REPLACE).
|
||||
// Single source of truth: ComputeStartingAE/TE(newLevel) - spent.
|
||||
// Subsumes the old GrantLevelUpEssence per-level delta AND catches
|
||||
// drift in both directions (cheese clamp + restore-from-loss).
|
||||
// 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);
|
||||
PushCurrency(player);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user