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);
|
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)
|
||||||
@@ -1111,10 +1282,20 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user