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:
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user