diff --git a/modules/mod-paragon/src/Paragon_Essence.cpp b/modules/mod-paragon/src/Paragon_Essence.cpp index cfca94f..2c00d02 100644 --- a/modules/mod-paragon/src/Paragon_Essence.cpp +++ b/modules/mod-paragon/src/Paragon_Essence.cpp @@ -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("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()); + } while (r->NextRow()); + } + + uint32 const tePerRank = sConfigMgr->GetOption("Paragon.Currency.TE.TalentLearnCost", 1); + uint32 const aePerRank = sConfigMgr->GetOption("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 const rank = f[1].Get(); + 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:,,... t::,..." -// Both sub-lists are optional but the leading tags are not. Examples: +// "C COMMIT s:,,... t::,... u:,..." +// 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 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> const& spellsAndCosts, - std::vector> const& talentDeltas) + std::vector> const& talentDeltas, + std::vector 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(); + 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(); + 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 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 spellIds = ParseCsvUInt(spellsCsv); + std::vector unlearnRaw = ParseCsvUInt(unlearnCsv); // Talents are "id:delta,id:delta,...". Parse into vector of pairs. std::vector> 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 unlearnTrackSet; + std::vector 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); }