Fractured: Paragon core hooks, mod-paragon, mod-ale, Docker build cap

- Track mod-paragon and mod-ale (un-ignore modules in .gitignore).
- Ship docker-compose.override.yml with CMAKE_EXTRA_OPTIONS for LuaJIT (mod-ale).
- Dockerfile: CBUILD_PARALLEL default to limit OOM under Docker/WSL2.
- Core: CLASS_PARAGON sticky combo points (DetachComboTarget), selection rebind,
  Spell::CheckPower rune path for multi-resource Paragon.
- spell_dk_death_rune: IsClass(CLASS_DEATH_KNIGHT, CLASS_CONTEXT_ABILITY) for
  Blood of the North / Reaping / DRM on Paragon.
- Remove temporary Paragon CheckPower logging.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Docker Build
2026-05-08 00:03:09 -04:00
parent f9f2bc5e0c
commit 8e4c8f57e4
163 changed files with 54817 additions and 10 deletions
+227
View File
@@ -0,0 +1,227 @@
/*
* 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 "Player.h"
#include "ScriptMgr.h"
#include "SharedDefines.h"
#include "UnitDefines.h"
#include "Config.h"
#include "Log.h"
#include "GameTime.h"
#include "ObjectGuid.h"
#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;
}
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
};
static std::unordered_map<ObjectGuid, ParagonRuneSyncState> runeSyncByGuid;
};
std::unordered_map<ObjectGuid, Paragon_PlayerScript::ParagonRuneSyncState> Paragon_PlayerScript::runeSyncByGuid;
void AddSC_paragon()
{
new Paragon_PlayerScript();
}