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
+32
View File
@@ -0,0 +1,32 @@
# mod-paragon
Server-side support module for the custom `CLASS_PARAGON` (id 12).
## What it does today
Hooks `Player::IsClass` / `Player::HasActivePowerType` so Paragon
inherits Death Knight ability mechanics where it matters:
- **Rune system**: `InitRunes`, rune cooldown tick, runic power regen,
`Spell::CheckRuneCost` / `Spell::TakeRunePower`, `SMSG_RESYNC_RUNES`
cast flags, combat exit grace reset.
- **DK ability scripts**: `spell_dk_*`, `MUST_BE_DEATH_KNIGHT` cast
result, DK aura effects.
It is intentionally narrow: the hook only fires for
`(realClass == PARAGON, queriedClass == DEATH_KNIGHT, context == CLASS_CONTEXT_ABILITY)`.
Other DK-flavored contexts (Ebon Hold teleport gating, heroic start
level, DK-only taxi mount, talent point math on Ebon Hold, etc.) keep
their normal behavior for Paragon.
`HasActivePowerType` additionally claims `POWER_RUNIC_POWER` and
`POWER_RUNE` for Paragon, which keeps the rune pool sized correctly
in `Player::InitStatsForLevel` even if Paragon's primary power type is
later switched away from runic power.
## Building
Auto-detected by `modules/CMakeLists.txt` (`GetModuleSourceList` globs
every subdirectory). No additional CMake plumbing is needed; rebuild
the worldserver image and the loader symbol `Addmod_paragonScripts`
gets linked into the static `modules` target.
@@ -0,0 +1,34 @@
# mod-paragon — Paragon (class 12) server options
#
# Copy to mod_paragon.conf in the same directory to override defaults.
[worldserver]
# Sticky combo points (server-side): CP pool is per-player; switching targets
# does not reset points. Implemented in core Unit::AddComboPoints / GetComboPoints.
Paragon.StickyComboPoints = 1
# When enabled, Paragon answers HasActivePowerType() for mana/rage/energy/focus
# in addition to runes/runic power. Required for the patch-enUS-5.MPQ player
# frame to populate Mana/Rage/Energy bars - otherwise the server treats those
# powers as inactive and never sends max values, leaving the bars empty.
Paragon.MultiResource.HasActivePowers = 1
# Ability / Talent Essence (AE/TE) — Ascension-inspired currency
Paragon.Currency.Enabled = 1
Paragon.Currency.AE.Start = 9
Paragon.Currency.TE.Start = 0
# From this character level onward, each level-up grants both essences once.
Paragon.Currency.GrantLevelMin = 10
Paragon.Currency.AE.PerLevel = 1
Paragon.Currency.TE.PerLevel = 1
# Flat TE cost per successful talent rank learn (hook runs once per LearnTalent).
Paragon.Currency.TE.TalentLearnCost = 1
# Default AE cost when spell is not listed in world.paragon_spell_ae_cost.
Paragon.Currency.AE.DefaultSpellCost = 2
# Diagnostics ----------------------------------------------------------------
# When enabled, dumps every Paragon's rune cooldown state to the server log
# every 5 seconds (channel "module"). Use with `.paragon runes` for in-game
# verification. Leave at 0 in production — it's noisy.
Paragon.Diag.RuneTrace = 0
@@ -0,0 +1,9 @@
-- AE / TE currency storage (Paragon class progression).
-- Apply to the *character* database (same DB as `characters`, `character_spell`, etc.).
CREATE TABLE IF NOT EXISTS `character_paragon_currency` (
`guid` INT UNSIGNED NOT NULL COMMENT 'characters.guid',
`ability_essence` SMALLINT UNSIGNED NOT NULL DEFAULT '0',
`talent_essence` SMALLINT UNSIGNED NOT NULL DEFAULT '0',
PRIMARY KEY (`guid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='mod-paragon: Ability Essence / Talent Essence';
@@ -0,0 +1,103 @@
-- mod-paragon: extend the gameTable SQL fallback tables to cover class 12.
--
-- AzerothCore loads each gtXxx DBC twice: first the .dbc file on disk, then
-- a `gtxxx_dbc` SQL fallback table that overrides / extends the in-memory
-- index (DBCDatabaseLoader::Load). For Player::OCTRegenMPPerSpirit,
-- Player::GetMissPercentageFromDefence, Player::GetRatingMultiplier and
-- friends, the SQL is the source of truth at runtime, not the DBC.
--
-- The vanilla acore_world ships these tables with rows for class 1..11 only.
-- For class 12 (Paragon), the lookup index is unmapped and every formula
-- silently returns 0 (mana regen, crit, dodge, rating multipliers).
--
-- We mirror Druid (class 11) values into class 12 -- druid is the closest
-- vanilla hybrid (mana + rage + energy across forms) and matches Paragon's
-- multi-resource intent. Same source class as the DBC patches and as
-- player_class_stats_paragon_basemana.sql, so the data stays consistent.
--
-- Lookup formulas (for cross-reference with src/server/game/Entities/Player):
-- gtChanceToMeleeCrit / gtChanceToSpellCrit / gt*RegenHP / gt*RegenMP /
-- gtRegenHPPerSpt / gtRegenMPPerSpt:
-- LookupEntry((class - 1) * GT_MAX_LEVEL + level - 1)
-- class 11 -> Ids 1000..1099 ; class 12 -> Ids 1100..1199
--
-- gtChanceToMeleeCritBase / gtChanceToSpellCritBase:
-- LookupEntry(class - 1)
-- class 11 -> Id 10 ; class 12 -> Id 11
--
-- gtOCTClassCombatRatingScalar:
-- LookupEntry((class - 1) * MAX_COMBAT_RATING + cr + 1)
-- class 11 -> Ids 321..352 ; class 12 -> Ids 353..384
--
-- Idempotent: each block deletes the class-12 Id range first, then re-clones
-- from class 11 via a materialised subquery (so we don't read the same
-- table we're inserting into in undefined order). Re-running the file is
-- safe and produces identical results.
--
-- Wrapped in a single transaction so a mid-script error rolls back the
-- whole thing.
--
-- Apply to acore_world.
START TRANSACTION;
-- gtChanceToMeleeCrit ---------------------------------------------------------
DELETE FROM `gtchancetomeleecrit_dbc` WHERE `ID` BETWEEN 1100 AND 1199;
INSERT INTO `gtchancetomeleecrit_dbc` (`ID`, `Data`)
SELECT t.ID + 100, t.Data
FROM (SELECT `ID`, `Data` FROM `gtchancetomeleecrit_dbc` WHERE `ID` BETWEEN 1000 AND 1099) AS t;
-- gtChanceToSpellCrit ---------------------------------------------------------
DELETE FROM `gtchancetospellcrit_dbc` WHERE `ID` BETWEEN 1100 AND 1199;
INSERT INTO `gtchancetospellcrit_dbc` (`ID`, `Data`)
SELECT t.ID + 100, t.Data
FROM (SELECT `ID`, `Data` FROM `gtchancetospellcrit_dbc` WHERE `ID` BETWEEN 1000 AND 1099) AS t;
-- gtOCTRegenHP ----------------------------------------------------------------
DELETE FROM `gtoctregenhp_dbc` WHERE `ID` BETWEEN 1100 AND 1199;
INSERT INTO `gtoctregenhp_dbc` (`ID`, `Data`)
SELECT t.ID + 100, t.Data
FROM (SELECT `ID`, `Data` FROM `gtoctregenhp_dbc` WHERE `ID` BETWEEN 1000 AND 1099) AS t;
-- gtRegenHPPerSpt -------------------------------------------------------------
DELETE FROM `gtregenhpperspt_dbc` WHERE `ID` BETWEEN 1100 AND 1199;
INSERT INTO `gtregenhpperspt_dbc` (`ID`, `Data`)
SELECT t.ID + 100, t.Data
FROM (SELECT `ID`, `Data` FROM `gtregenhpperspt_dbc` WHERE `ID` BETWEEN 1000 AND 1099) AS t;
-- gtRegenMPPerSpt -------------------------------------------------------------
DELETE FROM `gtregenmpperspt_dbc` WHERE `ID` BETWEEN 1100 AND 1199;
INSERT INTO `gtregenmpperspt_dbc` (`ID`, `Data`)
SELECT t.ID + 100, t.Data
FROM (SELECT `ID`, `Data` FROM `gtregenmpperspt_dbc` WHERE `ID` BETWEEN 1000 AND 1099) AS t;
-- gtChanceToMeleeCritBase -----------------------------------------------------
DELETE FROM `gtchancetomeleecritbase_dbc` WHERE `ID` = 11;
INSERT INTO `gtchancetomeleecritbase_dbc` (`ID`, `Data`)
SELECT t.ID + 1, t.Data
FROM (SELECT `ID`, `Data` FROM `gtchancetomeleecritbase_dbc` WHERE `ID` = 10) AS t;
-- gtChanceToSpellCritBase -----------------------------------------------------
DELETE FROM `gtchancetospellcritbase_dbc` WHERE `ID` = 11;
INSERT INTO `gtchancetospellcritbase_dbc` (`ID`, `Data`)
SELECT t.ID + 1, t.Data
FROM (SELECT `ID`, `Data` FROM `gtchancetospellcritbase_dbc` WHERE `ID` = 10) AS t;
-- gtOCTClassCombatRatingScalar (32 ratings per class, 1-based Ids) ------------
DELETE FROM `gtoctclasscombatratingscalar_dbc` WHERE `ID` BETWEEN 353 AND 384;
INSERT INTO `gtoctclasscombatratingscalar_dbc` (`ID`, `Data`)
SELECT t.ID + 32, t.Data
FROM (SELECT `ID`, `Data` FROM `gtoctclasscombatratingscalar_dbc` WHERE `ID` BETWEEN 321 AND 352) AS t;
COMMIT;
-- Sanity check (read-only). Expected class-12 counts:
-- 100 / 100 / 100 / 100 / 100 / 1 / 1 / 32
-- SELECT 'mc' cnt, COUNT(*) FROM gtchancetomeleecrit_dbc WHERE ID BETWEEN 1100 AND 1199
-- UNION ALL SELECT 'sc' cnt, COUNT(*) FROM gtchancetospellcrit_dbc WHERE ID BETWEEN 1100 AND 1199
-- UNION ALL SELECT 'orh' cnt, COUNT(*) FROM gtoctregenhp_dbc WHERE ID BETWEEN 1100 AND 1199
-- UNION ALL SELECT 'rh' cnt, COUNT(*) FROM gtregenhpperspt_dbc WHERE ID BETWEEN 1100 AND 1199
-- UNION ALL SELECT 'rm' cnt, COUNT(*) FROM gtregenmpperspt_dbc WHERE ID BETWEEN 1100 AND 1199
-- UNION ALL SELECT 'mcb' cnt, COUNT(*) FROM gtchancetomeleecritbase_dbc WHERE ID = 11
-- UNION ALL SELECT 'scb' cnt, COUNT(*) FROM gtchancetospellcritbase_dbc WHERE ID = 11
-- UNION ALL SELECT 'crs' cnt, COUNT(*) FROM gtoctclasscombatratingscalar_dbc WHERE ID BETWEEN 353 AND 384;
@@ -0,0 +1,11 @@
-- Optional per-spell AE costs for Paragon spell purchases (.paragon learn).
-- Apply to the *world* database.
CREATE TABLE IF NOT EXISTS `paragon_spell_ae_cost` (
`spell_id` INT UNSIGNED NOT NULL,
`ae_cost` SMALLINT UNSIGNED NOT NULL DEFAULT '2',
PRIMARY KEY (`spell_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='mod-paragon: AE cost per spell';
-- Example (uncomment to use):
-- INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES (55050, 2);
@@ -0,0 +1,26 @@
-- mod-paragon: give class 12 (Paragon) a real mana pool.
--
-- The original Paragon install cloned warrior rows into player_class_stats
-- for class 12, which left basemana = 0 at every level. That makes
-- Player::GetCreateMana() return 0 on login, so Player::UpdateMaxPower(POWER_MANA)
-- writes UNIT_FIELD_MAXPOWER1 = 0 and the client renders an empty mana bar even
-- though our PlayerFrame is now showing the slot.
--
-- We mirror Druid (class 11) basemana values: druid is the closest vanilla
-- hybrid (mana + rage + energy across forms), which matches Paragon's
-- multi-resource design philosophy. basehp is left untouched (already cloned
-- from warrior, which is fine for a melee-leaning hybrid).
--
-- Apply to the *characters* database? No -- player_class_stats lives in the
-- world DB. Apply to acore_world.
--
-- Re-runs are safe: this is an idempotent UPDATE, not an INSERT.
UPDATE `player_class_stats` p12
JOIN `player_class_stats` p11 ON p11.class = 11 AND p11.level = p12.level
SET p12.basemana = p11.basemana
WHERE p12.class = 12;
-- Sanity check (informational, no side-effects):
-- SELECT class, level, basehp, basemana FROM player_class_stats
-- WHERE class IN (11, 12) AND level IN (1, 40, 80) ORDER BY class, level;
+416
View File
@@ -0,0 +1,416 @@
/*
* mod-paragon — Ability Essence (AE) / Talent Essence (TE)
*
* Inspired by Project Ascension's classless currencies (see Fandom:
* Ability Essence / Talent Essence). Server-side only: values persist in
* character_paragon_currency; optional per-spell AE costs live in world
* table paragon_spell_ae_cost. Client UI for bars / advancement panels is
* separate (see README).
*/
#include "CharacterDatabase.h"
#include "Chat.h"
#include "CommandScript.h"
#include "Config.h"
#include "Player.h"
#include "RBAC.h"
#include "ScriptMgr.h"
#include "SharedDefines.h"
#include "SpellInfo.h"
#include "SpellMgr.h"
#include "WorldDatabase.h"
#include "Log.h"
#include <unordered_map>
using namespace Acore::ChatCommands;
namespace
{
struct ParagonCurrencyData
{
uint32 abilityEssence = 0;
uint32 talentEssence = 0;
};
std::unordered_map<uint32, ParagonCurrencyData> gParagonCurrencyCache;
uint32 ComputeStartingAE(uint8 level)
{
uint32 base = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.Start", 9);
uint32 per = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.PerLevel", 1);
uint32 minLv = sConfigMgr->GetOption<uint32>("Paragon.Currency.GrantLevelMin", 10);
if (level >= minLv)
base += uint32(level - minLv + 1) * per;
return base;
}
uint32 ComputeStartingTE(uint8 level)
{
uint32 base = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.Start", 0);
uint32 per = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.PerLevel", 1);
uint32 minLv = sConfigMgr->GetOption<uint32>("Paragon.Currency.GrantLevelMin", 10);
if (level >= minLv)
base += uint32(level - minLv + 1) * per;
return base;
}
ParagonCurrencyData& GetOrCreateCacheEntry(uint32 lowGuid)
{
return gParagonCurrencyCache[lowGuid];
}
void DbUpsertCurrency(uint32 lowGuid, uint32 ae, uint32 te)
{
CharacterDatabase.DirectExecute(
"REPLACE INTO character_paragon_currency (guid, ability_essence, talent_essence) VALUES ({}, {}, {})",
lowGuid, ae, te);
}
void LoadCurrencyFromDb(Player* player)
{
if (!player || player->getClass() != CLASS_PARAGON)
return;
if (!sConfigMgr->GetOption<bool>("Paragon.Currency.Enabled", true))
return;
uint32 const lowGuid = player->GetGUID().GetCounter();
if (QueryResult r = CharacterDatabase.Query(
"SELECT ability_essence, talent_essence FROM character_paragon_currency WHERE guid = {}", lowGuid))
{
Field const* f = r->Fetch();
ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid);
d.abilityEssence = f[0].Get<uint32>();
d.talentEssence = f[1].Get<uint32>();
return;
}
uint32 const ae = ComputeStartingAE(player->GetLevel());
uint32 const te = ComputeStartingTE(player->GetLevel());
DbUpsertCurrency(lowGuid, ae, te);
ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid);
d.abilityEssence = ae;
d.talentEssence = te;
}
void SaveCurrencyToDb(Player* player)
{
if (!player || player->getClass() != CLASS_PARAGON)
return;
if (!sConfigMgr->GetOption<bool>("Paragon.Currency.Enabled", true))
return;
uint32 const lowGuid = player->GetGUID().GetCounter();
auto itr = gParagonCurrencyCache.find(lowGuid);
if (itr == gParagonCurrencyCache.end())
return;
DbUpsertCurrency(lowGuid, itr->second.abilityEssence, itr->second.talentEssence);
}
void RemoveCacheEntry(uint32 lowGuid)
{
gParagonCurrencyCache.erase(lowGuid);
}
uint32 GetAE(Player* player)
{
if (!player)
return 0;
auto itr = gParagonCurrencyCache.find(player->GetGUID().GetCounter());
if (itr == gParagonCurrencyCache.end())
return 0;
return itr->second.abilityEssence;
}
uint32 GetTE(Player* player)
{
if (!player)
return 0;
auto itr = gParagonCurrencyCache.find(player->GetGUID().GetCounter());
if (itr == gParagonCurrencyCache.end())
return 0;
return itr->second.talentEssence;
}
bool TrySpendAE(Player* player, uint32 amount)
{
if (!amount)
return true;
auto itr = gParagonCurrencyCache.find(player->GetGUID().GetCounter());
if (itr == gParagonCurrencyCache.end())
return false;
if (itr->second.abilityEssence < amount)
return false;
itr->second.abilityEssence -= amount;
return true;
}
bool TrySpendTE(Player* player, uint32 amount)
{
if (!amount)
return true;
auto itr = gParagonCurrencyCache.find(player->GetGUID().GetCounter());
if (itr == gParagonCurrencyCache.end())
return false;
if (itr->second.talentEssence < amount)
return false;
itr->second.talentEssence -= amount;
return true;
}
void GrantLevelUpEssence(Player* player, uint8 oldLevel, uint8 newLevel)
{
if (!player || player->getClass() != CLASS_PARAGON)
return;
if (!sConfigMgr->GetOption<bool>("Paragon.Currency.Enabled", true))
return;
uint32 const minLv = sConfigMgr->GetOption<uint32>("Paragon.Currency.GrantLevelMin", 10);
uint32 const aePer = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.PerLevel", 1);
uint32 const tePer = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.PerLevel", 1);
ParagonCurrencyData& d = GetOrCreateCacheEntry(player->GetGUID().GetCounter());
for (uint32 lvl = uint32(oldLevel) + 1; lvl <= uint32(newLevel); ++lvl)
{
if (lvl >= minLv)
{
d.abilityEssence += aePer;
d.talentEssence += tePer;
}
}
}
uint32 LookupSpellAECost(uint32 spellId)
{
uint32 const fallback = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.DefaultSpellCost", 2);
QueryResult r = WorldDatabase.Query("SELECT ae_cost FROM paragon_spell_ae_cost WHERE spell_id = {}", spellId);
if (!r)
return fallback;
return std::max<uint32>(r->Fetch()[0].Get<uint32>(), 1u);
}
class Paragon_Essence_PlayerScript : public PlayerScript
{
public:
Paragon_Essence_PlayerScript() : PlayerScript("Paragon_Essence_PlayerScript", {
PLAYERHOOK_ON_LOGIN,
PLAYERHOOK_ON_LOGOUT,
PLAYERHOOK_ON_SAVE,
PLAYERHOOK_ON_CREATE,
PLAYERHOOK_ON_LEVEL_CHANGED,
PLAYERHOOK_CAN_LEARN_TALENT,
PLAYERHOOK_ON_PLAYER_LEARN_TALENTS
}) { }
void OnPlayerLogin(Player* player) override
{
LoadCurrencyFromDb(player);
}
void OnPlayerLogout(Player* player) override
{
SaveCurrencyToDb(player);
RemoveCacheEntry(player->GetGUID().GetCounter());
}
void OnPlayerSave(Player* player) override
{
SaveCurrencyToDb(player);
}
void OnPlayerCreate(Player* player) override
{
if (!player || player->getClass() != CLASS_PARAGON)
return;
if (!sConfigMgr->GetOption<bool>("Paragon.Currency.Enabled", true))
return;
uint32 const lowGuid = player->GetGUID().GetCounter();
uint32 const ae = ComputeStartingAE(player->GetLevel());
uint32 const te = ComputeStartingTE(player->GetLevel());
DbUpsertCurrency(lowGuid, ae, te);
ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid);
d.abilityEssence = ae;
d.talentEssence = te;
}
void OnPlayerLevelChanged(Player* player, uint8 oldLevel) override
{
if (!player || player->getClass() != CLASS_PARAGON)
return;
LoadCurrencyFromDb(player);
GrantLevelUpEssence(player, oldLevel, player->GetLevel());
}
bool OnPlayerCanLearnTalent(Player* player, TalentEntry const* /*talent*/, uint32 /*rank*/) override
{
if (!player || player->getClass() != CLASS_PARAGON)
return true;
if (!sConfigMgr->GetOption<bool>("Paragon.Currency.Enabled", true))
return true;
uint32 const cost = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
return GetTE(player) >= cost;
}
void OnPlayerLearnTalents(Player* player, uint32 /*talentId*/, uint32 /*talentRank*/, uint32 /*spellid*/) override
{
if (!player || player->getClass() != CLASS_PARAGON)
return;
if (!sConfigMgr->GetOption<bool>("Paragon.Currency.Enabled", true))
return;
uint32 const cost = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
if (!TrySpendTE(player, cost))
{
LOG_ERROR("module", "Paragon TE spend failed post-learn for player {} — currency desync?", player->GetName());
return;
}
}
};
class Paragon_Essence_CommandScript : public CommandScript
{
public:
Paragon_Essence_CommandScript() : CommandScript("Paragon_Essence_CommandScript") { }
ChatCommandTable GetCommands() const override
{
static ChatCommandTable paragonSubTable =
{
{ "currency", HandleCurrency, rbac::RBAC_PERM_COMMAND_LEARN, Console::No },
{ "learn", HandleLearn, rbac::RBAC_PERM_COMMAND_LEARN, Console::No },
{ "runes", HandleRunes, rbac::RBAC_PERM_COMMAND_LEARN, Console::No },
};
static ChatCommandTable commandTable =
{
{ "paragon", paragonSubTable },
};
return commandTable;
}
static bool HandleCurrency(ChatHandler* handler)
{
Player* pl = handler->GetPlayer();
if (!pl || pl->getClass() != CLASS_PARAGON)
{
handler->SendErrorMessage("Paragon currency is only tracked for Paragon characters.", false);
return false;
}
if (!sConfigMgr->GetOption<bool>("Paragon.Currency.Enabled", true))
{
handler->SendErrorMessage("Paragon.Currency.Enabled is off.", false);
return false;
}
LoadCurrencyFromDb(pl);
handler->PSendSysMessage("Ability Essence: {} | Talent Essence: {}", GetAE(pl), GetTE(pl));
return true;
}
// .paragon runes — diagnostic dump of rune state (server-side truth) and
// forced ResyncRunes packet to the client. Helps diagnose visual-vs-server
// desync where the rune pip animates to ready but the spell cast still
// returns SPELL_FAILED_NO_POWER because GetRuneCooldown(i) > 0 server-side.
static bool HandleRunes(ChatHandler* handler)
{
Player* pl = handler->GetPlayer();
if (!pl || pl->getClass() != CLASS_PARAGON)
{
handler->SendErrorMessage("Only Paragon characters have a rune block to inspect.", false);
return false;
}
for (uint8 i = 0; i < MAX_RUNES; ++i)
{
handler->PSendSysMessage("rune[{}] base={} cur={} cd={}ms grace={}ms",
i,
uint32(pl->GetBaseRune(i)),
uint32(pl->GetCurrentRune(i)),
uint32(pl->GetRuneCooldown(i)),
uint32(pl->GetGracePeriod(i)));
}
for (uint8 t = 0; t < NUM_RUNE_TYPES; ++t)
{
handler->PSendSysMessage("regen[{}] = {} (1/cooldown_ms)",
uint32(t),
pl->GetFloatValue(PLAYER_RUNE_REGEN_1 + t));
}
// Force a fresh client snapshot so we can see if visual catches up.
pl->ResyncRunes(MAX_RUNES);
handler->PSendSysMessage("|cff00ff00ResyncRunes packet sent.|r");
return true;
}
static bool HandleLearn(ChatHandler* handler, SpellInfo const* spell)
{
Player* pl = handler->GetPlayer();
if (!pl || pl->getClass() != CLASS_PARAGON)
{
handler->SendErrorMessage("Only Paragon characters use AE for this command.", false);
return false;
}
if (!sConfigMgr->GetOption<bool>("Paragon.Currency.Enabled", true))
{
handler->SendErrorMessage("Paragon.Currency.Enabled is off.", false);
return false;
}
if (!spell)
return false;
LoadCurrencyFromDb(pl);
uint32 const cost = LookupSpellAECost(spell->Id);
if (GetAE(pl) < cost)
{
handler->PSendSysMessage("Not enough Ability Essence (need {}, have {}).", cost, GetAE(pl));
return false;
}
if (!SpellMgr::IsSpellValid(spell))
{
handler->SendErrorMessage("Invalid spell.", false);
return false;
}
if (pl->HasSpell(spell->Id))
{
handler->SendErrorMessage("You already know this spell.", false);
return false;
}
if (!TrySpendAE(pl, cost))
return false;
pl->learnSpell(spell->Id, false);
SaveCurrencyToDb(pl);
handler->PSendSysMessage("Learned spell {} ({} AE spent, {} AE remaining).", spell->Id, cost, GetAE(pl));
return true;
}
};
} // namespace (anonymous)
void AddSC_paragon_essence()
{
new Paragon_Essence_PlayerScript();
new Paragon_Essence_CommandScript();
}
+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();
}
@@ -0,0 +1,16 @@
/*
* Paragon: server-side support module for the custom CLASS_PARAGON.
*
* Auto-discovered by AzerothCore's modules/CMakeLists.txt. The exported
* loader name must match Add<dir-with-underscores>Scripts(), so for
* the "mod-paragon" directory it is Addmod_paragonScripts().
*/
void AddSC_paragon();
void AddSC_paragon_essence();
void Addmod_paragonScripts()
{
AddSC_paragon();
AddSC_paragon_essence();
}