Files
Fractured/modules/mod-paragon/src/Paragon_SC.cpp
T
Docker Build 4d2a80ddb8 Paragon: server-authoritative CP/rune sync + cascade-spell revoke hardening
mod-paragon Paragon_Essence.cpp:
- Broaden SkillLinesLinkedToSpell: collect every SkillLineAbility row for
  an anchor spell regardless of AcquireMethod, so anchor spells whose
  primary SLA uses AcquireMethod 0 (e.g. Blood Strike) correctly identify
  their skill lines and let the dependent classifier do its job.
- IsSpellSkillLineCascadeDependent / RevokeUnwantedCascadeSpellsForPlayer
  use the broadened helper. HandleCommit calls the post-purchase sweep
  immediately so the spellbook never carries lingering cascade dependents
  (Blood Presence / Forceful Deflection / Death Coil / Death Grip).
- New character_paragon_panel_spell_revoked table tracks which active
  dependents we've revoked per (guid, parent) so OnPlayerLogin can
  re-revoke them after AC's _LoadSkills -> learnSkillRewardedSpells
  silently re-grants them.
- OnPlayerLogin opens the client SILENCE window via SendSilenceOpenForCommit
  with an empty allow list and intentionally omits the matching
  SendSilenceClose: the chat frame buffers CHAT_MSG_SYSTEM during the
  loading screen and only flushes after PLAYER_ENTERING_WORLD, so a paired
  CLOSE would shut the filter before the buffered "you have unlearned X"
  toasts hit it. The addon's 8s fail-open closes the window after the flush.
- New `.paragon hat` chat command for diagnosing Honor Among Thieves
  triggers (talent rank, learned spell, applied aura, proc table entry).

mod-paragon Paragon_SC.cpp:
- OnPlayerUpdate pushes server-authoritative combo points to the client
  via PARAA "R CP <n>" whenever the count changes. The client-side
  ComboFrame Paragon simulator listens for this and updates the target
  frame, fixing HAT-generated CP not displaying (HAT's trigger casts
  with a null target, which the combat-log inference path can't see).
- OnPlayerUpdate also pushes "R RUNES <cd0..cd5>" (ms remaining per
  rune slot) on rune mask changes, so the client RuneFrame simulator
  stays in lock-step with Spell::TakeRunePower instead of drifting
  through combat-log latency.

mod-paragon SQL:
- New updates/2026_05_09_00.sql migration creates
  character_paragon_panel_spell_revoked for AC's auto-DBUpdater so a
  fresh checkout can stand up an existing characters DB without
  manual intervention. Matching CREATE TABLE IF NOT EXISTS in
  base/character_paragon_panel_learned.sql for fresh installs.

mod-paragon conf:
- New Paragon.Diag.PanelLearn flag traces every PanelLearnSpellChain
  commit (chain ids, before/after spell-map sizes, side-spell
  classification) for diagnosing "spell reappears on relog" bugs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 11:22:57 -04:00

275 lines
11 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* mod-paragon — Paragon (class 12) class hooks
*
* See README for design. This file wires Player::IsClass / HasActivePowerType
* so Paragon can reuse other classes' mechanics in narrowly scoped contexts.
*/
#include "Chat.h"
#include "Config.h"
#include "GameTime.h"
#include "Log.h"
#include "ObjectGuid.h"
#include "Player.h"
#include "ScriptMgr.h"
#include "SharedDefines.h"
#include "UnitDefines.h"
#include "WorldPacket.h"
#include "WorldSession.h"
#include <fmt/format.h>
#include <string>
#include <unordered_map>
class Paragon_PlayerScript : public PlayerScript
{
public:
Paragon_PlayerScript() : PlayerScript("Paragon_PlayerScript", {
PLAYERHOOK_ON_PLAYER_IS_CLASS,
PLAYERHOOK_ON_PLAYER_HAS_ACTIVE_POWER_TYPE,
PLAYERHOOK_ON_UPDATE,
PLAYERHOOK_ON_LOGIN,
PLAYERHOOK_ON_LOGOUT,
PLAYERHOOK_ON_AFTER_UPDATE_MAX_POWER
})
{
LOG_INFO("module", "[paragon] Paragon_PlayerScript registered "
"(MultiResource.HasActivePowers={})",
sConfigMgr->GetOption<bool>("Paragon.MultiResource.HasActivePowers", false));
}
[[nodiscard]] Optional<bool> OnPlayerIsClass(Player const* player, Classes unitClass, ClassContext context) override
{
if (!player || player->getClass() != CLASS_PARAGON)
return std::nullopt;
// Death Knight rune / runic power ability stack (narrow on purpose).
if (unitClass == CLASS_DEATH_KNIGHT && context == CLASS_CONTEXT_ABILITY)
return true;
// Warrior ability stack: enables warrior-spec ability gates anywhere
// they're checked. None of the currently-traced sites in core/scripts
// gate on (CLASS_WARRIOR, CLASS_CONTEXT_ABILITY), so this is a safe
// forward-compatible claim. Rage generation itself is gated on
// HasActivePowerType(POWER_RAGE) and is wired below.
if (unitClass == CLASS_WARRIOR && context == CLASS_CONTEXT_ABILITY)
return true;
// Reactive melee states: Overpower-on-dodge (warrior), Counterattack window (hunter).
// We intentionally do NOT claim CLASS_ROGUE here: that context skips the generic
// AURA_STATE_DEFENSE update on dodge (Riposte path) in Unit::ProcDamageAndSpellFor.
if (context == CLASS_CONTEXT_ABILITY_REACTIVE)
{
if (unitClass == CLASS_WARRIOR || unitClass == CLASS_HUNTER)
return true;
}
return std::nullopt;
}
[[nodiscard]] bool OnPlayerHasActivePowerType(Player const* player, Powers power) override
{
if (!player || player->getClass() != CLASS_PARAGON)
return false;
if (power == POWER_RUNIC_POWER || power == POWER_RUNE)
return true;
if (sConfigMgr->GetOption<bool>("Paragon.MultiResource.HasActivePowers", false))
{
switch (power)
{
case POWER_MANA:
case POWER_RAGE:
case POWER_ENERGY:
case POWER_FOCUS:
return true;
default:
break;
}
}
return false;
}
// ChrClasses.dbc says POWER_RUNE has no pool (Unit::GetCreatePowers returns 0
// for POWER_RUNE), so Player::InitStatsForLevel and the level-reset path
// both clobber MaxPower(POWER_RUNE)=0 every login. The 3.3.5 client greys
// the action button for any rune-cost spell when UnitPowerMax(player,
// SPELL_POWER_RUNES) is 0 — visual rune pips can still animate via
// PLAYER_RUNE_REGEN_*, but the spell stays unusable.
//
// Fix: pin POWER_RUNE max=8 (and current=8 in the easy spots) for Paragon
// anywhere UpdateMaxPower runs. Rune availability is still authoritatively
// tracked in m_runes; this just keeps the client engine happy.
void OnPlayerAfterUpdateMaxPower(Player* player, Powers& power, float& value) override
{
if (!player || player->getClass() != CLASS_PARAGON)
return;
if (power == POWER_RUNE)
value = 8.0f;
else if (power == POWER_RUNIC_POWER)
value = 1000.0f;
}
// Login: re-claim MaxPower for POWER_RUNE / POWER_RUNIC_POWER and seed
// the rune state to the client. Player::LoadFromDB runs InitStatsForLevel
// which calls Unit::SetMaxPower with the value from GetCreatePowers — and
// GetCreatePowers returns 0 for POWER_RUNE (engine assumption: only DK has
// runes; class is identified via getClass(), not IsClass()). So we have to
// overwrite MaxPower here once the player is fully loaded. ResyncRunes is
// a one-shot push so the rune frame is correct on first appearance.
void OnPlayerLogin(Player* player) override
{
if (!player || player->getClass() != CLASS_PARAGON)
return;
player->SetMaxPower(POWER_RUNE, 8);
if (player->GetMaxPower(POWER_RUNIC_POWER) <= 0)
player->SetMaxPower(POWER_RUNIC_POWER, 1000);
player->ResyncRunes(MAX_RUNES);
ParagonRuneSyncState seed;
seed.lastReadyMask = 0;
for (uint8 i = 0; i < MAX_RUNES; ++i)
if (player->GetRuneCooldown(i) == 0)
seed.lastReadyMask |= (1u << i);
runeSyncByGuid[player->GetGUID()] = seed;
}
void OnPlayerLogout(Player* player) override
{
if (!player)
return;
runeSyncByGuid.erase(player->GetGUID());
}
// Per-tick rune state pump for Paragon.
//
// Two responsibilities:
//
// 1. UNIT_FIELD_POWER5 (POWER_RUNE) tracks the live ready-rune count. The
// 3.3.5 client's local "is this action usable" check on rune-cost
// spells reads UnitPower(player, SPELL_POWER_RUNES); for DK that's
// engine-managed but Paragon must own it.
//
// 2. Push SMSG_RESYNC_RUNES *only* on ready-bitmask transitions (rune
// consumed → on cd, or finished cd → ready). We deliberately do NOT
// push during the cooldown — Player::ResyncRunes encodes the per-rune
// "passed cooldown" byte as `255 - cd_ms * 51`, which is monotonic
// only for cd values in seconds (0..5). With cd in milliseconds the
// byte oscillates wildly tick-to-tick (cd=10000 → 207, cd=9000 → 7,
// cd=8000 → 63). Frequent resyncs feed the client garbage and freeze
// the rune frame. One mask-change resync is enough; sweeps run on the
// client's own 10s timer started from SMSG_SPELL_GO's CAST_FLAG_RUNE_LIST
// payload (which uses correct encoding) plus a Lua-side
// GetRuneCooldown poll in RuneFrame.lua.
void OnPlayerUpdate(Player* player, uint32 /*p_time*/) override
{
if (!player || player->getClass() != CLASS_PARAGON)
return;
uint8 readyMask = 0;
uint8 readyCount = 0;
for (uint8 i = 0; i < MAX_RUNES; ++i)
{
if (player->GetRuneCooldown(i) == 0)
{
readyMask |= (1u << i);
++readyCount;
}
}
if (uint32(readyCount) != player->GetPower(POWER_RUNE))
player->SetPower(POWER_RUNE, readyCount);
ParagonRuneSyncState& st = runeSyncByGuid[player->GetGUID()];
if (readyMask != st.lastReadyMask)
{
player->ResyncRunes(MAX_RUNES);
st.lastReadyMask = readyMask;
// Authoritative rune CD pump (PARAA "R RUNES cd0 cd1 ... cd5",
// ms remaining per slot, 0 = ready). The 3.3.5 client engine
// class-gates SMSG_RESYNC_RUNES / SMSG_SPELL_GO RUNE_LIST to DK,
// so the Paragon RuneFrame sim drives the visual entirely off
// COMBAT_LOG_EVENT_UNFILTERED:SPELL_CAST_SUCCESS. The combat log
// arrives ~100200ms after the server already started the
// cooldown, so the client's local timer trails the server. When
// the user spams a rune spell, the server's slot refreshes
// first, accepts the next cast, but the client UI still shows
// CD remaining → "leak-through" past a greyed icon. Pushing the
// actual remaining ms on every mask transition keeps the
// visual locked to server state.
std::string body = "R RUNES";
for (uint8 i = 0; i < MAX_RUNES; ++i)
body += " " + std::to_string(player->GetRuneCooldown(i));
std::string const payload = std::string(kParagonAddonPrefix) + "\t" + body;
WorldPacket runePkt;
ChatHandler::BuildChatPacket(runePkt, CHAT_MSG_WHISPER, LANG_ADDON, player, player, payload);
player->SendDirectMessage(&runePkt);
}
// Combo point pump: the 3.3.5 client engine class-gates SMSG_UPDATE_COMBO_POINTS
// to rogue / druid, so the Paragon UI sim never sees CP changes from
// Honor Among Thieves / Mutilate / etc. via either the engine state or
// the client-side combat-log inference (HAT's 51699 trigger fires with a
// null target and doesn't always emit SPELL_CAST_SUCCESS in the log).
// Push the count over PARAA whenever it changes; the addon's combo
// simulator listens for "R CP <n>" and overwrites paragonCP, so the
// ComboFrame on the target frame paints reliably.
int8 const cp = player->GetComboPoints();
if (cp != st.lastCp)
{
std::string const payload = std::string(kParagonAddonPrefix) + "\t"
+ fmt::format("R CP {}", int32(cp));
WorldPacket data;
ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER, LANG_ADDON, player, player, payload);
player->SendDirectMessage(&data);
st.lastCp = cp;
}
if (!sConfigMgr->GetOption<bool>("Paragon.Diag.RuneTrace", false))
return;
static thread_local time_t lastLogged = 0;
time_t const wall = GameTime::GetGameTime().count();
if (wall - lastLogged < 5)
return;
lastLogged = wall;
std::string out;
for (uint8 i = 0; i < MAX_RUNES; ++i)
{
char buf[64];
snprintf(buf, sizeof(buf), "[%u: type=%u cur=%u cd=%u]",
i,
uint32(player->GetBaseRune(i)),
uint32(player->GetCurrentRune(i)),
uint32(player->GetRuneCooldown(i)));
out += buf;
}
LOG_INFO("module", "[paragon-diag] {} runes: {} mask=0x{:02x}",
player->GetName(), out, uint32(readyMask));
}
private:
struct ParagonRuneSyncState
{
uint8 lastReadyMask{0xFFu}; // sentinel: no prior snapshot
int8 lastCp{-1}; // sentinel: no prior snapshot
};
static constexpr char const* kParagonAddonPrefix = "PARAA";
static std::unordered_map<ObjectGuid, ParagonRuneSyncState> runeSyncByGuid;
};
std::unordered_map<ObjectGuid, Paragon_PlayerScript::ParagonRuneSyncState> Paragon_PlayerScript::runeSyncByGuid;
void AddSC_paragon()
{
new Paragon_PlayerScript();
}