Compare commits

..

10 Commits

Author SHA1 Message Date
Docker Build 6a1f8eec89 Paragon tester hunter BiS, mount cast QoL, learn all mounts RBAC, trade cap 11
- mod-paragon: .paragon tester bis hunter (Sanctified Ahn'Kahar Blood Hunter + Windrunner's Heartseeker), bis gems kits, AGI bow vs ranged/gun/crossbow, ranged for spi/hybrid weapons.
- .learn all mounts: RBAC 916 + db_auth migration 2026_05_12_00.sql.
- Cast-time mount spells: allow start/complete while moving; block in combat; interrupt mount cast on combat enter; relax movement prevention for NPCs/units.
- MaxPrimaryTradeSkill default 11 (all WotLK primary professions) in WorldConfig + worldserver.conf.dist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 23:02:30 -04:00
Docker Build 0bb6b0ef84 feat(launcher): script to replace launcher-only on legacy Gitea release
- gitea-replace-launcher-only.sh: swap Fractured-Launcher* + yml without wiping MPQs
- Only remove latest.yml / latest-linux.yml if a replacement exists in dist/
- README: bridge rollout steps and checklist item

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 21:44:13 -05:00
Docker Build 295cb6df52 chore(launcher): point baked Gitea to git.hisora.dev, bump 1.0.13
Players on the old DDNS host can fetch this build from there once, then
patches and launcher updates use https://git.hisora.dev (Dawnsorrow/Fractured-Distro).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 21:39:33 -05:00
Docker Build fbd6ea47f2 Switch server scripts to tmux for panel console access
Rewrite start-azeroth-servers.sh to launch auth/worldserver in named
tmux sessions instead of nohup/disown. Add kill-azeroth-servers.sh to
tear down sessions and stray processes. Update vps-update-server.sh
with a --restart flag that stops servers before compile and restarts
them in tmux after.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 21:39:33 -05:00
Docker Build a64279ed7e fix(player): restore missing additional save timer and reduce autosave interval
The m_additionalSaveTimer was never processed in the update loop, so quick
partial saves after important events (rare+ item pickups, quest completions)
never fired. This caused players to lose progress on disconnect/crash since
only the 15-minute full autosave protected them.

- Add m_additionalSaveTimer tick logic to Player::Update
- Reduce default PlayerSaveInterval from 900000 (15 min) to 300000 (5 min)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 21:39:33 -05:00
Docker Build 87219cb4eb Paragon: multidot Devouring Plague, stance/presence clones, advancement SLA
- Allow multiple Devouring Plague DoTs on different targets (core + DK script).

- Warrior stance and DK presence clone spells for Character Advancement; spellbook SkillLineAbility rows and aura/shapeshift attribute fixes.

- World SQL updates 2026_05_12_02 through 07 (mod-paragon db-world).

Client patch-enUS-4/5/6 and Wow.exe ship on the matching GitHub Release (not in repo).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 19:20:13 -04:00
Docker Build da17074a63 Paragon: Runeforging support (panel-purchasable, no anvil required)
Lets the Paragon class buy Runeforging from the Character Advancement
panel and apply rune enchants from anywhere in the world without needing
to be near a runeforge GameObject. Three carve-outs work together:

* Spell.cpp: bypass the SpellFocusObject GO proximity check when the
  caster is a Paragon and the spell belongs to SKILL_RUNEFORGING (776).
  Stock DK behaviour is unchanged -- the bypass is gated on
  getClass() == CLASS_PARAGON, not on the IsClass() context hook.

* Player.cpp: skip the Paragon class-skill cascade block for skill 776
  so the rune-enchant SLA cascade actually fires. Without this the
  player gets the Runeforging skill but no rune options at the anvil.

* Paragon_Essence.cpp:
  - Treat SKILL_RUNEFORGING children as a meta-skill cluster: cascade
    them like passives even though they're active casts, so they stick
    as panel_spell_children and get cleaned up via the standard refund
    path.
  - Whitelist the 8 basic rune-enchants in PruneSkillLineCascadeChildren
    so they don't get evicted as "active in children = legacy garbage".
  - Force-attach them in PanelLearnSpellChain (the SLA rows ship with
    AcquireMethod=0, so the engine cascade alone won't grant them).
  - Add an OnPlayerLogin fixup so existing Paragons who bought
    Runeforging before this change get the 8 runes retro-granted.
  - Stop filtering SPELL_ATTR0_DO_NOT_DISPLAY in PushSpellSnapshot --
    Runeforging itself is hidden in the DBC but is a real panel
    purchase that must show in the Overview tab.

The two advanced runes (Stoneskin Gargoyle, Nerubian Carapace) are
intentionally excluded from the auto-grant -- retail gates them behind
heroic dungeon / raid item drops and the SLA AcquireMethod=0 honours
that gating.

No SQL migration needed; works against existing DBC + SLA data.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 04:49:38 -04:00
Docker Build b8826370c6 Paragon: cross-class stance exclusivity + cancel, hunter ammo soft-fail, Feral Cat scaling, Pestilence DP spread
Server-side batch following v0.7.18, all gated to Paragon (or applied
server-wide where the design discussion called for it):

* Cross-class stance / form / presence / aspect exclusivity (server-wide).
  New `IsFracturedExclusiveStanceSpell()` (`Unit.cpp`/`Unit.h`) returns
  true for the union of warrior stances + druid forms (combat AND utility:
  Travel, Aquatic, Flight, Swift Flight) + Ghost Wolf + base Stealth +
  Shadowform + Metamorphosis + DK Presences + Hunter Aspects (combat AND
  utility: Cheetah, Pack). `Aura::CanStackWith` (`SpellAuras.cpp`) refuses
  to stack two spells from this set, which routes through
  `_RemoveNoStackAurasDueToAura` to drop the older aura -- the same
  mechanism Battle Elixirs / Curses use. Plugs the stock-AC gap where DK
  Presences and Hunter Aspects (regular auras, just rendered in the
  stance bar) coexisted with engine-shapeshifts.

* Stances / Presences / Aspects cancellable like Druid forms.
  `SpellInfoCorrections.cpp` now zeroes `CategoryEntry` on warrior
  stances, DK presences, and every rank of every hunter aspect (moves
  them out of SpellCategory 47 "Combat States", which gates the client's
  right-click / `/cancelaura` path), AND clears `AttributesEx6` bit
  `0x1000` on warrior stances + DK presences (a second client-UI gate
  surfaced via DBC diff -- aspects don't have it set). Mirrored client-
  side by `_patch_spell_dbc_presences_cancelable.py`. Aspects / presences
  do NOT swap action bars (those are owned by `SPELL_AURA_MOD_SHAPESHIFT`,
  not by Category / AttrEx6) -- only warrior stances and druid forms keep
  the bar swap, matching the design requirement that presences/aspects
  not change the player's action bar.

* Hunter ammo soft-fail (server-wide).
  Replaced both `SPELL_FAILED_NO_AMMO` returns in `Spell::CheckCast` with
  `break;` so ranged + thrown abilities cast through with zero ammo;
  `_ApplyAmmoBonuses` continues to gate the actual arrow/bullet DPS bonus
  on a non-empty stack, so equipping ammo still pays off. New programmatic
  `ApplySpellFix`-style block in `SpellInfoCorrections.cpp` iterates every
  Hunter-family spell whose `EquippedItemClass == ITEM_CLASS_WEAPON` and
  `EquippedItemSubClassMask` includes Bow/Gun/Crossbow and sets
  `EquippedItemClass = -1` (skipping a small DENYLIST of Quiver / Ammo
  Pouch passive haste auras + Aynasha's Bow + Legendary Bow Haste -- those
  are item-equip-driven and must keep gating on the ranged weapon being
  equipped). Server log: ">> Fractured: dropped EquippedItemClass on 196
  hunter shot abilities". Mirrored client-side by the new
  `_patch_spell_dbc_hunter_ammo.py` so the 3.3.5a client preflight stops
  blocking the cast packet with "Ammo needs to be in the paper doll ammo
  slot before it can be fired." `Spell::TakeAmmo` no longer clears
  `PLAYER_AMMO_ID` to 0 when the bag empties (defense in depth so a half-
  deployed pair degrades to soft-fail rather than hard-reject). Adds
  `#include "ItemTemplate.h"` for `ITEM_SUBCLASS_WEAPON_*`.

* Feral Cat scaling (server-wide, cat-only).
  `StatSystem.cpp` `UpdateAttackPowerAndDamage` FORM_CAT branch doubles
  the AGI coefficient (1.0 -> 2.0). `SpellAuraEffects.cpp` Master
  Shapeshifter FORM_CAT branch doubles the talent's bp before triggering
  48420 (R1: 2% -> 4% crit, R2: 4% -> 8%). FORM_BEAR / FORM_DIREBEAR /
  FORM_MOONKIN / FORM_TREE branches all left untouched so bear stays
  "already fine" per the resident Feral expert. Client tooltip drift on
  Cat Form (768) + Master Shapeshifter (48411 / 48412) + Pestilence
  (50842) handled by `_patch_spell_dbc_feral_tooltips.py`.

* Pestilence spreads / refreshes Devouring Plague for Paragon casters.
  `spell_dk.cpp` `spell_dk_pestilence::HandleScriptEffect` now also
  spreads (and Glyph-of-Disease refreshes) Priest Devouring Plague when
  `IsParagonWildcardCaller(caster)`. Uses `GetAuraOfRankedSpell(2944)` so
  the spread copy carries the caster's actual rank. Stock DKs cannot
  cast Devouring Plague at all, so this branch is a no-op for them.

* Dancing Rune Weapon: Paragon copies melee, not casts.
  `spell_dk.cpp` `spell_dk_dancing_rune_weapon::CheckProc` for Paragon
  callers requires `eventInfo.GetDamageInfo()` and
  `spellInfo->DmgClass == SPELL_DAMAGE_CLASS_MELEE`, so the ghostly
  weapon now copies cross-class melee strikes (Hamstring, Sinister Strike,
  Heart Strike, Frost Strike, ...) and auto-attacks instead of re-casting
  the DK's nukes. Stock DK gating below is untouched.

* Maelstrom Weapon: drop Arcane Blast from the allowlist.
  `SpellInfo.cpp` and `spell_shaman.cpp` allowlists now match Fireball
  and Frostbolt only -- Arcane Blast stacked with its own self-buff was
  too potent.

* `BALANCE-TODO.md` added under `contrib/fractured-dev-extras/` to
  capture the resident Feral expert's recommendation, the levers we
  considered, and the cat-only Master-Shapeshifter / AGI-doubling
  resolution we shipped, plus the next-lever knobs if field reports
  still flag cat as weak.

DBC patcher pipeline (lives outside the repo in `fractured-tooling/`)
documented run order: runes -> reagents -> stances -> presences_cancelable
-> hunter_ammo -> feral_tooltips -> _make_paragon_dbc_patch.

No SQL migrations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 23:17:37 -04:00
Docker Build d1d68cb44a fix(scripts): update server paths for new VPS location
Start script now defaults to /home/fractured-panel/azeroth-server and
passes -c flags so binaries find configs regardless of compiled-in path.
Update script gains --prefix flag to override CMAKE_INSTALL_PREFIX
(persisted to conf/config.sh) during rebuild.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 19:44:03 -05:00
Docker Build 999f7e94bd Paragon: narrow weapon bypass; cascade talent ranks; Savage Defense
* Weapon-narrow: replace blanket EquippedItemSubClassMask bypass with an
  explicit allowlist (IsParagonWeaponSubclassWildcardSpell, currently
  Maelstrom Weapon 51528 only). Applied at all three gates: Player::
  HasItemFitToSpellRequirements, Player::CheckAttackFitToAuraRequirement,
  and Aura::IsProcTriggeredOnEvent. Maelstrom Weapon still procs from any
  weapon for Paragon, but Hack and Slash / Sword / Mace Specialization
  remain correctly weapon-gated.
* Talent ability-rank cascade: TeachLevelGatedAbilityChainNoPanel +
  CascadeRanksForTalentLearnSpellEffects walk the SPELL_EFFECT_LEARN_SPELL
  chain a talent rank grants and learn each rank up to the player's level.
  Wired into HandleCommit (on talent purchase) and OnPlayerLevelChanged
  (on level-up). Fixes Mangle (and any future LEARN_SPELL talent) being
  stuck at rank 1 because Player::learnSkillRewardedSpells is intentionally
  disabled for Paragon's class skill lines.
* Riding-skill gate for flight forms (IsParagonSpellAllowedByRidingSkill):
  Flight Form (33943) requires Expert Riding (34090); Swift Flight Form
  (40120) requires Artisan Riding (34091). Applied in PanelLearnSpellChain
  and TeachLevelGatedAbilityChainNoPanel so the cascade can't push past a
  rank the player isn't trained for. Also backticks `rank` in the level-up
  query (MySQL reserved word).
* Savage Defense (62600) on the panel: the SpellData bake's blanket
  SPELL_ATTR0_PASSIVE filter dropped Savage Defense even though Druid
  trainer 33 sells it at level 40. Bake (in fractured-tooling) now carves
  out a small PASSIVE_TRAINER_ALLOWLIST and the regenerated
  paragon_spell_ae_cost.sql + 2026_05_11_05.sql migration surface it on
  the Druid Feral spell tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 19:09:25 -04:00
38 changed files with 2409 additions and 170 deletions
@@ -0,0 +1,94 @@
# Fractured / Paragon — Balance Backlog
Open balance / scaling questions surfaced by play-testers that have not yet
been actioned. Each entry should record the *symptom*, the *suspected cause*
based on a quick code dive, the *option set* we discussed, and any *links*
to relevant code. Knock items off as they ship.
---
## Feral Cat scaling feels weak (2026-05-11)
**Reporter feedback:**
> "Weapons don't automatically feature feral AP on this server and nothing
> is currently rescaled resulting in a super low feral scale. We can either
> rescale their abilities or add the AP back to all weapons."
>
> Resident Feral expert: "this is not a bear issue unfortunately, I have
> 11k AP" / "Stam > AP and Armor > AP means this is completely a cat issue."
**What's actually happening on the server:**
Feral AP *is* being granted on weapons — `ItemTemplate::getFeralBonus`
(`src/server/game/Entities/Item/ItemTemplate.h`) synthesises it from the
weapon's DPS for any weapon with `INVTYPE_WEAPON / 2HWEAPON / WEAPONMAINHAND
/ WEAPONOFFHAND`:
```cpp
int32 bonus = int32((extraDPS + getDPS()) * 14.0f) - 767;
```
That's then routed through `Player::ApplyFeralAPBonus` (Player.cpp ~6896)
into `m_baseFeralAP`, which `Player::UpdateAttackPowerAndDamage` adds into
the cat / bear formulas in `src/server/game/Entities/Unit/StatSystem.cpp`
(~line 477):
```cpp
case FORM_CAT:
val2 = (level * mLevelMult) + STR*2 + AGI - 20 + weapon_bonus + m_baseFeralAP;
break;
case FORM_BEAR:
case FORM_DIREBEAR:
val2 = (level * mLevelMult) + STR*2 - 20 + weapon_bonus + m_baseFeralAP;
break;
```
So bear and cat get the same `m_baseFeralAP` — the only delta is `+ AGI` for
cat. The "bears feel fine, cats feel weak" complaint is real; it's because
bear damage is rage / proc / mitigation driven (Lacerate stack, Savage
Defense, Pulverize-style talents) while cat damage is much more
AP-coefficient driven (Shred, Mangle, Rake, Rip, FB).
**Options discussed (pick when we revisit):**
| ID | Lever | Pros | Cons |
|----|-------|------|------|
| A | Bump the cat AP formula (e.g. `+ AGI*1.5`) | Cleanest, very tunable, hits both auto-attacks and abilities | Blunt instrument, also affects PvP |
| B | Boost cat-form ability coefficients (Shred / Rake / Rip / Mangle / FB) via spellmod auras | Most retail-faithful, surgical | More moving parts, harder to communicate |
| C | Increase `getFeralBonus` payout for druid weapons (e.g. `* 18.0f` or drop the `-767` floor) | Single-line change | Buffs bears too — bears are already fine, would over-buff |
| D | New Cat-only passive "Predator's Edge" = `+X% physical damage in Cat Form` | Easy to balance, easy to communicate, easy to undo | Adds another aura to track |
**Recommendation when we pick this back up:** start with **D + small A**
D is the readable "+15-20% cat damage" knob, A is a backup if AP-scaling
abilities (Mangle / FB) still feel weak relative to bleeds. Both are
trivially tunable via a single config knob during play-testing.
Do **not** pick C — it over-buffs bears, which the Feral expert explicitly
said are already fine.
**Resolution (2026-05-11, second pass):** Per the resident Feral expert
("instead of adding a new passive, you could probably just increase Cat
Form's Master Shapeshifter value along with its tooltip, alongside buffing
the agi scaling") we shipped a hybrid of **A** and a *cat-only* knob that
sits next to **D** but reuses an existing aura instead of inventing a new
one:
* `StatSystem.cpp` `UpdateAttackPowerAndDamage` FORM_CAT branch now
reads `+ GetStat(STAT_AGILITY) * 2.0f` (stock 1.0). FORM_BEAR /
FORM_DIREBEAR / FORM_MOONKIN are untouched, so bear's "already fine"
state is preserved.
* `SpellAuraEffects.cpp` Master Shapeshifter FORM_CAT branch multiplies
the talent's value by 2 before triggering 48420 (cat-form aura).
Talent ranks: 2% -> 4% (R1), 4% -> 8% (R2) crit chance in Cat Form.
Bear / Moonkin / Tree branches still pass `bp` through unchanged.
* Client tooltip drift handled by
`fractured-tooling/from-workspace-root/_patch_spell_dbc_feral_tooltips.py`,
which appends a `[Fractured]` paragraph to the Description column of
Cat Form (768) and Master Shapeshifter ranks (48411 / 48412).
If field reports after this lands still say cat is weak, the next levers
are (in order): bump `2.0f` to `2.5f` in StatSystem.cpp, then bump the
Master Shapeshifter cat multiplier from `* 2` to `* 3` in
SpellAuraEffects.cpp, then -- only if those are exhausted -- revisit
**B** (per-ability spellmod coefficients).
+3
View File
@@ -6,6 +6,9 @@ AzerothCore. Upstream AzerothCore does not ship these paths.
Contents: Contents:
- BUILD-NATIVE.md — fork-specific native build notes (moved from repo root). - BUILD-NATIVE.md — fork-specific native build notes (moved from repo root).
- BALANCE-TODO.md — open balance / scaling questions raised by play-testers
that have not yet been actioned (e.g. Feral Cat scaling). Knock items off
as they ship.
- CLAUDE.md — optional AI assistant context (moved from repo root). - CLAUDE.md — optional AI assistant context (moved from repo root).
- CLIENT-PATCHES.md — what ships in a Fractured client release (MPQs + - CLIENT-PATCHES.md — what ships in a Fractured client release (MPQs +
patched Wow.exe), where to download them (Releases page), and how patched Wow.exe), where to download them (Releases page), and how
@@ -0,0 +1,10 @@
-- DB update 2026_05_03_00 -> 2026_05_12_00
-- RBAC permission for .learn all mounts (Admin 196, Gamemaster 197).
DELETE FROM `rbac_permissions` WHERE `id` = 916;
INSERT INTO `rbac_permissions` (`id`, `name`) VALUES
(916, 'Command: learn all mounts');
DELETE FROM `rbac_linked_permissions` WHERE `linkedId` = 916;
INSERT INTO `rbac_linked_permissions` (`id`, `linkedId`) VALUES
(196, 916),
(197, 916);
@@ -472,6 +472,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(61999, 1), (61999, 1),
(62078, 1), (62078, 1),
(62124, 1), (62124, 1),
(62600, 1),
(62757, 1), (62757, 1),
(64382, 1), (64382, 1),
(64843, 1), (64843, 1),
@@ -0,0 +1,19 @@
-- mod-paragon: surface Savage Defense (62600) on the Druid Feral spell tab
-- of the Character Advancement panel.
--
-- The bake (tools/_gen_paragon_advancement_spells_lua.py) used to drop every
-- SPELL_ATTR0_PASSIVE spell up front, even when the trainer explicitly sells
-- it. That filter was correct for class-internal triggers (Feline Grace, etc.)
-- but kicked out Savage Defense -- a passive that DRUID trainer 33 sells at
-- level 40 (trainer_spell.sql line 2457). Bake now carves out a small
-- PASSIVE_TRAINER_ALLOWLIST so legitimate trainer-taught passives survive.
--
-- The base file (data/sql/db-world/base/paragon_spell_ae_cost.sql) was
-- regenerated alongside this migration so fresh deployments already have
-- this row. Existing servers do not re-run base files on content change,
-- so this update inserts the new (spell_id, ae_cost) pair idempotently.
-- INSERT IGNORE keeps any per-row tuning a server operator may have already
-- applied.
INSERT IGNORE INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
(62600, 1); -- Savage Defense (Druid, trainer 33, level 40)
@@ -0,0 +1,21 @@
-- Fractured / Paragon: multidot Devouring Plague clone (spell IDs 951000-951008).
-- Spell rows live in the patched client Spell.dbc (see fractured-tooling
-- from-workspace-root/_patch_spell_dbc_paragon_multidot_devouring_plague.py).
-- Deploy the same Spell.dbc into the worldserver `data/dbc/` folder OR import
-- equivalent `spell_dbc` rows from a full exporter; stock SQL cannot express
-- the SpellEntryfmt NA padding columns safely in one INSERT here.
DELETE FROM `spell_ranks` WHERE `first_spell_id` = 951000;
INSERT INTO `spell_ranks` (`first_spell_id`,`spell_id`,`rank`) VALUES
(951000,951000,1),
(951000,951001,2),
(951000,951002,3),
(951000,951003,4),
(951000,951004,5),
(951000,951005,6),
(951000,951006,7),
(951000,951007,8),
(951000,951008,9);
DELETE FROM `paragon_spell_ae_cost` WHERE `spell_id` IN (2944,951000);
INSERT INTO `paragon_spell_ae_cost` (`spell_id`,`ae_cost`) VALUES (951000, 1);
@@ -0,0 +1,15 @@
-- Fractured / Paragon: spellbook tab for multidot Devouring Plague (951000 chain).
-- Shadow priest skill line (78); ClassMask 2064 matches mod-paragon SLA overlay.
-- Client: patched SkillLineAbility.dbc in patch-enUS-4 from the same script.
DELETE FROM `skilllineability_dbc` WHERE `ID` IN (1951000, 1951001, 1951002, 1951003, 1951004, 1951005, 1951006, 1951007, 1951008);
INSERT INTO `skilllineability_dbc` (`ID`,`SkillLine`,`Spell`,`RaceMask`,`ClassMask`,`ExcludeRace`,`ExcludeClass`,`MinSkillLineRank`,`SupercededBySpell`,`AcquireMethod`,`TrivialSkillLineRankHigh`,`TrivialSkillLineRankLow`,`CharacterPoints_1`,`CharacterPoints_2`) VALUES
(1951000,78,951000,0,2064,0,0,1,0,0,0,0,0,0),
(1951001,78,951001,0,2064,0,0,1,0,0,0,0,0,0),
(1951002,78,951002,0,2064,0,0,1,0,0,0,0,0,0),
(1951003,78,951003,0,2064,0,0,1,0,0,0,0,0,0),
(1951004,78,951004,0,2064,0,0,1,0,0,0,0,0,0),
(1951005,78,951005,0,2064,0,0,1,0,0,0,0,0,0),
(1951006,78,951006,0,2064,0,0,1,0,0,0,0,0,0),
(1951007,78,951007,0,2064,0,0,1,0,0,0,0,0,0),
(1951008,78,951008,0,2064,0,0,1,0,0,0,0,0,0);
@@ -0,0 +1,21 @@
-- Fractured / Paragon: Character Advancement stance/presence clones (951010-951015).
-- Client: patched Spell.dbc + SpellShapeshiftForm.dbc + SkillLineAbility.dbc in patch-enUS-4.MPQ.
-- Server: copy Spell.dbc + SpellShapeshiftForm.dbc into `data/dbc/` (SpellShapeshiftForm is not in stock MPQ); SkillLineAbility is DB-driven on server.
DELETE FROM `paragon_spell_ae_cost` WHERE `spell_id` IN (951010,951011,951012,951013,951014,951015);
INSERT INTO `paragon_spell_ae_cost` (`spell_id`,`ae_cost`) VALUES
(951010, 1),
(951011, 1),
(951012, 1),
(951013, 1),
(951014, 1),
(951015, 1);
DELETE FROM `skilllineability_dbc` WHERE `ID` IN (1951020,1951021,1951022,1951023,1951024,1951025);
INSERT INTO `skilllineability_dbc` (`ID`,`SkillLine`,`Spell`,`RaceMask`,`ClassMask`,`ExcludeRace`,`ExcludeClass`,`MinSkillLineRank`,`SupercededBySpell`,`AcquireMethod`,`TrivialSkillLineRankHigh`,`TrivialSkillLineRankLow`,`CharacterPoints_1`,`CharacterPoints_2`) VALUES
(1951020,26,951010,0,2049,0,0,1,0,2,0,0,0,0),
(1951021,257,951011,0,2049,0,0,1,0,0,0,0,0,0),
(1951022,256,951012,0,2049,0,0,1,0,0,0,0,0,0),
(1951023,770,951013,0,2080,0,0,1,0,2,0,0,0,0),
(1951024,771,951014,0,2080,0,0,1,0,0,0,0,0,0),
(1951025,772,951015,0,2080,0,0,1,0,0,0,0,0,0);
@@ -0,0 +1,9 @@
-- Fractured / Paragon: run spell_dk_presence on Character Advancement DK presence clones (951013-951015).
-- Spell.dbc sets SpellFamilyName=0 on these rows (see fractured-tooling/_patch_spell_dbc_paragon_stance_presence_clones.py)
-- so the stock client does not map them onto DK stance buttons; core still needs the aura script for Improved Presence.
DELETE FROM `spell_script_names` WHERE `spell_id` IN (951013, 951014, 951015);
INSERT INTO `spell_script_names` (`spell_id`, `ScriptName`) VALUES
(951013, 'spell_dk_presence'),
(951014, 'spell_dk_presence'),
(951015, 'spell_dk_presence');
@@ -0,0 +1,8 @@
-- Fractured / Paragon: Character Advancement stance/presence clones — spellbook + client bits.
-- 1) SkillLineAbility: DK presence clones belong on 770/771/772 (Blood/Frost/Unholy tabs), not 760 (General).
-- (760 was an experiment; stance bar visibility is driven by Spell.dbc AttributesEx2 USE_SHAPESHIFT_BAR.)
-- 2) Idempotent if rows already match.
UPDATE `skilllineability_dbc` SET `SkillLine` = 770 WHERE `ID` = 1951023 AND `Spell` = 951013;
UPDATE `skilllineability_dbc` SET `SkillLine` = 771 WHERE `ID` = 1951024 AND `Spell` = 951014;
UPDATE `skilllineability_dbc` SET `SkillLine` = 772 WHERE `ID` = 1951025 AND `Spell` = 951015;
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Kill AzerothCore authserver + worldserver tmux sessions and any stray processes.
#
# Usage:
# bash scripts/kill-azeroth-servers.sh
set -euo pipefail
AUTH_SESSION="authserver"
WORLD_SESSION="worldserver"
echo "Stopping servers..."
# Kill tmux sessions
if tmux has-session -t "$WORLD_SESSION" 2>/dev/null; then
tmux kill-session -t "$WORLD_SESSION"
echo " killed tmux session: $WORLD_SESSION"
else
echo " no tmux session: $WORLD_SESSION"
fi
if tmux has-session -t "$AUTH_SESSION" 2>/dev/null; then
tmux kill-session -t "$AUTH_SESSION"
echo " killed tmux session: $AUTH_SESSION"
else
echo " no tmux session: $AUTH_SESSION"
fi
# Clean up any stray processes not managed by tmux
if pkill -x worldserver 2>/dev/null; then
echo " killed stray worldserver process"
fi
if pkill -x authserver 2>/dev/null; then
echo " killed stray authserver process"
fi
echo "Done."
+38 -16
View File
@@ -1,23 +1,37 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Start AzerothCore authserver + worldserver detached from the SSH session (nohup + disown). # Start AzerothCore authserver + worldserver in named tmux sessions.
# Stops any already-running authserver/worldserver processes first. # Kills any already-running sessions first (acts as a restart).
# #
# Usage: # Usage:
# sudo bash scripts/start-azeroth-servers.sh # bash scripts/start-azeroth-servers.sh
# AZEROTH_BIN=/path/to/azeroth-server/bin bash scripts/start-azeroth-servers.sh # AZEROTH_BIN=/path/to/bin bash scripts/start-azeroth-servers.sh
# #
# Environment: # Environment:
# AZEROTH_BIN — directory with authserver and worldserver (default: /root/azeroth-server/bin) # AZEROTH_BIN — directory with authserver and worldserver (default: /home/fractured-panel/azeroth-server/bin)
# AZEROTH_LOG_DIR — log directory (default: <parent of bin>/logs) # AZEROTH_LOG_DIR — log directory (default: <parent of bin>/logs)
#
# tmux sessions:
# authserver — authserver console
# worldserver — worldserver console
set -euo pipefail set -euo pipefail
BIN_DIR="${AZEROTH_BIN:-/root/azeroth-server/bin}" BIN_DIR="${AZEROTH_BIN:-/home/fractured-panel/azeroth-server/bin}"
LOG_DIR="${AZEROTH_LOG_DIR:-$(cd "$(dirname "$BIN_DIR")" && pwd)/logs}" BASE_DIR="$(cd "$(dirname "$BIN_DIR")" && pwd)"
LOG_DIR="${AZEROTH_LOG_DIR:-${BASE_DIR}/logs}"
CONF_DIR="${BASE_DIR}/etc"
AUTH_BIN="${BIN_DIR}/authserver" AUTH_BIN="${BIN_DIR}/authserver"
WORLD_BIN="${BIN_DIR}/worldserver" WORLD_BIN="${BIN_DIR}/worldserver"
AUTH_SESSION="authserver"
WORLD_SESSION="worldserver"
if ! command -v tmux &>/dev/null; then
echo "error: tmux is not installed" >&2
exit 1
fi
if [[ ! -x "$AUTH_BIN" ]]; then if [[ ! -x "$AUTH_BIN" ]]; then
echo "error: not found or not executable: $AUTH_BIN" >&2 echo "error: not found or not executable: $AUTH_BIN" >&2
exit 1 exit 1
@@ -27,23 +41,31 @@ if [[ ! -x "$WORLD_BIN" ]]; then
exit 1 exit 1
fi fi
mkdir -p "$LOG_DIR"
# Tear down existing sessions (ignore errors if they don't exist)
tmux kill-session -t "$AUTH_SESSION" 2>/dev/null || true
tmux kill-session -t "$WORLD_SESSION" 2>/dev/null || true
# Also kill any stray processes not managed by tmux
pkill -x authserver 2>/dev/null || true pkill -x authserver 2>/dev/null || true
pkill -x worldserver 2>/dev/null || true pkill -x worldserver 2>/dev/null || true
sleep 1 sleep 1
mkdir -p "$LOG_DIR" # Launch authserver in a tmux session
tmux new-session -d -s "$AUTH_SESSION" -c "$BIN_DIR" \
cd "$BIN_DIR" "$AUTH_BIN -c ${CONF_DIR}/authserver.conf 2>&1 | tee -a ${LOG_DIR}/authserver.log"
nohup "$AUTH_BIN" >>"$LOG_DIR/authserver.log" 2>&1 &
disown || true
sleep 2 sleep 2
nohup "$WORLD_BIN" >>"$LOG_DIR/worldserver.log" 2>&1 & # Launch worldserver in a tmux session
disown || true tmux new-session -d -s "$WORLD_SESSION" -c "$BIN_DIR" \
"$WORLD_BIN -c ${CONF_DIR}/worldserver.conf 2>&1 | tee -a ${LOG_DIR}/worldserver.log"
echo "Started authserver and worldserver (survives SSH disconnect)." echo "Started servers in tmux sessions."
echo " tmux attach -t $AUTH_SESSION — authserver console"
echo " tmux attach -t $WORLD_SESSION — worldserver console"
echo "Bin: $BIN_DIR" echo "Bin: $BIN_DIR"
echo "Config: $CONF_DIR"
echo "Logs: $LOG_DIR/authserver.log" echo "Logs: $LOG_DIR/authserver.log"
echo " $LOG_DIR/worldserver.log" echo " $LOG_DIR/worldserver.log"
+62 -13
View File
@@ -6,24 +6,25 @@
# (see docs/DEPLOY_LINUX_VPS.md). # (see docs/DEPLOY_LINUX_VPS.md).
# #
# What this does: # What this does:
# 1. git pull on the current branch (optional; can skip) # 1. Optionally kill running servers (tmux sessions)
# 2. ./acore.sh compiler build — or compiler all for a full clean rebuild # 2. git pull on the current branch (optional; can skip)
# 3. ./acore.sh compiler build — or compiler all for a full clean rebuild
# 4. Optionally restart servers in tmux sessions
# #
# Database migrations from data/sql/updates/ run when you next start worldserver/authserver # Database migrations from data/sql/updates/ run when you next start worldserver/authserver
# (Updates.* / SourceDirectory in *.conf). This script does not start or stop daemons unless # (Updates.* / SourceDirectory in *.conf).
# you pass --run-after or set FRACTURED_POST_UPDATE_CMD.
# #
# Usage: # Usage:
# bash scripts/vps-update-server.sh # bash scripts/vps-update-server.sh # pull + compile only
# bash scripts/vps-update-server.sh --full # bash scripts/vps-update-server.sh --restart # pull + compile + restart servers in tmux
# bash scripts/vps-update-server.sh --no-pull # bash scripts/vps-update-server.sh --full --restart # clean rebuild + restart
# bash scripts/vps-update-server.sh --no-pull --restart # compile current tree + restart
# bash scripts/vps-update-server.sh --dry-run # bash scripts/vps-update-server.sh --dry-run
# FRACTURED_POST_UPDATE_CMD='sudo systemctl restart fractured-world' bash scripts/vps-update-server.sh --run-after # bash scripts/vps-update-server.sh --run-after 'custom command here'
# bash scripts/vps-update-server.sh --run-after 'sudo systemctl restart fractured-world'
# #
# Environment: # Environment:
# FRACTURED_GIT_REMOTE — remote name (default: origin) # FRACTURED_GIT_REMOTE — remote name (default: origin)
# FRACTURED_POST_UPDATE_CMD — shell command run after a successful compile (if --run-after is passed without an argument, this is used) # FRACTURED_POST_UPDATE_CMD — shell command run after compile (used by bare --run-after)
set -euo pipefail set -euo pipefail
@@ -35,6 +36,8 @@ FULL_BUILD=0
COMPILE_ONLY=0 COMPILE_ONLY=0
DRY_RUN=0 DRY_RUN=0
DO_RUN_AFTER=0 DO_RUN_AFTER=0
DO_RESTART=0
INSTALL_PREFIX=""
POST_UPDATE_CMD="${FRACTURED_POST_UPDATE_CMD:-}" POST_UPDATE_CMD="${FRACTURED_POST_UPDATE_CMD:-}"
GIT_REMOTE="${FRACTURED_GIT_REMOTE:-origin}" GIT_REMOTE="${FRACTURED_GIT_REMOTE:-origin}"
@@ -49,9 +52,11 @@ Options:
--no-pull Skip git pull (only compile current tree). --no-pull Skip git pull (only compile current tree).
--full ./acore.sh compiler all (clean + configure + compile). --full ./acore.sh compiler all (clean + configure + compile).
--compile-only ./acore.sh compiler compile (incremental). --compile-only ./acore.sh compiler compile (incremental).
--prefix PATH Override CMAKE_INSTALL_PREFIX (updates conf/config.sh BINPATH).
--dry-run Print commands without running them. --dry-run Print commands without running them.
--run-after [CMD] Run shell command after successful compile. If CMD is omitted, --restart Kill servers before compile, restart in tmux after.
uses FRACTURED_POST_UPDATE_CMD from the environment. --run-after [CMD] Run a custom shell command after successful compile.
If CMD is omitted, uses FRACTURED_POST_UPDATE_CMD.
Environment: Environment:
FRACTURED_GIT_REMOTE Git remote (default: origin). FRACTURED_GIT_REMOTE Git remote (default: origin).
@@ -87,10 +92,23 @@ while [[ $# -gt 0 ]]; do
COMPILE_ONLY=1 COMPILE_ONLY=1
shift shift
;; ;;
--prefix)
shift
if [[ $# -eq 0 || "$1" == -* ]]; then
echo "error: --prefix requires a path argument" >&2
exit 2
fi
INSTALL_PREFIX="$1"
shift
;;
--dry-run) --dry-run)
DRY_RUN=1 DRY_RUN=1
shift shift
;; ;;
--restart)
DO_RESTART=1
shift
;;
--run-after) --run-after)
DO_RUN_AFTER=1 DO_RUN_AFTER=1
shift shift
@@ -129,6 +147,28 @@ fi
cd "$ROOT" cd "$ROOT"
KILL_SCRIPT="${SCRIPT_DIR}/kill-azeroth-servers.sh"
START_SCRIPT="${SCRIPT_DIR}/start-azeroth-servers.sh"
if [[ "$DO_RESTART" -eq 1 ]]; then
if [[ ! -f "$KILL_SCRIPT" || ! -f "$START_SCRIPT" ]]; then
echo "error: --restart requires kill-azeroth-servers.sh and start-azeroth-servers.sh in scripts/" >&2
exit 1
fi
echo "==> stopping servers before compile"
run bash "$KILL_SCRIPT"
fi
if [[ -n "$INSTALL_PREFIX" ]]; then
echo "==> updating conf/config.sh BINPATH to: $INSTALL_PREFIX"
if grep -q '^BINPATH=' conf/config.sh; then
run sed -i "s|^BINPATH=.*|BINPATH=\"$INSTALL_PREFIX\"|" conf/config.sh
else
echo "BINPATH=\"$INSTALL_PREFIX\"" >> conf/config.sh
fi
export BINPATH="$INSTALL_PREFIX"
fi
if [[ "$DO_RUN_AFTER" -eq 1 && -z "${POST_UPDATE_CMD// }" ]]; then if [[ "$DO_RUN_AFTER" -eq 1 && -z "${POST_UPDATE_CMD// }" ]]; then
echo "error: --run-after needs a command or FRACTURED_POST_UPDATE_CMD set in the environment." >&2 echo "error: --run-after needs a command or FRACTURED_POST_UPDATE_CMD set in the environment." >&2
exit 2 exit 2
@@ -168,6 +208,11 @@ else
run ./acore.sh compiler build run ./acore.sh compiler build
fi fi
if [[ "$DO_RESTART" -eq 1 ]]; then
echo "==> restarting servers in tmux sessions"
run bash "$START_SCRIPT"
fi
if [[ "$DO_RUN_AFTER" -eq 1 ]]; then if [[ "$DO_RUN_AFTER" -eq 1 ]]; then
echo "==> post-update: $POST_UPDATE_CMD" echo "==> post-update: $POST_UPDATE_CMD"
if [[ "$DRY_RUN" -eq 1 ]]; then if [[ "$DRY_RUN" -eq 1 ]]; then
@@ -178,4 +223,8 @@ if [[ "$DO_RUN_AFTER" -eq 1 ]]; then
fi fi
fi fi
echo "Done. Restart authserver/worldserver (or your service manager) when ready so new binaries and SQL updates apply." if [[ "$DO_RESTART" -eq 0 && "$DO_RUN_AFTER" -eq 0 ]]; then
echo "Done. Run 'bash scripts/start-azeroth-servers.sh' to (re)start servers in tmux."
else
echo "Done."
fi
@@ -1753,9 +1753,9 @@ InstantLogout = 1
# #
# PlayerSaveInterval # PlayerSaveInterval
# Description: Time (in milliseconds) for player save interval. # Description: Time (in milliseconds) for player save interval.
# Default: 900000 - (15 min) # Default: 300000 - (5 min)
PlayerSaveInterval = 900000 PlayerSaveInterval = 300000
# #
# PlayerSave.Stats.MinLevel # PlayerSave.Stats.MinLevel
@@ -2260,9 +2260,9 @@ Achievement.RealmFirstKillWindow = 60
# MaxPrimaryTradeSkill # MaxPrimaryTradeSkill
# Description: Maximum number of primary professions a character can learn. # Description: Maximum number of primary professions a character can learn.
# Range: 0-11 # Range: 0-11
# Default: 2 # Default: 11 - (All WotLK primary professions; set 2 for retail-like two-slot cap.)
MaxPrimaryTradeSkill = 2 MaxPrimaryTradeSkill = 11
# #
# SkillChance.Prospecting # SkillChance.Prospecting
+1
View File
@@ -678,6 +678,7 @@ enum RBACPermissions
RBAC_PERM_COMMAND_BF_QUEUE = 913, RBAC_PERM_COMMAND_BF_QUEUE = 913,
RBAC_PERM_COMMAND_PET_LIST = 914, RBAC_PERM_COMMAND_PET_LIST = 914,
RBAC_PERM_COMMAND_PET_DELETE = 915, RBAC_PERM_COMMAND_PET_DELETE = 915,
RBAC_PERM_COMMAND_LEARN_ALL_MOUNTS = 916,
// custom permissions 1000+ // custom permissions 1000+
RBAC_PERM_MAX RBAC_PERM_MAX
}; };
@@ -3640,6 +3640,13 @@ bool Creature::IsMovementPreventedByCasting() const
return false; return false;
} }
// Fractured: cast-time mount summon (player-style mount spells on NPCs are rare but supported).
if (Spell* genSpell = m_currentSpells[CURRENT_GENERIC_SPELL])
{
if (genSpell->getState() == SPELL_STATE_PREPARING && genSpell->m_spellInfo->IsCastTimeRidingMountSpell())
return false;
}
if (HasSpellFocus()) if (HasSpellFocus())
{ {
return true; return true;
+37 -18
View File
@@ -7143,14 +7143,16 @@ bool Player::CheckAttackFitToAuraRequirement(WeaponAttackType attackType, AuraEf
if (spellInfo->EquippedItemClass == -1) if (spellInfo->EquippedItemClass == -1)
return true; return true;
// Fractured / Paragon: cross-class wildcard relaxes weapon-class subclass // Fractured / Paragon: cross-class wildcard relaxes the weapon-subclass
// gates on per-swing proc matches. A Paragon's talent list spans every // gate ONLY for the curated allowlist of cross-class proc talents
// class so a stock weapon-subclass mask (e.g. Maelstrom Weapon's // (currently just Maelstrom Weapon 51528-51532). Weapon-specialization
// axe/mace/staff/fist/dagger restriction) excludes weapons the player // talents like Sword Specialization, Mace Specialization, Hack and
// can legitimately wield. Accept any equipped weapon in attackType slot // Slash, Two-Handed Weapon Specialization etc. deliberately stay
// when listener is a Paragon AND the spell gates on ITEM_CLASS_WEAPON; // weapon-gated for Paragon -- the player picks a weapon and the
// ITEM_CLASS_ARMOR (shield) gates still enforce the original mask. // matching specialization passive activates, same as any class.
if (spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON && IsParagonWildcardCaller(this)) if (spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON
&& IsParagonWildcardCaller(this)
&& IsParagonWeaponSubclassWildcardSpell(spellInfo->Id))
return GetWeaponForAttack(attackType, true) != nullptr; return GetWeaponForAttack(attackType, true) != nullptr;
Item* item = GetWeaponForAttack(attackType, true); Item* item = GetWeaponForAttack(attackType, true);
@@ -12046,7 +12048,20 @@ void Player::learnSkillRewardedSpells(uint32 skill_id, uint32 skill_value)
// weapon, language, and racial skill cascades stay enabled so things // weapon, language, and racial skill cascades stay enabled so things
// like recipe auto-learn, weapon proficiencies, and racial perks // like recipe auto-learn, weapon proficiencies, and racial perks
// still work. // still work.
if (getClass() == CLASS_PARAGON) //
// Carve-out: SKILL_RUNEFORGING (776) is a CLASS-category skill but
// behaves like a profession in this context — the player buys ONE
// panel ability (Runeforging, spell 53428) and the rune-enchant
// spells (Rune of the Fallen Crusader, Razorice, Cinderglacier, ...)
// are supposed to come along for the ride via the standard SLA
// cascade, exactly the same way they do for a stock DK. Without
// this carve-out, the early-return below blocks the cascade and a
// Paragon who buys Runeforging gets the skill but no actual rune
// options at the runeforge anvil. The cascade only fires once per
// skill-grant for 776 (it's not on UpdateSkillsForLevel) so the
// "leaking back into the spellbook" concern that motivates the
// early-return doesn't apply to this skill.
if (getClass() == CLASS_PARAGON && skill_id != SKILL_RUNEFORGING)
{ {
if (SkillLineEntry const* sl = sSkillLineStore.LookupEntry(skill_id)) if (SkillLineEntry const* sl = sSkillLineStore.LookupEntry(skill_id))
if (sl->categoryId == SKILL_CATEGORY_CLASS) if (sl->categoryId == SKILL_CATEGORY_CLASS)
@@ -12591,15 +12606,19 @@ bool Player::HasItemFitToSpellRequirements(SpellInfo const* spellInfo, Item cons
if (spellInfo->EquippedItemClass < 0) if (spellInfo->EquippedItemClass < 0)
return true; return true;
// Fractured / Paragon: cross-class wildcard relaxes weapon-class subclass // Fractured / Paragon: cross-class wildcard relaxes the weapon-subclass
// gates so passive talent auras (e.g. Maelstrom Weapon talents 51528-51532) // gate ONLY for the curated allowlist of cross-class proc talents
// attach for any equipped weapon, not just the talent's restrictive // (currently just Maelstrom Weapon 51528-51532) so the passive talent
// subclass mask. Mirrors CheckAttackFitToAuraRequirement so per-swing // aura attaches when the player wields a non-stock weapon. Weapon-
// proc match agrees with talent-attach time. Still requires *some* weapon // specialization talents (Sword/Mace Specialization, Hack and Slash,
// to be equipped (otherwise unarmed Paragons would auto-activate every // Two-Handed Weapon Specialization, etc.) deliberately stay weapon-
// weapon-gated talent in the game). ITEM_CLASS_ARMOR (shield) is left // gated -- they're meant to bind to a specific weapon type. Still
// alone -- shield-gated talents still need an actual shield. // requires *some* weapon equipped (unarmed Paragons don't auto-activate
if (spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON && IsParagonWildcardCaller(this)) // weapon talents). ITEM_CLASS_ARMOR (shield) is left alone -- shield-
// gated talents still need an actual shield.
if (spellInfo->EquippedItemClass == ITEM_CLASS_WEAPON
&& IsParagonWildcardCaller(this)
&& IsParagonWeaponSubclassWildcardSpell(spellInfo->Id))
{ {
for (uint8 i = EQUIPMENT_SLOT_MAINHAND; i < EQUIPMENT_SLOT_TABARD; ++i) for (uint8 i = EQUIPMENT_SLOT_MAINHAND; i < EQUIPMENT_SLOT_TABARD; ++i)
if (Item const* item = GetUseableItemByPos(INVENTORY_SLOT_BAG_0, i)) if (Item const* item = GetUseableItemByPos(INVENTORY_SLOT_BAG_0, i))
+1
View File
@@ -1828,6 +1828,7 @@ public:
uint32 GetLastPotionId() { return m_lastPotionId; } uint32 GetLastPotionId() { return m_lastPotionId; }
void SetLastPotionId(uint32 item_id) { m_lastPotionId = item_id; } void SetLastPotionId(uint32 item_id) { m_lastPotionId = item_id; }
void UpdatePotionCooldown(Spell* spell = nullptr); void UpdatePotionCooldown(Spell* spell = nullptr);
void AtEnterCombat() override;
void AtExitCombat() override; void AtExitCombat() override;
void setResurrectRequestData(ObjectGuid guid, uint32 mapId, float X, float Y, float Z, uint32 health, uint32 mana) void setResurrectRequestData(ObjectGuid guid, uint32 mapId, float X, float Y, float Z, uint32 health, uint32 mana)
@@ -32,6 +32,7 @@
#include "Player.h" #include "Player.h"
#include "ScriptMgr.h" #include "ScriptMgr.h"
#include "SkillDiscovery.h" #include "SkillDiscovery.h"
#include "Spell.h"
#include "SpellAuraEffects.h" #include "SpellAuraEffects.h"
#include "SpellMgr.h" #include "SpellMgr.h"
#include "UpdateFieldFlags.h" #include "UpdateFieldFlags.h"
@@ -332,6 +333,28 @@ void Player::Update(uint32 p_time)
} }
} }
if (m_additionalSaveTimer)
{
if (p_time >= m_additionalSaveTimer)
{
m_additionalSaveTimer = 0;
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
if (m_additionalSaveMask & ADDITIONAL_SAVING_INVENTORY_AND_GOLD)
SaveInventoryAndGoldToDB(trans);
if (m_additionalSaveMask & ADDITIONAL_SAVING_QUEST_STATUS)
_SaveQuestStatus(trans);
CharacterDatabase.CommitTransaction(trans);
m_additionalSaveMask = 0;
}
else
{
m_additionalSaveTimer -= p_time;
}
}
// Handle Water/drowning // Handle Water/drowning
HandleDrowning(p_time); HandleDrowning(p_time);
@@ -1539,6 +1562,27 @@ void Player::UpdatePvP(bool state, bool _override)
sScriptMgr->OnPlayerPVPFlagChange(this, state); sScriptMgr->OnPlayerPVPFlagChange(this, state);
} }
void Player::AtEnterCombat()
{
Unit::AtEnterCombat();
// Fractured: cancel cast-time mount summon if combat starts mid-cast.
for (uint32 spellType = CURRENT_FIRST_NON_MELEE_SPELL; spellType < CURRENT_MAX_SPELL; ++spellType)
{
if (Spell* spell = GetCurrentSpell(CurrentSpellTypes(spellType)))
{
if (SpellInfo const* info = spell->GetSpellInfo())
{
if (info->IsCastTimeRidingMountSpell())
{
InterruptSpell(CurrentSpellTypes(spellType), false, false);
break;
}
}
}
}
}
void Player::AtExitCombat() void Player::AtExitCombat()
{ {
Unit::AtExitCombat(); Unit::AtExitCombat();
+19 -1
View File
@@ -474,7 +474,25 @@ void Player::UpdateAttackPowerAndDamage(bool ranged)
switch (GetShapeshiftForm()) switch (GetShapeshiftForm())
{ {
case FORM_CAT: case FORM_CAT:
val2 = (GetLevel() * mLevelMult) + GetStat(STAT_STRENGTH) * 2.0f + GetStat(STAT_AGILITY) - 20.0f + weapon_bonus + m_baseFeralAP; // Fractured: Cat Form gets 2 AP per Agility instead of stock 1.
// Field reports said "weapons dont automatically feature feral
// AP on this server and nothing is currently rescaled, super
// low feral scale" -- specifically a CAT issue, not a bear
// issue (the resident bear had 11k AP, the resident cat was
// miles behind because Stam > AP and Armor > AP for bears
// hides the missing weapon-AP for them but cat's whole
// mainline is melee crits scaling off AP). The cleanest knob
// that does NOT touch bear is the AGI multiplier in this
// switch -- bears get STR*2 with no AGI term, so doubling
// the AGI coefficient lifts cat's primary scaling stat
// without re-buffing bear. Also pairs with the cat-form
// Master Shapeshifter buff in SpellAuraEffects.cpp's
// FORM_CAT branch (bp doubled there). Together that lands
// the resident Feral expert's recommendation
// ("instead of adding a new passive, you could probably
// just increase Cat Form's Master Shapeshifter value along
// with its tooltip, alongside buffing the agi scaling").
val2 = (GetLevel() * mLevelMult) + GetStat(STAT_STRENGTH) * 2.0f + GetStat(STAT_AGILITY) * 2.0f - 20.0f + weapon_bonus + m_baseFeralAP;
break; break;
case FORM_BEAR: case FORM_BEAR:
case FORM_DIREBEAR: case FORM_DIREBEAR:
+123
View File
@@ -103,6 +103,122 @@ bool ParagonFamilyMatches(Unit const* listener, uint32 expectedFamily, uint32 ac
return expectedFamily == actualFamily; return expectedFamily == actualFamily;
} }
bool IsParagonWeaponSubclassWildcardSpell(uint32 spellId)
{
if (!spellId)
return false;
// Resolve to the first-rank id so callers can pass any rank.
uint32 firstRankId = spellId;
if (SpellInfo const* info = sSpellMgr->GetSpellInfo(spellId))
if (SpellInfo const* first = info->GetFirstRankSpell())
firstRankId = first->Id;
switch (firstRankId)
{
// Maelstrom Weapon (talent ranks 51528 / 51529 / 51530 / 51531 / 51532).
// Cross-class proc talent that should fire off any equipped weapon
// for a Paragon caster (1H sword, polearm, staff, fist, dagger, etc.).
case 51528:
return true;
default:
return false;
}
}
bool IsFracturedExclusiveStanceSpell(uint32 spellId)
{
if (!spellId)
return false;
// Resolve to the first-rank id so callers can pass any rank. This means
// every rank of Aspect of the Hawk / Wild / Pack / Dragonhawk is covered
// by listing only the rank-1 id below; same for druid forms that have
// multiple ranks via talent (none in WotLK actually, but kept consistent).
uint32 firstRankId = spellId;
if (SpellInfo const* info = sSpellMgr->GetSpellInfo(spellId))
if (SpellInfo const* first = info->GetFirstRankSpell())
firstRankId = first->Id;
switch (firstRankId)
{
// -- Warrior stances (engine-shapeshifts; engine already mutually
// excludes them with each other and with druid forms via
// AuraEffect::HandleAuraModShapeshift's RemoveAurasByType, but we
// list them here so they participate in the union with presences /
// aspects).
case 2457: // Battle Stance
case 71: // Defensive Stance
case 2458: // Berserker Stance
// -- Paragon Advancement warrior stance clones (951010-951012).
case 951010:
case 951011:
case 951012:
// -- Druid combat forms (engine-shapeshifts).
case 5487: // Bear Form
case 9634: // Dire Bear Form
case 768: // Cat Form
case 24858: // Moonkin Form
case 33891: // Tree of Life Form
// -- Druid utility forms (engine-shapeshifts; included per design
// decision 2026-05-11 -- player must drop Travel/Aquatic/Flight to
// apply Hawk / Frost Presence / Berserker Stance, and vice versa).
case 783: // Travel Form
case 1066: // Aquatic Form
case 33943: // Flight Form
case 40120: // Swift Flight Form
// -- Shaman utility form (engine-shapeshift FORM_GHOSTWOLF).
case 2645: // Ghost Wolf
// -- Rogue base stealth (engine-shapeshift FORM_STEALTH). Shadow
// Dance (51713) is intentionally NOT listed -- it is a 6s
// stealth-burst on a 60s CD, gating it would defeat its purpose.
case 1784: // Stealth
// -- Priest combat form (engine-shapeshift FORM_SHADOW).
case 15473: // Shadowform
// -- Warlock combat form (engine-shapeshift FORM_METAMORPHOSIS).
case 47241: // Metamorphosis
// -- Death Knight Presences. NOT engine-shapeshifts in stock AC --
// they are regular auras that the client just renders in the
// stance bar -- which is exactly why stock DK can stack them on
// top of Bear Form / Defensive Stance / Aspect of the Hawk on a
// Paragon character. Listing them here is what plugs the gap.
case 48266: // Blood Presence
case 48263: // Frost Presence
case 48265: // Unholy Presence
// -- Paragon Advancement DK presence clones (951013-951015).
case 951013:
case 951014:
case 951015:
// -- Hunter Aspects (combat). Like presences, these are regular
// auras stock AC, not engine-shapeshifts; rank-1 ids cover all
// ranks via GetFirstRankSpell. Cheetah / Pack are the utility
// aspects -- included per design decision so a hunter must pick
// between Hawk and Cheetah (no more "always Hawk while running",
// matches Ascension's nerf rationale for Monkey).
case 13165: // Aspect of the Hawk (rank 1; ranks 14318/14319/14320/14321/14322/25296/27044 covered via first-rank)
case 5118: // Aspect of the Cheetah
case 13159: // Aspect of the Pack (rank 1; rank 27047 covered via first-rank)
case 20043: // Aspect of the Wild (rank 1; ranks 20190/27045 covered via first-rank)
case 13161: // Aspect of the Beast
case 13163: // Aspect of the Monkey
case 34074: // Aspect of the Viper
case 61846: // Aspect of the Dragonhawk (rank 1; rank 61847 covered via first-rank)
return true;
default:
return false;
}
}
float baseMoveSpeed[MAX_MOVE_TYPE] = float baseMoveSpeed[MAX_MOVE_TYPE] =
{ {
2.5f, // MOVE_WALK 2.5f, // MOVE_WALK
@@ -4387,6 +4503,13 @@ bool Unit::IsMovementPreventedByCasting() const
return false; return false;
} }
// Fractured: cast-time mount summon may be completed while moving.
if (Spell* genSpell = m_currentSpells[CURRENT_GENERIC_SPELL])
{
if (genSpell->getState() == SPELL_STATE_PREPARING && genSpell->m_spellInfo->IsCastTimeRidingMountSpell())
return false;
}
// channeled spells during channel stage (after the initial cast timer) allow movement with a specific spell attribute // channeled spells during channel stage (after the initial cast timer) allow movement with a specific spell attribute
if (Spell* spell = m_currentSpells[CURRENT_CHANNELED_SPELL]) if (Spell* spell = m_currentSpells[CURRENT_CHANNELED_SPELL])
{ {
+41
View File
@@ -2287,6 +2287,47 @@ private:
// beyond what they already include via Unit.h's transitive headers. // beyond what they already include via Unit.h's transitive headers.
[[nodiscard]] bool ParagonFamilyMatches(Unit const* listener, uint32 expectedFamily, uint32 actualFamily); [[nodiscard]] bool ParagonFamilyMatches(Unit const* listener, uint32 expectedFamily, uint32 actualFamily);
// Fractured / Paragon: returns true iff `spellId` is on the curated allowlist
// of cross-class proc talents whose `EquippedItemSubClassMask` should be
// bypassed for Paragon casters (e.g. Maelstrom Weapon ranks 51528-51532).
// Used by `Player::HasItemFitToSpellRequirements`,
// `Player::CheckAttackFitToAuraRequirement`, and
// `Aura::IsProcTriggeredOnEvent` to let these specific talents fire from
// any equipped weapon while keeping weapon-specialization talents
// (Sword Specialization, Mace Specialization, Hack and Slash, Two-Handed
// Weapon Specialization, etc.) gated by their stock weapon-class mask.
// The allowlist is matched against `SpellInfo::GetFirstRankSpell()`'s id
// so all ranks of a talent are covered by listing the rank-1 id.
[[nodiscard]] bool IsParagonWeaponSubclassWildcardSpell(uint32 spellId);
// Fractured: returns true iff `spellId` is one of the cross-class
// "stance-like" auras that we treat as mutually exclusive on this server,
// regardless of the caster's class. Stock AC engine-shapeshifts (warrior
// stances, druid forms, Shadowform, Metamorphosis) already auto-replace
// each other via `RemoveAurasByType(SPELL_AURA_MOD_SHAPESHIFT)` in
// `AuraEffect::HandleAuraModShapeshift`, but DK Presences and Hunter
// Aspects are regular auras (the client just renders them in the stance
// bar), so they coexist with shapeshifts in stock AC. The Fractured rule
// makes the entire union mutually exclusive: warrior stances + druid
// forms (combat AND utility -- Travel/Aquatic/Flight/Swift Flight) +
// Ghost Wolf + Stealth + Shadowform + Metamorphosis + DK Presences +
// Hunter Aspects (combat AND utility -- Cheetah/Pack). Casting any of
// them removes any other from the same set, so e.g. a Paragon DK can no
// longer stack Frost Presence on top of Bear Form, and a hunter must
// pick between Hawk and Cheetah even out of combat.
//
// The set is matched against `SpellInfo::GetFirstRankSpell()`'s id so
// every rank of every aspect / form is covered by listing the rank-1 id.
// Server-wide -- this is *not* gated on CLASS_PARAGON because the only
// stock-class-only effect of the rule (a DK losing Travel Form when
// they cast Frost Presence) is impossible: stock DKs cannot shapeshift.
// Used by `Aura::CanStackWith` to refuse stacking, which then drives
// `Unit::_RemoveNoStackAurasDueToAura` to drop the older aura -- the
// same mechanism Battle Elixirs / Curses already use (spell_group rule
// SPELL_GROUP_STACK_RULE_EXCLUSIVE), implemented in C++ here so we do
// not have to enumerate every rank of every aspect / form in SQL.
[[nodiscard]] bool IsFracturedExclusiveStanceSpell(uint32 spellId);
namespace Acore namespace Acore
{ {
// Binary predicate for sorting Units based on percent value of a power // Binary predicate for sorting Units based on percent value of a power
+7 -1
View File
@@ -99,7 +99,13 @@ enum ShapeshiftForm
FORM_FLIGHT = 0x1D, FORM_FLIGHT = 0x1D,
FORM_STEALTH = 0x1E, FORM_STEALTH = 0x1E,
FORM_MOONKIN = 0x1F, FORM_MOONKIN = 0x1F,
FORM_SPIRITOFREDEMPTION = 0x20 FORM_SPIRITOFREDEMPTION = 0x20,
// Fractured / Paragon: Character Advancement warrior stance clones (Spell.dbc
// MOD_SHAPESHIFT -> SpellShapeshiftForm 33-35, BonusActionBar=0, no client bar swap).
FORM_PARAGON_BATTLE_STANCE = 33,
FORM_PARAGON_DEFENSIVE_STANCE = 34,
FORM_PARAGON_BERSERKER_STANCE = 35,
}; };
enum ShapeshiftFlags enum ShapeshiftFlags
@@ -1373,12 +1373,15 @@ void AuraEffect::HandleShapeshiftBoosts(Unit* target, bool apply) const
HotWSpellId = 24899; HotWSpellId = 24899;
break; break;
case FORM_BATTLESTANCE: case FORM_BATTLESTANCE:
case FORM_PARAGON_BATTLE_STANCE:
spellId = 21156; spellId = 21156;
break; break;
case FORM_DEFENSIVESTANCE: case FORM_DEFENSIVESTANCE:
case FORM_PARAGON_DEFENSIVE_STANCE:
spellId = 7376; spellId = 7376;
break; break;
case FORM_BERSERKERSTANCE: case FORM_BERSERKERSTANCE:
case FORM_PARAGON_BERSERKER_STANCE:
spellId = 7381; spellId = 7381;
break; break;
case FORM_MOONKIN: case FORM_MOONKIN:
@@ -1545,7 +1548,21 @@ void AuraEffect::HandleShapeshiftBoosts(Unit* target, bool apply) const
// Master Shapeshifter - Cat // Master Shapeshifter - Cat
if (AuraEffect const* aurEff = target->GetDummyAuraEffect(SPELLFAMILY_GENERIC, 2851, 0)) if (AuraEffect const* aurEff = target->GetDummyAuraEffect(SPELLFAMILY_GENERIC, 2851, 0))
{ {
int32 bp = aurEff->GetAmount(); // Fractured: cat-only Master Shapeshifter bonus is
// doubled (rank 1: 2% -> 4%, rank 2: 4% -> 8%) to
// make Feral Cat builds feel less "super low feral
// scale" without touching bear / moonkin / tree (the
// FORM_BEAR / FORM_MOONKIN / FORM_TREE branches
// below pass `bp` straight through, unchanged). The
// talent's own SpellInfo Effects[].BasePoints is
// intentionally NOT bumped -- aurEff->GetAmount()
// returns the per-rank talent value (2 / 4) shared
// across all four forms, so we apply the cat
// multiplier here at the cast site, leaving every
// other form on the stock value. Pairs with the
// Cat-Form AGI doubling in StatSystem.cpp's
// UpdateAttackPowerAndDamage FORM_CAT branch.
int32 bp = aurEff->GetAmount() * 2;
target->CastCustomSpell(target, 48420, &bp, nullptr, nullptr, true); target->CastCustomSpell(target, 48420, &bp, nullptr, nullptr, true);
} }
break; break;
@@ -1981,7 +1998,7 @@ void AuraEffect::HandlePhase(AuraApplication const* aurApp, uint8 mode, bool app
/*** UNIT MODEL ***/ /*** UNIT MODEL ***/
/**********************/ /**********************/
void AuraEffect::HandleAuraModShapeshift(AuraApplication const* aurApp, uint8 mode, bool apply) const static void Fractured_ApplyShapeshiftFormFromAuraEffect(AuraEffect const* aurEff, AuraApplication const* aurApp, uint8 mode, bool apply, ShapeshiftForm form)
{ {
if (!(mode & AURA_EFFECT_HANDLE_REAL_OR_REAPPLY_MASK)) if (!(mode & AURA_EFFECT_HANDLE_REAL_OR_REAPPLY_MASK))
return; return;
@@ -1990,8 +2007,6 @@ void AuraEffect::HandleAuraModShapeshift(AuraApplication const* aurApp, uint8 mo
uint32 modelid = 0; uint32 modelid = 0;
Powers PowerType = POWER_MANA; Powers PowerType = POWER_MANA;
ShapeshiftForm form = ShapeshiftForm(GetMiscValue());
switch (form) switch (form)
{ {
case FORM_CAT: // 0x01 case FORM_CAT: // 0x01
@@ -2005,6 +2020,9 @@ void AuraEffect::HandleAuraModShapeshift(AuraApplication const* aurApp, uint8 mo
case FORM_BATTLESTANCE: // 0x11 case FORM_BATTLESTANCE: // 0x11
case FORM_DEFENSIVESTANCE: // 0x12 case FORM_DEFENSIVESTANCE: // 0x12
case FORM_BERSERKERSTANCE: // 0x13 case FORM_BERSERKERSTANCE: // 0x13
case FORM_PARAGON_BATTLE_STANCE:
case FORM_PARAGON_DEFENSIVE_STANCE:
case FORM_PARAGON_BERSERKER_STANCE:
PowerType = POWER_RAGE; PowerType = POWER_RAGE;
break; break;
@@ -2035,10 +2053,10 @@ void AuraEffect::HandleAuraModShapeshift(AuraApplication const* aurApp, uint8 mo
case FORM_SPIRITOFREDEMPTION: // 0x20 case FORM_SPIRITOFREDEMPTION: // 0x20
break; break;
default: default:
LOG_ERROR("spells.aura.effect", "Auras: Unknown Shapeshift Type: {}", GetMiscValue()); LOG_ERROR("spells.aura.effect", "Auras: Unknown Shapeshift Type: {}", aurEff->GetMiscValue());
} }
modelid = target->GetModelForForm(form, GetId()); modelid = target->GetModelForForm(form, aurEff->GetId());
if (apply) if (apply)
{ {
@@ -2073,8 +2091,8 @@ void AuraEffect::HandleAuraModShapeshift(AuraApplication const* aurApp, uint8 mo
// remove other shapeshift before applying a new one // remove other shapeshift before applying a new one
// xinef: rogue shouldnt be wrapped by this check (shadow dance vs stealth) // xinef: rogue shouldnt be wrapped by this check (shadow dance vs stealth)
if (GetSpellInfo()->SpellFamilyName != SPELLFAMILY_ROGUE) if (aurEff->GetSpellInfo()->SpellFamilyName != SPELLFAMILY_ROGUE)
target->RemoveAurasByType(SPELL_AURA_MOD_SHAPESHIFT, ObjectGuid::Empty, GetBase()); target->RemoveAurasByType(SPELL_AURA_MOD_SHAPESHIFT, ObjectGuid::Empty, aurEff->GetBase());
// stop handling the effect if it was removed by linked event // stop handling the effect if it was removed by linked event
if (aurApp->GetRemoveMode()) if (aurApp->GetRemoveMode())
@@ -2098,13 +2116,13 @@ void AuraEffect::HandleAuraModShapeshift(AuraApplication const* aurApp, uint8 mo
if (AuraEffect const* dummy = target->GetDummyAuraEffect(SPELLFAMILY_DRUID, 238, 0)) if (AuraEffect const* dummy = target->GetDummyAuraEffect(SPELLFAMILY_DRUID, 238, 0))
FurorChance = std::max(dummy->GetAmount(), 0); FurorChance = std::max(dummy->GetAmount(), 0);
switch (GetMiscValue()) switch (aurEff->GetMiscValue())
{ {
case FORM_CAT: case FORM_CAT:
{ {
int32 basePoints = int32(std::min(oldPower, FurorChance)); int32 basePoints = int32(std::min(oldPower, FurorChance));
target->SetPower(POWER_ENERGY, 0); target->SetPower(POWER_ENERGY, 0);
target->CastCustomSpell(target, 17099, &basePoints, nullptr, nullptr, true, nullptr, this); target->CastCustomSpell(target, 17099, &basePoints, nullptr, nullptr, true, nullptr, aurEff);
break; break;
} }
case FORM_BEAR: case FORM_BEAR:
@@ -2178,13 +2196,16 @@ void AuraEffect::HandleAuraModShapeshift(AuraApplication const* aurApp, uint8 mo
case FORM_BATTLESTANCE: case FORM_BATTLESTANCE:
case FORM_DEFENSIVESTANCE: case FORM_DEFENSIVESTANCE:
case FORM_BERSERKERSTANCE: case FORM_BERSERKERSTANCE:
case FORM_PARAGON_BATTLE_STANCE:
case FORM_PARAGON_DEFENSIVE_STANCE:
case FORM_PARAGON_BERSERKER_STANCE:
{ {
uint32 Rage_val = 0; uint32 Rage_val = 0;
// Defensive Tactics // Defensive Tactics
if (form == FORM_DEFENSIVESTANCE) if (form == FORM_DEFENSIVESTANCE || form == FORM_PARAGON_DEFENSIVE_STANCE)
{ {
if (AuraEffect const* aurEff = target->IsScriptOverriden(m_spellInfo, 831)) if (AuraEffect const* scriptEff = target->IsScriptOverriden(aurEff->GetSpellInfo(), 831))
Rage_val += aurEff->GetAmount() * 10; Rage_val += scriptEff->GetAmount() * 10;
} }
// Stance mastery + Tactical mastery (both passive, and last have aura only in defense stance, but need apply at any stance switch) // Stance mastery + Tactical mastery (both passive, and last have aura only in defense stance, but need apply at any stance switch)
if (target->IsPlayer()) if (target->IsPlayer())
@@ -2224,7 +2245,7 @@ void AuraEffect::HandleAuraModShapeshift(AuraApplication const* aurApp, uint8 mo
// adding/removing linked auras // adding/removing linked auras
// add/remove the shapeshift aura's boosts // add/remove the shapeshift aura's boosts
HandleShapeshiftBoosts(target, apply); aurEff->HandleShapeshiftBoosts(target, apply);
if (target->IsPlayer()) if (target->IsPlayer())
target->ToPlayer()->InitDataForForm(); target->ToPlayer()->InitDataForForm();
@@ -2232,8 +2253,8 @@ void AuraEffect::HandleAuraModShapeshift(AuraApplication const* aurApp, uint8 mo
if (target->IsClass(CLASS_DRUID, CLASS_CONTEXT_ABILITY)) if (target->IsClass(CLASS_DRUID, CLASS_CONTEXT_ABILITY))
{ {
// Dash // Dash
if (AuraEffect* aurEff = target->GetAuraEffect(SPELL_AURA_MOD_INCREASE_SPEED, SPELLFAMILY_DRUID, 0, 0, 0x8)) if (AuraEffect* dashSpeedEff = target->GetAuraEffect(SPELL_AURA_MOD_INCREASE_SPEED, SPELLFAMILY_DRUID, 0, 0, 0x8))
aurEff->RecalculateAmount(); dashSpeedEff->RecalculateAmount();
// Disarm handling // Disarm handling
// If druid shifts while being disarmed we need to deal with that since forms aren't affected by disarm // If druid shifts while being disarmed we need to deal with that since forms aren't affected by disarm
@@ -2267,6 +2288,11 @@ void AuraEffect::HandleAuraModShapeshift(AuraApplication const* aurApp, uint8 mo
if (target->IsPlayer()) if (target->IsPlayer())
{ {
SpellShapeshiftFormEntry const* shapeInfo = sSpellShapeshiftFormStore.LookupEntry(form); SpellShapeshiftFormEntry const* shapeInfo = sSpellShapeshiftFormStore.LookupEntry(form);
if (!shapeInfo)
{
LOG_ERROR("spells.aura.effect", "Fractured_ApplyShapeshiftFormFromAuraEffect: missing SpellShapeshiftForm {}", uint32(form));
return;
}
// Learn spells for shapeshift form - no need to send action bars or add spells to spellbook // Learn spells for shapeshift form - no need to send action bars or add spells to spellbook
for (uint8 i = 0; i < MAX_SHAPESHIFT_SPELLS; ++i) for (uint8 i = 0; i < MAX_SHAPESHIFT_SPELLS; ++i)
{ {
@@ -2280,6 +2306,11 @@ void AuraEffect::HandleAuraModShapeshift(AuraApplication const* aurApp, uint8 mo
} }
} }
void AuraEffect::HandleAuraModShapeshift(AuraApplication const* aurApp, uint8 mode, bool apply) const
{
Fractured_ApplyShapeshiftFormFromAuraEffect(this, aurApp, mode, apply, ShapeshiftForm(GetMiscValue()));
}
void AuraEffect::HandleAuraTransform(AuraApplication const* aurApp, uint8 mode, bool apply) const void AuraEffect::HandleAuraTransform(AuraApplication const* aurApp, uint8 mode, bool apply) const
{ {
if (!(mode & AURA_EFFECT_HANDLE_SEND_FOR_CLIENT_MASK)) if (!(mode & AURA_EFFECT_HANDLE_SEND_FOR_CLIENT_MASK))
@@ -5160,6 +5191,20 @@ void AuraEffect::HandleAuraDummy(AuraApplication const* aurApp, uint8 mode, bool
Unit* caster = GetCaster(); Unit* caster = GetCaster();
// Fractured: Paragon warrior stance clones (951010-951012) use SPELL_AURA_DUMMY on Spell.dbc **effect2**
// (misc = Paragon SpellShapeshiftForm 33-35). Effect1 is a separate DUMMY for the buff strip; passive stats
// (e.g. armor pen / threat) live on Effect3. **AttributesEx** clears SPELL_ATTR1_NO_AURA_ICON (DBC + SpellInfoCorrections)
// so the client shows an aura icon — stock Warrior stances keep that bit set on purpose.
if (GetAuraType() == SPELL_AURA_DUMMY && m_effIndex == 1)
{
uint32 const sid = GetSpellInfo()->Id;
if (sid == 951010 || sid == 951011 || sid == 951012)
{
Fractured_ApplyShapeshiftFormFromAuraEffect(this, aurApp, mode, apply, ShapeshiftForm(GetMiscValue()));
return;
}
}
if (mode & AURA_EFFECT_HANDLE_REAL) if (mode & AURA_EFFECT_HANDLE_REAL)
{ {
// pet auras // pet auras
+35 -13
View File
@@ -1973,6 +1973,31 @@ bool Aura::CanStackWith(Aura const* existingAura) const
|| (sameCaster && m_spellInfo->IsAuraExclusiveBySpecificPerCasterWith(existingSpellInfo))) || (sameCaster && m_spellInfo->IsAuraExclusiveBySpecificPerCasterWith(existingSpellInfo)))
return false; return false;
// Fractured: cross-class stance / form / presence / aspect exclusivity.
// Stock AC's engine-shapeshift removal in HandleAuraModShapeshift only
// covers warrior stances, druid forms, Shadowform, Metamorphosis, etc.
// -- DK Presences and Hunter Aspects are regular auras (the client just
// happens to render them in the stance bar) and therefore stack with
// engine-shapeshifts in stock AC. The Fractured rule (server-wide --
// see IsFracturedExclusiveStanceSpell in Unit.h for the curated set
// and the design rationale) treats the union of stances + forms (combat
// AND utility) + presences + aspects as mutually exclusive. Refusing
// to stack here triggers the same _RemoveNoStackAurasDueToAura cleanup
// path that Battle Elixirs / Curses already use, so the older aura
// drops and the newly-cast one applies cleanly. Different ranks of the
// same talent (e.g. Hawk rank 4 -> Hawk rank 7) are NOT treated as
// exclusive with each other -- IsFracturedExclusiveStanceSpell resolves
// to first-rank ids, so we compare those.
if (IsFracturedExclusiveStanceSpell(m_spellInfo->Id) && IsFracturedExclusiveStanceSpell(existingSpellInfo->Id))
{
SpellInfo const* newFirst = m_spellInfo->GetFirstRankSpell();
SpellInfo const* oldFirst = existingSpellInfo->GetFirstRankSpell();
uint32 newFirstId = newFirst ? newFirst->Id : m_spellInfo->Id;
uint32 oldFirstId = oldFirst ? oldFirst->Id : existingSpellInfo->Id;
if (newFirstId != oldFirstId)
return false;
}
// check spell group stack rules // check spell group stack rules
switch (sSpellMgr->CheckSpellGroupStackRules(m_spellInfo, existingSpellInfo)) switch (sSpellMgr->CheckSpellGroupStackRules(m_spellInfo, existingSpellInfo))
{ {
@@ -2255,20 +2280,17 @@ uint8 Aura::GetProcEffectMask(AuraApplication* aurApp, ProcEventInfo& eventInfo,
return 0; return 0;
if (!item->IsFitToSpellRequirements(GetSpellInfo())) if (!item->IsFitToSpellRequirements(GetSpellInfo()))
{ {
// Fractured / Paragon: cross-class wildcard relaxes weapon- // Fractured / Paragon: cross-class wildcard relaxes the
// class subclass gates on per-event proc evaluation. This // weapon-subclass gate ONLY for the curated allowlist of
// mirrors Player::CheckAttackFitToAuraRequirement and // cross-class proc talents (currently just Maelstrom Weapon
// Player::HasItemFitToSpellRequirements -- without this // 51528-51532). Weapon-specialization talents (Sword/Mace
// third bypass, the talent attaches (HasItemFit lets it), // Specialization, Hack and Slash, Two-Handed Weapon
// the per-swing match accepts the weapon (CheckAttackFit // Specialization, etc.) deliberately stay weapon-gated for
// lets it), but IsProcTriggeredOnEvent still kills the // Paragon. Restricted to ITEM_CLASS_WEAPON so shield-gated
// proc here for any weapon outside the talent's stock // talents still need an actual shield.
// subclass mask (e.g. Maelstrom Weapon on a Paragon
// wielding a 1H sword or polearm). Restricted to
// ITEM_CLASS_WEAPON so shield-gated talents still need
// an actual shield.
if (!(GetSpellInfo()->EquippedItemClass == ITEM_CLASS_WEAPON if (!(GetSpellInfo()->EquippedItemClass == ITEM_CLASS_WEAPON
&& IsParagonWildcardCaller(target))) && IsParagonWildcardCaller(target)
&& IsParagonWeaponSubclassWildcardSpell(GetSpellInfo()->Id)))
return 0; return 0;
} }
} }
+95 -43
View File
@@ -3589,7 +3589,10 @@ SpellCastResult Spell::prepare(SpellCastTargets const* targets, AuraEffect const
// don't allow channeled spells / spells with cast time to be casted while moving // don't allow channeled spells / spells with cast time to be casted while moving
// (even if they are interrupted on moving, spells with almost immediate effect get to have their effect processed before movement interrupter kicks in) // (even if they are interrupted on moving, spells with almost immediate effect get to have their effect processed before movement interrupter kicks in)
// Fractured: cast-time mount summons (SPELL_AURA_MOUNTED + non-zero base cast) may be started while moving.
if ((m_spellInfo->IsChanneled() || m_casttime) && m_caster->IsPlayer() && m_caster->isMoving() && m_spellInfo->InterruptFlags & SPELL_INTERRUPT_FLAG_MOVEMENT && !IsTriggered()) if ((m_spellInfo->IsChanneled() || m_casttime) && m_caster->IsPlayer() && m_caster->isMoving() && m_spellInfo->InterruptFlags & SPELL_INTERRUPT_FLAG_MOVEMENT && !IsTriggered())
{
if (!m_spellInfo->IsCastTimeRidingMountSpell())
{ {
// 1. Has casttime, 2. Or doesn't have flag to allow action during channel // 1. Has casttime, 2. Or doesn't have flag to allow action during channel
if (m_casttime || !m_spellInfo->IsActionAllowedChannel()) if (m_casttime || !m_spellInfo->IsActionAllowedChannel())
@@ -3599,6 +3602,7 @@ SpellCastResult Spell::prepare(SpellCastTargets const* targets, AuraEffect const
return SPELL_FAILED_MOVING; return SPELL_FAILED_MOVING;
} }
} }
}
// xinef: if spell have nearby target entry only, do not allow to cast if no targets are found // xinef: if spell have nearby target entry only, do not allow to cast if no targets are found
if (m_CastItem) if (m_CastItem)
@@ -4436,9 +4440,11 @@ void Spell::update(uint32 difftime)
// check if the player caster has moved before the spell finished // check if the player caster has moved before the spell finished
// xinef: added preparing state (real cast, skip channels as they have other flags for this) // xinef: added preparing state (real cast, skip channels as they have other flags for this)
// Fractured: cast-time mount summons are allowed to complete while moving.
if ((m_caster->IsPlayer() && m_timer != 0) && if ((m_caster->IsPlayer() && m_timer != 0) &&
m_caster->isMoving() && (m_spellInfo->InterruptFlags & SPELL_INTERRUPT_FLAG_MOVEMENT) && m_spellState == SPELL_STATE_PREPARING && m_caster->isMoving() && (m_spellInfo->InterruptFlags & SPELL_INTERRUPT_FLAG_MOVEMENT) && m_spellState == SPELL_STATE_PREPARING &&
(m_spellInfo->Effects[0].Effect != SPELL_EFFECT_STUCK || !m_caster->HasUnitMovementFlag(MOVEMENTFLAG_FALLING_FAR))) (m_spellInfo->Effects[0].Effect != SPELL_EFFECT_STUCK || !m_caster->HasUnitMovementFlag(MOVEMENTFLAG_FALLING_FAR)) &&
!m_spellInfo->IsCastTimeRidingMountSpell())
{ {
// don't cancel for melee, autorepeat, triggered and instant spells // don't cancel for melee, autorepeat, triggered and instant spells
if (!IsNextMeleeSwingSpell() && !IsAutoRepeat() && !IsTriggered()) if (!IsNextMeleeSwingSpell() && !IsAutoRepeat() && !IsTriggered())
@@ -5815,6 +5821,10 @@ SpellCastResult Spell::CheckCast(bool strict, uint32* /*param1*/, uint32* /*para
if (reqCombat && m_caster->IsInCombat() && !m_spellInfo->CanBeUsedInCombat()) if (reqCombat && m_caster->IsInCombat() && !m_spellInfo->CanBeUsedInCombat())
return SPELL_FAILED_AFFECTING_COMBAT; return SPELL_FAILED_AFFECTING_COMBAT;
// Fractured: cast-time mount summons cannot be used while in combat (even if DBC omits NOT_IN_COMBAT_ONLY_PEACEFUL).
if (reqCombat && m_caster->IsInCombat() && m_spellInfo->IsCastTimeRidingMountSpell())
return SPELL_FAILED_AFFECTING_COMBAT;
} }
// Xinef: exploit protection // Xinef: exploit protection
@@ -5841,12 +5851,16 @@ SpellCastResult Spell::CheckCast(bool strict, uint32* /*param1*/, uint32* /*para
// cancel autorepeat spells if cast start when moving // cancel autorepeat spells if cast start when moving
// (not wand currently autorepeat cast delayed to moving stop anyway in spell update code) // (not wand currently autorepeat cast delayed to moving stop anyway in spell update code)
if (m_caster->IsPlayer() && m_caster->ToPlayer()->isMoving() && !IsTriggered()) if (m_caster->IsPlayer() && m_caster->ToPlayer()->isMoving() && !IsTriggered())
{
// Fractured: cast-time mount summons may be started while moving.
if (!m_spellInfo->IsCastTimeRidingMountSpell())
{ {
// skip stuck spell to allow use it in falling case and apply spell limitations at movement // skip stuck spell to allow use it in falling case and apply spell limitations at movement
if ((!m_caster->HasUnitMovementFlag(MOVEMENTFLAG_FALLING_FAR) || m_spellInfo->Effects[0].Effect != SPELL_EFFECT_STUCK) && if ((!m_caster->HasUnitMovementFlag(MOVEMENTFLAG_FALLING_FAR) || m_spellInfo->Effects[0].Effect != SPELL_EFFECT_STUCK) &&
(IsAutoRepeat() || (m_spellInfo->AuraInterruptFlags & AURA_INTERRUPT_FLAG_NOT_SEATED) != 0)) (IsAutoRepeat() || (m_spellInfo->AuraInterruptFlags & AURA_INTERRUPT_FLAG_NOT_SEATED) != 0))
return SPELL_FAILED_MOVING; return SPELL_FAILED_MOVING;
} }
}
Vehicle* vehicle = m_caster->GetVehicle(); Vehicle* vehicle = m_caster->GetVehicle();
if (vehicle && !HasTriggeredCastFlag(TRIGGERED_IGNORE_CASTER_MOUNTED_OR_ON_VEHICLE)) if (vehicle && !HasTriggeredCastFlag(TRIGGERED_IGNORE_CASTER_MOUNTED_OR_ON_VEHICLE))
@@ -7733,55 +7747,63 @@ SpellCastResult Spell::CheckItems(uint32* param1, uint32* param2)
{ {
case ITEM_SUBCLASS_WEAPON_THROWN: case ITEM_SUBCLASS_WEAPON_THROWN:
{ {
uint32 ammo = pItem->GetEntry(); // Fractured: thrown abilities behave like DK runes -- they
if (!m_caster->ToPlayer()->HasItemCount(ammo)) // remain usable even when the player has run out of the
return SPELL_FAILED_NO_AMMO; // throwing item. Stock AC returned SPELL_FAILED_NO_AMMO
}; // here; we just drop the gate. Spell::TakeAmmo's stack
// decrement is wrapped in a HasItemCount check via
// DestroyItemCount and will silently no-op at zero. The
// ranged-DPS bonus naturally vanishes when the stack runs
// out, so the player still throws but loses the per-shot
// damage contribution from the throwing item.
break; break;
};
case ITEM_SUBCLASS_WEAPON_GUN: case ITEM_SUBCLASS_WEAPON_GUN:
case ITEM_SUBCLASS_WEAPON_BOW: case ITEM_SUBCLASS_WEAPON_BOW:
case ITEM_SUBCLASS_WEAPON_CROSSBOW: case ITEM_SUBCLASS_WEAPON_CROSSBOW:
{ {
uint32 ammo = m_caster->ToPlayer()->GetUInt32Value(PLAYER_AMMO_ID); // Fractured: ranged abilities behave like DK runes -- they
if (!ammo) // remain usable when the player has no ammo loaded or the
{ // quiver / pouch is empty. The DPS-bonus path (StatSystem.cpp:
// Requires No Ammo // `weaponMin/MaxDamage += GetAmmoDPS() * attackSpeedMod`)
if (m_caster->HasAura(46699)) // reads `m_ammoDPS`, which is 0 when no ammo is loaded and
break; // skip other checks // recomputed via Player::_ApplyAmmoBonuses on equip / stack
// exhaustion, so a hunter with an empty bag still casts
return SPELL_FAILED_NO_AMMO; // Steady Shot / Aimed Shot etc. -- they just lose the arrow
} // / bullet DPS contribution.
//
ItemTemplate const* ammoProto = sObjectMgr->GetItemTemplate(ammo); // We deliberately do NOT clear PLAYER_AMMO_ID when the bag
if (!ammoProto) // empties. Defense in depth alongside the data-side fix:
return SPELL_FAILED_NO_AMMO; //
// * The primary client-side fix lives in Spell.dbc --
if (ammoProto->Class != ITEM_CLASS_PROJECTILE) // SpellInfoCorrections.cpp's "drop EquippedItemClass on
return SPELL_FAILED_NO_AMMO; // hunter shot abilities" block (mirrored client-side by
// fractured-tooling/_patch_spell_dbc_hunter_ammo.py)
// check ammo ws. weapon compatibility // sets EquippedItemClass = -1 on every player-castable
switch (pItem->GetTemplate()->SubClass) // hunter shot, which removes the 3.3.5a client's
{ // "ranged weapon AND ammo slot non-empty" preflight
case ITEM_SUBCLASS_WEAPON_BOW: // gate entirely. After that, ammo is purely a
case ITEM_SUBCLASS_WEAPON_CROSSBOW: // server-side DPS bonus, never a hard requirement.
if (ammoProto->SubClass != ITEM_SUBCLASS_ARROW) //
return SPELL_FAILED_NO_AMMO; // * Keeping the (now-stale) ammo id in PLAYER_AMMO_ID
// field is harmless: TakeAmmo's DestroyItemCount
// silently no-ops when HasItemCount is 0, and
// _ApplyAmmoBonuses already recomputes m_ammoDPS to 0
// when the proto can no longer be found / the stack is
// empty. So the StatSystem.cpp ammo-DPS path gracefully
// degrades to "no bonus" the moment the bag goes empty.
//
// * Player un-equipping the ammo via the paper-doll
// right-click still routes through RemoveAmmo() and
// zeroes the field -- that is the player's explicit
// action and we leave it alone.
//
// Net result: hunter has bow + ammo -> full DPS; bow only ->
// shots still fire, no ammo DPS; no bow -> client engine's
// own ranged-weapon gate still blocks (Auto Shot timer
// simply never spins up without a ranged weapon equipped).
break; break;
case ITEM_SUBCLASS_WEAPON_GUN:
if (ammoProto->SubClass != ITEM_SUBCLASS_BULLET)
return SPELL_FAILED_NO_AMMO;
break;
default:
return SPELL_FAILED_NO_AMMO;
}
if (!m_caster->ToPlayer()->HasItemCount(ammo))
{
m_caster->ToPlayer()->SetUInt32Value(PLAYER_AMMO_ID, 0);
return SPELL_FAILED_NO_AMMO;
}
}; };
break;
case ITEM_SUBCLASS_WEAPON_WAND: case ITEM_SUBCLASS_WEAPON_WAND:
break; break;
default: default:
@@ -7852,6 +7874,36 @@ SpellCastResult Spell::CheckSpellFocus()
// check spell focus object // check spell focus object
if (m_spellInfo->RequiresSpellFocus) if (m_spellInfo->RequiresSpellFocus)
{ {
// Fractured / Paragon: skip the GO proximity check for Paragon
// casters when the spell is a runeforge enchant (skill line 776).
// Paragons get a Runeforge tab in the Character Advancement
// panel that lets them apply rune-enchants from anywhere in the
// world -- no need to fly back to Acherus or find the nearest
// Eastern Plaguelands anvil. The skill-line gate keeps the
// bypass tightly scoped: only the 10 SkillLineAbility-tagged
// rune-enchant spells qualify, every other spell that uses
// SpellFocusObject (Enchanting bench, Cooking fire, Lockpicking
// anvil, etc.) keeps its requirement intact.
//
// DK / other class casters are unchanged -- this carve-out
// intentionally checks getClass() == CLASS_PARAGON rather than
// IsClass(CLASS_DEATH_KNIGHT, CLASS_CONTEXT_ABILITY) (which
// would also return true for Paragons via mod-paragon's hook).
// Stock DK behavior must stay vanilla; the QoL bypass is a
// class-12 feature only.
if (m_caster && m_caster->IsPlayer()
&& m_caster->ToPlayer()->getClass() == CLASS_PARAGON)
{
auto bounds = sSpellMgr->GetSkillLineAbilityMapBounds(m_spellInfo->Id);
for (auto it = bounds.first; it != bounds.second; ++it)
{
if (it->second->SkillLine == SKILL_RUNEFORGING)
{
return SPELL_CAST_OK;
}
}
}
CellCoord p(Acore::ComputeCellCoord(m_caster->GetPositionX(), m_caster->GetPositionY())); CellCoord p(Acore::ComputeCellCoord(m_caster->GetPositionX(), m_caster->GetPositionY()));
Cell cell(p); Cell cell(p);
+18 -4
View File
@@ -909,6 +909,15 @@ bool SpellInfo::HasAura(AuraType aura) const
return false; return false;
} }
bool SpellInfo::IsCastTimeRidingMountSpell() const
{
if (IsChanneled())
return false;
if (!HasAura(SPELL_AURA_MOUNTED))
return false;
return CalcCastTime(nullptr, nullptr) > 0;
}
bool SpellInfo::HasAnyAura() const bool SpellInfo::HasAnyAura() const
{ {
for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i) for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i)
@@ -1395,15 +1404,16 @@ bool SpellInfo::IsAffectedBySpellMod(SpellModifier const* mod, Unit const* liste
{ {
case 53817: // Shaman: Maelstrom Weapon case 53817: // Shaman: Maelstrom Weapon
{ {
// Allow any rank of Mage Fireball / Frostbolt / Arcane Blast to // Allow any rank of Mage Fireball / Frostbolt to benefit from
// benefit from the cast-time + cost reduction spellmod. // the cast-time + cost reduction spellmod. Arcane Blast was on
// the allowlist briefly but proved too potent stacked with its
// own self-buff -- removed.
if (SpellFamilyName == SPELLFAMILY_MAGE) if (SpellFamilyName == SPELLFAMILY_MAGE)
{ {
SpellInfo const* first = GetFirstRankSpell(); SpellInfo const* first = GetFirstRankSpell();
uint32 firstId = first ? first->Id : Id; uint32 firstId = first ? first->Id : Id;
if (firstId == 133 /*Fireball*/ if (firstId == 133 /*Fireball*/
|| firstId == 116 /*Frostbolt*/ || firstId == 116 /*Frostbolt*/)
|| firstId == 30451 /*Arcane Blast*/)
return true; return true;
} }
break; break;
@@ -2146,6 +2156,10 @@ SpellSpecificType SpellInfo::LoadSpellSpecific() const
{ {
case SPELLFAMILY_GENERIC: case SPELLFAMILY_GENERIC:
{ {
// Fractured / Paragon: DK presence advancement clones (SpellFamilyName=0 in Spell.dbc for client UX).
if (Id == 951013 || Id == 951014 || Id == 951015)
return SPELL_SPECIFIC_PRESENCE;
// Food / Drinks (mostly) // Food / Drinks (mostly)
if (AuraInterruptFlags & AURA_INTERRUPT_FLAG_NOT_SEATED) if (AuraInterruptFlags & AURA_INTERRUPT_FLAG_NOT_SEATED)
{ {
+3
View File
@@ -434,6 +434,9 @@ public:
bool HasEffect(SpellEffects effect) const; bool HasEffect(SpellEffects effect) const;
bool HasEffectMechanic(Mechanics mechanic) const; bool HasEffectMechanic(Mechanics mechanic) const;
bool HasAura(AuraType aura) const; bool HasAura(AuraType aura) const;
/// Summon mount aura (SPELL_AURA_MOUNTED) with a non-zero base cast time from SpellCastTimes.dbc.
/// Used by Fractured mount rules: castable while moving, never in combat, interrupted on combat enter.
bool IsCastTimeRidingMountSpell() const;
bool HasAnyAura() const; bool HasAnyAura() const;
bool HasAreaAuraEffect() const; bool HasAreaAuraEffect() const;
bool HasOnlyDamageEffects() const; bool HasOnlyDamageEffects() const;
@@ -18,6 +18,7 @@
#include "DBCStores.h" #include "DBCStores.h"
#include "DBCStructure.h" #include "DBCStructure.h"
#include "GameGraveyard.h" #include "GameGraveyard.h"
#include "ItemTemplate.h"
#include "SpellInfo.h" #include "SpellInfo.h"
#include "SpellMgr.h" #include "SpellMgr.h"
@@ -5380,6 +5381,139 @@ void SpellMgr::LoadSpellInfoCorrections()
spellInfo->Attributes |= SPELL_ATTR0_PASSIVE; spellInfo->Attributes |= SPELL_ATTR0_PASSIVE;
}); });
// Fractured: move Death Knight Presences and Hunter Aspects out of
// SpellCategory 47 ("Combat States") so they cancel/toggle the same
// way Druid shapeshift forms do.
//
// Category 47 is the "stance bar" category. The 3.3.5a client UI
// explicitly disables right-click-cancel and `/cancelaura <name>` for
// any aura whose Spell.dbc Category column points at a SpellCategory
// entry that is "Combat States" (47). Druid forms (Bear Form, Cat
// Form, Travel Form, Moonkin, Tree of Life, etc.) sit in Category 0
// and are therefore freely cancellable -- right-click drops the form,
// /cancelaura drops it, recasting from the action bar drops it.
// Warrior stances, DK Presences and Hunter Aspects all live in
// Category 47, which is why none of them are cancellable in stock.
//
// For the cross-class stance / form / presence / aspect exclusivity
// rule (see IsFracturedExclusiveStanceSpell in Unit.cpp), a Paragon
// hybrid often wants to drop their active presence/aspect so they can
// apply a different stance/form *without* first switching to a
// different presence/aspect. Setting Category to 0 here mirrors what
// Druid forms already do, gives the cancel/toggle UX the user
// explicitly asked for, and -- importantly -- does NOT change the
// action bar (presences and aspects are not engine-shapeshifts, the
// bar swap behavior is owned by SPELL_AURA_MOD_SHAPESHIFT, not by
// SpellCategory). The matching client-side Spell.dbc edit ships in
// patch-enUS-4.MPQ via _patch_spell_dbc_presences_cancelable.py.
//
// Warrior stances are also included per design decision 2026-05-11
// ("you could make Warrior Stances toggleable as well, it should be
// okay"). The previously-shipped Stances=0 client patch already lets
// Paragon non-warriors cast every warrior ability without picking up
// a stance, so a stock warrior who right-clicks their stance just
// ends up at "no stance" -- which on this server still leaves all
// their warrior abilities available. Stock warriors who like the
// never-cancel UX can simply not right-click; nothing forces them.
//
// Tradeoff: stances / presences / aspects lose the 1s SpellCategory
// GCD that Category 47 enforces between same-category spells. This
// matches the Druid-form UX (Bear -> Cat -> Bear has no shared GCD),
// and the cross-class exclusivity rule in Aura::CanStackWith already
// prevents stacking, so the only thing actually possible at "0s GCD"
// is rapid-toggling the same stance on and off, which is harmless.
ApplySpellFix({
// Warrior Stances.
2457, // Battle Stance
71, // Defensive Stance
2458, // Berserker Stance
951010, 951011, 951012, // Paragon advancement warrior stance clones
// Death Knight Presences.
48266, // Blood Presence
48263, // Frost Presence
48265, // Unholy Presence
951013, 951014, 951015, // Paragon advancement DK presence clones (SpellFamily GENERIC in Spell.dbc)
// Hunter Aspects -- every rank, since AC stores the per-rank
// SpellInfo as separate objects and `Category` lives on each.
// Rank-1 ids are the same ones listed in
// IsFracturedExclusiveStanceSpell; trailing ids are higher ranks.
13165, 14318, 14319, 14320, 14321, 14322, 25296, 27044, // Aspect of the Hawk r1..r8
5118, // Aspect of the Cheetah
13159, // Aspect of the Pack (only one rank in WotLK; 27047 is "Growl", do NOT add)
20043, 20190, 27045, // Aspect of the Wild r1..r3
13161, // Aspect of the Beast
13163, // Aspect of the Monkey
34074, // Aspect of the Viper
61846, 61847, // Aspect of the Dragonhawk r1..r2
}, [](SpellInfo* spellInfo)
{
spellInfo->CategoryEntry = nullptr;
});
// Fractured: clear AttributesEx6 bit 0x1000 on Warrior Stances and DK
// Presences so the 3.3.5 client UI lets right-click and `/cancelaura`
// drop them, the same way Druid forms / Hunter Aspects already cancel.
//
// Empirical finding (see fractured-tooling/inspect_stance_attr6.py for
// the diff script): when only `SpellCategory` is cleared (the Combat-
// States gate at column 1), Hunter Aspects become cancellable but
// Warrior Stances and DK Presences still aren't. Diffing the Spell.dbc
// rows of working vs broken stance-bar buffs across patched-Aspects and
// unpatched-Stances/Presences identifies a SECOND gating column:
// `AttributesEx6` (col 10) bit `0x1000`. It is set on every Warrior
// Stance (Battle/Defensive/Berserker) and every DK Presence
// (Blood/Frost/Unholy) but NOT on any Hunter Aspect (and not on Druid
// forms / Ghost Wolf / Stealth / Shadowform). Clearing the bit removes
// the secondary client-UI gate without changing how the action bar /
// shapeshift system works (those are owned by SPELL_AURA_MOD_SHAPESHIFT,
// not by attribute bits).
//
// AC names this bit `SPELL_ATTR6_ALLOW_WHILE_RIDING_VEHICLE`. That name
// is from a different role of the same bit -- when set on a regular
// ability, AC's `Spell::CheckCast` vehicle-passenger gate uses it to
// grant "this spell is castable from a vehicle seat". Stripping it from
// Warrior Stances / DK Presences is harmless because those aren't cast
// from vehicle seats anyway (the player is `IsCharmed()` in a seat and
// the stance / presence wouldn't apply meaningfully). The matching
// client-side Spell.dbc edit ships in patch-enUS-4.MPQ via
// _patch_spell_dbc_presences_cancelable.py.
//
// Hunter Aspects intentionally NOT included -- their AttributesEx6 is
// already 0 (or 0x04000000 for Pack/Wild, which is a different bit
// unrelated to cancel gating), and listing them here would be a no-op.
ApplySpellFix({
2457, // Battle Stance
71, // Defensive Stance
2458, // Berserker Stance
951010, 951011, 951012, // Paragon advancement warrior stance clones
48266, // Blood Presence
48263, // Frost Presence
48265, // Unholy Presence
951013, 951014, 951015, // Paragon advancement DK presence clones (SpellFamily GENERIC in Spell.dbc)
}, [](SpellInfo* spellInfo)
{
spellInfo->AttributesEx6 &= ~SPELL_ATTR6_ALLOW_WHILE_RIDING_VEHICLE;
});
// Fractured / Paragon: advancement warrior stance clones — strip SPELL_ATTR1_NO_AURA_ICON
// (copied from stock 2457/71/2458). Stock Warrior stances intentionally hide from the default aura bar;
// these clones are meant to show a cancellable buff icon instead. Client Spell.dbc is patched in tandem via
// fractured-tooling/_patch_spell_dbc_paragon_stance_presence_clones.py.
ApplySpellFix({ 951010, 951011, 951012 }, [](SpellInfo* spellInfo)
{
spellInfo->AttributesEx &= ~SPELL_ATTR1_NO_AURA_ICON;
});
// Fractured / Paragon: advancement DK presence clones — strip SPELL_ATTR2_USE_SHAPESHIFT_BAR (0x10) copied
// from 48266/48263/48265. That client-only bit is what parks a spell on the secondary stance bar above the
// action bar; SkillLine / SpellFamily alone do not remove it. Spellbook tabs still come from SkillLines 770/771/772.
ApplySpellFix({ 951013, 951014, 951015 }, [](SpellInfo* spellInfo)
{
spellInfo->AttributesEx2 &= ~SPELL_ATTR2_USE_SHAPESHIFT_BAR;
});
// Fractured: strip reagent requirements from every player-class spell at // Fractured: strip reagent requirements from every player-class spell at
// load time. Filtered by SpellFamilyName != 0 so that profession spells // load time. Filtered by SpellFamilyName != 0 so that profession spells
// (cooking, alchemy, enchanting, blacksmithing, jewelcrafting, leatherworking, // (cooking, alchemy, enchanting, blacksmithing, jewelcrafting, leatherworking,
@@ -5418,6 +5552,76 @@ void SpellMgr::LoadSpellInfoCorrections()
LOG_INFO("server.loading", ">> Fractured: cleared reagents on {} class spells", fixedClassSpells); LOG_INFO("server.loading", ">> Fractured: cleared reagents on {} class spells", fixedClassSpells);
} }
// Fractured: drop EquippedItemClass on hunter shot abilities at load time
// so the server agrees with the matching client-side Spell.dbc patch
// (fractured-tooling/_patch_spell_dbc_hunter_ammo.py). Both surfaces have
// to agree -- if only the client patch shipped, the server's stock
// EquippedItemClass check would still reject mid-cast; if only the server
// mirror shipped, the 3.3.5a client preflight would still block the cast
// packet from leaving the box with "Ammo needs to be in the paper doll
// ammo slot before it can be fired." The Spell::CheckCast soft-fail
// (Spell.cpp 7741..) and the never-clear-PLAYER_AMMO_ID change there are
// still in place as defense in depth so a half-deployed client / server
// pair degrades to the soft-fail behavior rather than to hard rejects.
//
// Filter mirrors the Python patcher byte-for-byte:
// SpellFamilyName == SPELLFAMILY_HUNTER (9)
// AND EquippedItemClass == ITEM_CLASS_WEAPON (2)
// AND EquippedItemSubClassMask & ((1<<BOW)|(1<<GUN)|(1<<XBOW)) != 0
// with a small DENYLIST of item-equip-driven passive auras (Quiver /
// Ammo Pouch haste ranks, Legendary Bow Haste, Aynasha's Bow proc) whose
// entire purpose is "have a ranged weapon equipped" -- those keep their
// stock EquippedItemClass = 2.
//
// Effect: after this fix, hunter shots leave the client preflight without
// hitting the ammo-slot gate AND pass the server's EquippedItemClass
// check unconditionally. _ApplyAmmoBonuses still gates the arrow / bullet
// DPS bonus on actually having a stack in the quiver, so equipping ammo
// continues to give the DPS bump and an empty quiver no longer bricks
// abilities -- "you still get the DPS increase from arrows but aren't
// completely neutered if you run out", per the resident hunter expert.
{
constexpr uint32 RANGED_SUBCLASS_MASK =
(1u << ITEM_SUBCLASS_WEAPON_BOW)
| (1u << ITEM_SUBCLASS_WEAPON_GUN)
| (1u << ITEM_SUBCLASS_WEAPON_CROSSBOW);
// Keep in sync with DENYLIST in
// fractured-tooling/_patch_spell_dbc_hunter_ammo.py.
static const std::unordered_set<uint32> hunterAmmoDenylist = {
// Quiver / Ammo Pouch ranged-attack-speed haste passives (gun).
14824, 14825, 14826, 14827, 14828, 14829,
// Quiver passive haste (bow + crossbow).
29413, 29414, 29415, 29416, 29417, 29418,
// Late-rank quiver haste, gun-only.
44333,
// Legendary Bow Haste (item proc on a specific bow).
44972,
// Aynasha's Bow item proc.
19767,
};
uint32 fixedShots = 0;
for (uint32 spellId = 1; spellId < sSpellMgr->GetSpellInfoStoreSize(); ++spellId)
{
SpellInfo const* info = sSpellMgr->GetSpellInfo(spellId);
if (!info || info->SpellFamilyName != SPELLFAMILY_HUNTER)
continue;
if (info->EquippedItemClass != ITEM_CLASS_WEAPON)
continue;
if (info->EquippedItemSubClassMask <= 0
|| (uint32(info->EquippedItemSubClassMask) & RANGED_SUBCLASS_MASK) == 0)
continue;
if (hunterAmmoDenylist.find(spellId) != hunterAmmoDenylist.end())
continue;
SpellInfo* mut = const_cast<SpellInfo*>(info);
mut->EquippedItemClass = -1;
++fixedShots;
}
LOG_INFO("server.loading", ">> Fractured: dropped EquippedItemClass on {} hunter shot abilities", fixedShots);
}
LOG_INFO("server.loading", ">> Loading spell dbc data corrections in {} ms", GetMSTimeDiffToNow(oldMSTime)); LOG_INFO("server.loading", ">> Loading spell dbc data corrections in {} ms", GetMSTimeDiffToNow(oldMSTime));
LOG_INFO("server.loading", " "); LOG_INFO("server.loading", " ");
} }
+3 -2
View File
@@ -163,7 +163,7 @@ void WorldConfig::BuildConfigCache()
SetConfigValue<bool>(CONFIG_ALLOW_PLAYER_COMMANDS, "AllowPlayerCommands", 1); SetConfigValue<bool>(CONFIG_ALLOW_PLAYER_COMMANDS, "AllowPlayerCommands", 1);
SetConfigValue<bool>(CONFIG_PRESERVE_CUSTOM_CHANNELS, "PreserveCustomChannels", false); SetConfigValue<bool>(CONFIG_PRESERVE_CUSTOM_CHANNELS, "PreserveCustomChannels", false);
SetConfigValue<uint32>(CONFIG_PRESERVE_CUSTOM_CHANNEL_DURATION, "PreserveCustomChannelDuration", 14); SetConfigValue<uint32>(CONFIG_PRESERVE_CUSTOM_CHANNEL_DURATION, "PreserveCustomChannelDuration", 14);
SetConfigValue<uint32>(CONFIG_INTERVAL_SAVE, "PlayerSaveInterval", 900000); SetConfigValue<uint32>(CONFIG_INTERVAL_SAVE, "PlayerSaveInterval", 300000);
SetConfigValue<uint32>(CONFIG_INTERVAL_DISCONNECT_TOLERANCE, "DisconnectToleranceInterval", 0); SetConfigValue<uint32>(CONFIG_INTERVAL_DISCONNECT_TOLERANCE, "DisconnectToleranceInterval", 0);
SetConfigValue<bool>(CONFIG_STATS_SAVE_ONLY_ON_LOGOUT, "PlayerSave.Stats.SaveOnlyOnLogout", true); SetConfigValue<bool>(CONFIG_STATS_SAVE_ONLY_ON_LOGOUT, "PlayerSave.Stats.SaveOnlyOnLogout", true);
SetConfigValue<bool>(CONFIG_VALIDATE_SKILL_LEARNED_BY_SPELLS, "ValidateSkillLearnedBySpells", true); SetConfigValue<bool>(CONFIG_VALIDATE_SKILL_LEARNED_BY_SPELLS, "ValidateSkillLearnedBySpells", true);
@@ -270,7 +270,8 @@ void WorldConfig::BuildConfigCache()
SetConfigValue<uint32>(CONFIG_INSTANCE_RESET_TIME_RELATIVE_TIMESTAMP, "Instance.ResetTimeRelativeTimestamp", 1135814400); SetConfigValue<uint32>(CONFIG_INSTANCE_RESET_TIME_RELATIVE_TIMESTAMP, "Instance.ResetTimeRelativeTimestamp", 1135814400);
SetConfigValue<uint32>(CONFIG_INSTANCE_UNLOAD_DELAY, "Instance.UnloadDelay", 1800000); SetConfigValue<uint32>(CONFIG_INSTANCE_UNLOAD_DELAY, "Instance.UnloadDelay", 1800000);
SetConfigValue<uint32>(CONFIG_MAX_PRIMARY_TRADE_SKILL, "MaxPrimaryTradeSkill", 2); // WotLK has 11 primary profession skill lines (gathering + crafting); secondary (Cooking, Fishing, First Aid) are not limited here.
SetConfigValue<uint32>(CONFIG_MAX_PRIMARY_TRADE_SKILL, "MaxPrimaryTradeSkill", 11);
SetConfigValue<uint32>(CONFIG_MIN_PETITION_SIGNS, "MinPetitionSigns", 9, ConfigValueCache::Reloadable::Yes, [](uint32 const& value) { return value <= 9; }, "<= 9"); SetConfigValue<uint32>(CONFIG_MIN_PETITION_SIGNS, "MinPetitionSigns", 9, ConfigValueCache::Reloadable::Yes, [](uint32 const& value) { return value <= 9; }, "<= 9");
SetConfigValue<uint32>(CONFIG_GM_LOGIN_STATE, "GM.LoginState", 2); SetConfigValue<uint32>(CONFIG_GM_LOGIN_STATE, "GM.LoginState", 2);
+62
View File
@@ -16,6 +16,7 @@
*/ */
#include "CommandScript.h" #include "CommandScript.h"
#include "DBCStores.h"
#include "Language.h" #include "Language.h"
#include "ObjectMgr.h" #include "ObjectMgr.h"
#include "Pet.h" #include "Pet.h"
@@ -51,6 +52,7 @@ public:
{ "default", HandleLearnAllDefaultCommand, rbac::RBAC_PERM_COMMAND_LEARN_ALL_DEFAULT, Console::No }, { "default", HandleLearnAllDefaultCommand, rbac::RBAC_PERM_COMMAND_LEARN_ALL_DEFAULT, Console::No },
{ "lang", HandleLearnAllLangCommand, rbac::RBAC_PERM_COMMAND_LEARN_ALL_LANG, Console::No }, { "lang", HandleLearnAllLangCommand, rbac::RBAC_PERM_COMMAND_LEARN_ALL_LANG, Console::No },
{ "recipes", HandleLearnAllRecipesCommand, rbac::RBAC_PERM_COMMAND_LEARN_ALL_RECIPES, Console::No }, { "recipes", HandleLearnAllRecipesCommand, rbac::RBAC_PERM_COMMAND_LEARN_ALL_RECIPES, Console::No },
{ "mounts", HandleLearnAllMountsCommand, rbac::RBAC_PERM_COMMAND_LEARN_ALL_MOUNTS, Console::No },
}; };
static ChatCommandTable learnCommandTable = static ChatCommandTable learnCommandTable =
@@ -386,6 +388,66 @@ public:
return true; return true;
} }
static void SetRidingSkillToMaxForPlayer(Player* player)
{
SkillRaceClassInfoEntry const* rcInfo = GetSkillRaceClassInfo(SKILL_RIDING, player->getRace(), player->getClass());
if (!rcInfo || GetSkillRangeType(rcInfo) != SKILL_RANGE_RANK)
return;
SkillTiersEntry const* tier = sSkillTiersStore.LookupEntry(rcInfo->SkillTierID);
if (!tier)
return;
uint8 rank = 0;
uint16 maxValue = 0;
for (uint8 i = 0; i < MAX_SKILL_STEP; ++i)
{
if (tier->Value[i] == 0)
continue;
rank = i + 1;
maxValue = tier->Value[i];
}
if (!rank || !maxValue)
return;
player->SetSkill(SKILL_RIDING, rank, maxValue, maxValue);
}
static bool HandleLearnAllMountsCommand(ChatHandler* handler)
{
Player* target = handler->getSelectedPlayer();
if (!target)
{
handler->SendSysMessage(LANG_PLAYER_NOT_FOUND);
return false;
}
SetRidingSkillToMaxForPlayer(target);
handler->PSendSysMessage("Set Riding skill to maximum for {}.", handler->GetNameLink(target));
uint32 learned = 0;
for (uint32 i = 0; i < sSpellMgr->GetSpellInfoStoreSize(); ++i)
{
SpellInfo const* spellInfo = sSpellMgr->GetSpellInfo(i);
if (!spellInfo || !SpellMgr::IsSpellValid(spellInfo))
continue;
if (!spellInfo->HasAura(SPELL_AURA_MOUNTED))
continue;
if (target->HasSpell(i))
continue;
target->learnSpell(i, false);
if (target->HasSpell(i))
++learned;
}
handler->PSendSysMessage("Learned {} mount spell(s) for {}.", learned, handler->GetNameLink(target));
return true;
}
static void HandleLearnSkillRecipesHelper(Player* player, uint32 skillId) static void HandleLearnSkillRecipesHelper(Player* player, uint32 skillId)
{ {
uint32 classmask = player->getClassMask(); uint32 classmask = player->getClassMask();
+97 -11
View File
@@ -66,6 +66,9 @@ enum DeathKnightSpells
SPELL_DK_ITEM_T8_MELEE_4P_BONUS = 64736, SPELL_DK_ITEM_T8_MELEE_4P_BONUS = 64736,
SPELL_DK_MASTER_OF_GHOULS = 52143, SPELL_DK_MASTER_OF_GHOULS = 52143,
SPELL_DK_BLOOD_PLAGUE = 55078, SPELL_DK_BLOOD_PLAGUE = 55078,
// Fractured / Paragon: stock Priest Devouring Plague vs Character Advancement multidot clone
SPELL_PRIEST_DEVOURING_PLAGUE_R1 = 2944,
SPELL_PARAGON_MULTIDOT_DEVOURING_PLAGUE_R1 = 951000,
SPELL_DK_RAISE_DEAD_USE_REAGENT = 48289, SPELL_DK_RAISE_DEAD_USE_REAGENT = 48289,
SPELL_DK_RUNIC_POWER_ENERGIZE = 49088, SPELL_DK_RUNIC_POWER_ENERGIZE = 49088,
SPELL_DK_SCENT_OF_BLOOD = 50422, SPELL_DK_SCENT_OF_BLOOD = 50422,
@@ -107,6 +110,10 @@ enum DeathKnightSpells
SPELL_DK_RUNE_STRIKE_OFF_HAND_R1 = 66217, SPELL_DK_RUNE_STRIKE_OFF_HAND_R1 = 66217,
SPELL_DK_BLOOD_STRIKE_OFF_HAND_R1 = 66215, SPELL_DK_BLOOD_STRIKE_OFF_HAND_R1 = 66215,
SPELL_DK_KILLING_MACHINE = 51124, SPELL_DK_KILLING_MACHINE = 51124,
// Fractured / Paragon: Character Advancement DK presence clones (SpellFamily GENERIC in Spell.dbc).
SPELL_PARAGON_ADV_BLOOD_PRESENCE = 951013,
SPELL_PARAGON_ADV_FROST_PRESENCE = 951014,
SPELL_PARAGON_ADV_UNHOLY_PRESENCE = 951015,
}; };
enum DeathKnightSpellIcons enum DeathKnightSpellIcons
@@ -126,6 +133,21 @@ enum Misc
NPC_RISEN_ALLY = 30230 NPC_RISEN_ALLY = 30230
}; };
inline bool Fractured_UnitHasBloodPresenceAura(Unit const* unit)
{
return unit->HasAura(SPELL_DK_BLOOD_PRESENCE) || unit->HasAura(SPELL_PARAGON_ADV_BLOOD_PRESENCE);
}
inline bool Fractured_UnitHasFrostPresenceAura(Unit const* unit)
{
return unit->HasAura(SPELL_DK_FROST_PRESENCE) || unit->HasAura(SPELL_PARAGON_ADV_FROST_PRESENCE);
}
inline bool Fractured_UnitHasUnholyPresenceAura(Unit const* unit)
{
return unit->HasAura(SPELL_DK_UNHOLY_PRESENCE) || unit->HasAura(SPELL_PARAGON_ADV_UNHOLY_PRESENCE);
}
// 50526 - Wandering Plague // 50526 - Wandering Plague
class spell_dk_wandering_plague : public SpellScript class spell_dk_wandering_plague : public SpellScript
{ {
@@ -664,6 +686,23 @@ class spell_dk_dancing_rune_weapon : public AuraScript
return false; return false;
SpellInfo const* spellInfo = eventInfo.GetSpellInfo(); SpellInfo const* spellInfo = eventInfo.GetSpellInfo();
// Fractured / Paragon: Paragon owners get a "ghostly weapon copies
// your swings" identity instead of the stock magical-doppelganger
// (which also re-cast Death Coil / Icy Touch / Howling Blast /
// etc.). For Paragon callers only, accept auto-attacks and
// melee-class abilities (Hamstring, Sinister Strike, Heart Strike,
// Frost Strike, Mortal Strike, ...) and reject magic / ranged
// spells. Stock DK gating below is left untouched.
if (IsParagonWildcardCaller(eventInfo.GetActor()))
{
if (!eventInfo.GetDamageInfo())
return false;
if (spellInfo && spellInfo->DmgClass != SPELL_DAMAGE_CLASS_MELEE)
return false;
return true;
}
if (!spellInfo) if (!spellInfo)
return true; return true;
@@ -1780,8 +1819,11 @@ class spell_dk_improved_blood_presence : public AuraScript
return ValidateSpellInfo( return ValidateSpellInfo(
{ {
SPELL_DK_BLOOD_PRESENCE, SPELL_DK_BLOOD_PRESENCE,
SPELL_PARAGON_ADV_BLOOD_PRESENCE,
SPELL_DK_FROST_PRESENCE, SPELL_DK_FROST_PRESENCE,
SPELL_PARAGON_ADV_FROST_PRESENCE,
SPELL_DK_UNHOLY_PRESENCE, SPELL_DK_UNHOLY_PRESENCE,
SPELL_PARAGON_ADV_UNHOLY_PRESENCE,
SPELL_DK_IMPROVED_BLOOD_PRESENCE_TRIGGERED SPELL_DK_IMPROVED_BLOOD_PRESENCE_TRIGGERED
}); });
} }
@@ -1789,14 +1831,14 @@ class spell_dk_improved_blood_presence : public AuraScript
void HandleEffectApply(AuraEffect const* aurEff, AuraEffectHandleModes /*mode*/) void HandleEffectApply(AuraEffect const* aurEff, AuraEffectHandleModes /*mode*/)
{ {
Unit* target = GetTarget(); Unit* target = GetTarget();
if (target->HasAnyAuras(SPELL_DK_FROST_PRESENCE, SPELL_DK_UNHOLY_PRESENCE) && !target->HasAura(SPELL_DK_IMPROVED_BLOOD_PRESENCE_TRIGGERED)) if (target->HasAnyAuras(SPELL_DK_FROST_PRESENCE, SPELL_DK_UNHOLY_PRESENCE, SPELL_PARAGON_ADV_FROST_PRESENCE, SPELL_PARAGON_ADV_UNHOLY_PRESENCE) && !target->HasAura(SPELL_DK_IMPROVED_BLOOD_PRESENCE_TRIGGERED))
target->CastCustomSpell(SPELL_DK_IMPROVED_BLOOD_PRESENCE_TRIGGERED, SPELLVALUE_BASE_POINT1, aurEff->GetAmount(), target, true, nullptr, aurEff); target->CastCustomSpell(SPELL_DK_IMPROVED_BLOOD_PRESENCE_TRIGGERED, SPELLVALUE_BASE_POINT1, aurEff->GetAmount(), target, true, nullptr, aurEff);
} }
void HandleEffectRemove(AuraEffect const* /*aurEff*/, AuraEffectHandleModes /*mode*/) void HandleEffectRemove(AuraEffect const* /*aurEff*/, AuraEffectHandleModes /*mode*/)
{ {
Unit* target = GetTarget(); Unit* target = GetTarget();
if (!target->HasAura(SPELL_DK_BLOOD_PRESENCE)) if (!Fractured_UnitHasBloodPresenceAura(target))
target->RemoveAura(SPELL_DK_IMPROVED_BLOOD_PRESENCE_TRIGGERED); target->RemoveAura(SPELL_DK_IMPROVED_BLOOD_PRESENCE_TRIGGERED);
} }
@@ -1817,8 +1859,11 @@ class spell_dk_improved_frost_presence : public AuraScript
return ValidateSpellInfo( return ValidateSpellInfo(
{ {
SPELL_DK_BLOOD_PRESENCE, SPELL_DK_BLOOD_PRESENCE,
SPELL_PARAGON_ADV_BLOOD_PRESENCE,
SPELL_DK_FROST_PRESENCE, SPELL_DK_FROST_PRESENCE,
SPELL_PARAGON_ADV_FROST_PRESENCE,
SPELL_DK_UNHOLY_PRESENCE, SPELL_DK_UNHOLY_PRESENCE,
SPELL_PARAGON_ADV_UNHOLY_PRESENCE,
SPELL_DK_FROST_PRESENCE_TRIGGERED SPELL_DK_FROST_PRESENCE_TRIGGERED
}); });
} }
@@ -1826,14 +1871,14 @@ class spell_dk_improved_frost_presence : public AuraScript
void HandleEffectApply(AuraEffect const* aurEff, AuraEffectHandleModes /*mode*/) void HandleEffectApply(AuraEffect const* aurEff, AuraEffectHandleModes /*mode*/)
{ {
Unit* target = GetTarget(); Unit* target = GetTarget();
if (target->HasAnyAuras(SPELL_DK_BLOOD_PRESENCE, SPELL_DK_UNHOLY_PRESENCE) && !target->HasAura(SPELL_DK_FROST_PRESENCE_TRIGGERED)) if (target->HasAnyAuras(SPELL_DK_BLOOD_PRESENCE, SPELL_DK_UNHOLY_PRESENCE, SPELL_PARAGON_ADV_BLOOD_PRESENCE, SPELL_PARAGON_ADV_UNHOLY_PRESENCE) && !target->HasAura(SPELL_DK_FROST_PRESENCE_TRIGGERED))
target->CastCustomSpell(SPELL_DK_FROST_PRESENCE_TRIGGERED, SPELLVALUE_BASE_POINT0, aurEff->GetAmount(), target, true, nullptr, aurEff); target->CastCustomSpell(SPELL_DK_FROST_PRESENCE_TRIGGERED, SPELLVALUE_BASE_POINT0, aurEff->GetAmount(), target, true, nullptr, aurEff);
} }
void HandleEffectRemove(AuraEffect const* /*aurEff*/, AuraEffectHandleModes /*mode*/) void HandleEffectRemove(AuraEffect const* /*aurEff*/, AuraEffectHandleModes /*mode*/)
{ {
Unit* target = GetTarget(); Unit* target = GetTarget();
if (!target->HasAura(SPELL_DK_FROST_PRESENCE)) if (!Fractured_UnitHasFrostPresenceAura(target))
target->RemoveAura(SPELL_DK_FROST_PRESENCE_TRIGGERED); target->RemoveAura(SPELL_DK_FROST_PRESENCE_TRIGGERED);
} }
@@ -1854,8 +1899,11 @@ class spell_dk_improved_unholy_presence : public AuraScript
return ValidateSpellInfo( return ValidateSpellInfo(
{ {
SPELL_DK_BLOOD_PRESENCE, SPELL_DK_BLOOD_PRESENCE,
SPELL_PARAGON_ADV_BLOOD_PRESENCE,
SPELL_DK_FROST_PRESENCE, SPELL_DK_FROST_PRESENCE,
SPELL_PARAGON_ADV_FROST_PRESENCE,
SPELL_DK_UNHOLY_PRESENCE, SPELL_DK_UNHOLY_PRESENCE,
SPELL_PARAGON_ADV_UNHOLY_PRESENCE,
SPELL_DK_IMPROVED_UNHOLY_PRESENCE_TRIGGERED, SPELL_DK_IMPROVED_UNHOLY_PRESENCE_TRIGGERED,
SPELL_DK_UNHOLY_PRESENCE_TRIGGERED SPELL_DK_UNHOLY_PRESENCE_TRIGGERED
}); });
@@ -1864,14 +1912,14 @@ class spell_dk_improved_unholy_presence : public AuraScript
void HandleEffectApply(AuraEffect const* aurEff, AuraEffectHandleModes /*mode*/) void HandleEffectApply(AuraEffect const* aurEff, AuraEffectHandleModes /*mode*/)
{ {
Unit* target = GetTarget(); Unit* target = GetTarget();
if (target->HasAura(SPELL_DK_UNHOLY_PRESENCE) && !target->HasAura(SPELL_DK_IMPROVED_UNHOLY_PRESENCE_TRIGGERED)) if (Fractured_UnitHasUnholyPresenceAura(target) && !target->HasAura(SPELL_DK_IMPROVED_UNHOLY_PRESENCE_TRIGGERED))
{ {
// Not listed as any effect, only base points set in dbc // Not listed as any effect, only base points set in dbc
int32 basePoints = GetSpellInfo()->Effects[EFFECT_1].CalcValue(); int32 basePoints = GetSpellInfo()->Effects[EFFECT_1].CalcValue();
target->CastCustomSpell(target, SPELL_DK_IMPROVED_UNHOLY_PRESENCE_TRIGGERED, &basePoints, &basePoints, &basePoints, true, nullptr, aurEff); target->CastCustomSpell(target, SPELL_DK_IMPROVED_UNHOLY_PRESENCE_TRIGGERED, &basePoints, &basePoints, &basePoints, true, nullptr, aurEff);
} }
if (target->HasAnyAuras(SPELL_DK_BLOOD_PRESENCE, SPELL_DK_FROST_PRESENCE) && !target->HasAura(SPELL_DK_UNHOLY_PRESENCE_TRIGGERED)) if (target->HasAnyAuras(SPELL_DK_BLOOD_PRESENCE, SPELL_DK_FROST_PRESENCE, SPELL_PARAGON_ADV_BLOOD_PRESENCE, SPELL_PARAGON_ADV_FROST_PRESENCE) && !target->HasAura(SPELL_DK_UNHOLY_PRESENCE_TRIGGERED))
target->CastCustomSpell(SPELL_DK_UNHOLY_PRESENCE_TRIGGERED, SPELLVALUE_BASE_POINT0, aurEff->GetAmount(), target, true, nullptr, aurEff); target->CastCustomSpell(SPELL_DK_UNHOLY_PRESENCE_TRIGGERED, SPELLVALUE_BASE_POINT0, aurEff->GetAmount(), target, true, nullptr, aurEff);
} }
@@ -1881,7 +1929,7 @@ class spell_dk_improved_unholy_presence : public AuraScript
target->RemoveAura(SPELL_DK_IMPROVED_UNHOLY_PRESENCE_TRIGGERED); target->RemoveAura(SPELL_DK_IMPROVED_UNHOLY_PRESENCE_TRIGGERED);
if (!target->HasAura(SPELL_DK_UNHOLY_PRESENCE)) if (!Fractured_UnitHasUnholyPresenceAura(target))
target->RemoveAura(SPELL_DK_UNHOLY_PRESENCE_TRIGGERED); target->RemoveAura(SPELL_DK_UNHOLY_PRESENCE_TRIGGERED);
} }
@@ -1916,6 +1964,20 @@ class spell_dk_pestilence : public SpellScript
if (!target) if (!target)
return; return;
// Fractured / Paragon: when the Pestilence caster is a Paragon and
// wildcard family matching is on, also spread (or refresh) Priest
// Devouring Plague. Devouring Plague's Dispel field is DISPEL_DISEASE
// and Unit::GetDiseasesByCaster already counts it for Paragon callers
// (see Unit.cpp), so it is conceptually a disease; stock Pestilence
// just hard-codes Blood Plague + Frost Fever and so silently drops it.
// GetAuraOfRankedSpell with the rank-1 id (2944 / 951000) covers every rank of
// Devouring Plague the player has on the target -- we re-cast that
// exact same rank so the spread copy carries the caster's actual
// damage tier rather than always rank 1. Stock DKs cannot cast
// Devouring Plague at all, so both lookups return null for them and
// this branch is a no-op there.
bool const paragonSpread = IsParagonWildcardCaller(caster);
// Spread on others // Spread on others
if (target != hitUnit) if (target != hitUnit)
{ {
@@ -1926,6 +1988,17 @@ class spell_dk_pestilence : public SpellScript
// Frost Fever // Frost Fever
if (target->GetAura(SPELL_DK_FROST_FEVER, caster->GetGUID())) if (target->GetAura(SPELL_DK_FROST_FEVER, caster->GetGUID()))
caster->CastSpell(hitUnit, SPELL_DK_FROST_FEVER, true); caster->CastSpell(hitUnit, SPELL_DK_FROST_FEVER, true);
// Fractured / Paragon: Devouring Plague spread (stock 2944 chain or
// Character Advancement multidot clone 951000 chain).
if (paragonSpread)
{
Aura const* dp = target->GetAuraOfRankedSpell(SPELL_PRIEST_DEVOURING_PLAGUE_R1, caster->GetGUID());
if (!dp)
dp = target->GetAuraOfRankedSpell(SPELL_PARAGON_MULTIDOT_DEVOURING_PLAGUE_R1, caster->GetGUID());
if (dp)
caster->CastSpell(hitUnit, dp->GetId(), true);
}
} }
// Refresh on target // Refresh on target
else if (caster->GetAura(SPELL_DK_GLYPH_OF_DISEASE)) else if (caster->GetAura(SPELL_DK_GLYPH_OF_DISEASE))
@@ -1946,6 +2019,16 @@ class spell_dk_pestilence : public SpellScript
disease->RefreshDuration(); disease->RefreshDuration();
else if (Aura* disease = target->GetAuraOfRankedSpell(SPELL_DK_CRYPT_FEVER_R1, caster->GetGUID())) else if (Aura* disease = target->GetAuraOfRankedSpell(SPELL_DK_CRYPT_FEVER_R1, caster->GetGUID()))
disease->RefreshDuration(); disease->RefreshDuration();
// Fractured / Paragon: Devouring Plague Glyph-of-Disease refresh.
if (paragonSpread)
{
Aura* dp = target->GetAuraOfRankedSpell(SPELL_PRIEST_DEVOURING_PLAGUE_R1, caster->GetGUID());
if (!dp)
dp = target->GetAuraOfRankedSpell(SPELL_PARAGON_MULTIDOT_DEVOURING_PLAGUE_R1, caster->GetGUID());
if (dp)
dp->RefreshDuration();
}
} }
} }
@@ -1969,6 +2052,9 @@ class spell_dk_presence : public AuraScript
SPELL_DK_BLOOD_PRESENCE, SPELL_DK_BLOOD_PRESENCE,
SPELL_DK_FROST_PRESENCE, SPELL_DK_FROST_PRESENCE,
SPELL_DK_UNHOLY_PRESENCE, SPELL_DK_UNHOLY_PRESENCE,
SPELL_PARAGON_ADV_BLOOD_PRESENCE,
SPELL_PARAGON_ADV_FROST_PRESENCE,
SPELL_PARAGON_ADV_UNHOLY_PRESENCE,
SPELL_DK_IMPROVED_BLOOD_PRESENCE_R1, SPELL_DK_IMPROVED_BLOOD_PRESENCE_R1,
SPELL_DK_IMPROVED_FROST_PRESENCE_R1, SPELL_DK_IMPROVED_FROST_PRESENCE_R1,
SPELL_DK_IMPROVED_UNHOLY_PRESENCE_R1, SPELL_DK_IMPROVED_UNHOLY_PRESENCE_R1,
@@ -1983,7 +2069,7 @@ class spell_dk_presence : public AuraScript
{ {
Unit* target = GetTarget(); Unit* target = GetTarget();
if (GetId() == SPELL_DK_BLOOD_PRESENCE) if (GetId() == SPELL_DK_BLOOD_PRESENCE || GetId() == SPELL_PARAGON_ADV_BLOOD_PRESENCE)
target->CastSpell(target, SPELL_DK_IMPROVED_BLOOD_PRESENCE_TRIGGERED, true); target->CastSpell(target, SPELL_DK_IMPROVED_BLOOD_PRESENCE_TRIGGERED, true);
else if (AuraEffect const* impAurEff = target->GetAuraEffectOfRankedSpell(SPELL_DK_IMPROVED_BLOOD_PRESENCE_R1, EFFECT_0)) else if (AuraEffect const* impAurEff = target->GetAuraEffectOfRankedSpell(SPELL_DK_IMPROVED_BLOOD_PRESENCE_R1, EFFECT_0))
if (!target->HasAura(SPELL_DK_IMPROVED_BLOOD_PRESENCE_TRIGGERED)) if (!target->HasAura(SPELL_DK_IMPROVED_BLOOD_PRESENCE_TRIGGERED))
@@ -1994,7 +2080,7 @@ class spell_dk_presence : public AuraScript
{ {
Unit* target = GetTarget(); Unit* target = GetTarget();
if (GetId() == SPELL_DK_FROST_PRESENCE) if (GetId() == SPELL_DK_FROST_PRESENCE || GetId() == SPELL_PARAGON_ADV_FROST_PRESENCE)
target->CastSpell(target, SPELL_DK_FROST_PRESENCE_TRIGGERED, true); target->CastSpell(target, SPELL_DK_FROST_PRESENCE_TRIGGERED, true);
else if (AuraEffect const* impAurEff = target->GetAuraEffectOfRankedSpell(SPELL_DK_IMPROVED_FROST_PRESENCE_R1, EFFECT_0)) else if (AuraEffect const* impAurEff = target->GetAuraEffectOfRankedSpell(SPELL_DK_IMPROVED_FROST_PRESENCE_R1, EFFECT_0))
if (!target->HasAura(SPELL_DK_FROST_PRESENCE_TRIGGERED)) if (!target->HasAura(SPELL_DK_FROST_PRESENCE_TRIGGERED))
@@ -2005,12 +2091,12 @@ class spell_dk_presence : public AuraScript
{ {
Unit* target = GetTarget(); Unit* target = GetTarget();
if (GetId() == SPELL_DK_UNHOLY_PRESENCE) if (GetId() == SPELL_DK_UNHOLY_PRESENCE || GetId() == SPELL_PARAGON_ADV_UNHOLY_PRESENCE)
target->CastSpell(target, SPELL_DK_UNHOLY_PRESENCE_TRIGGERED, true); target->CastSpell(target, SPELL_DK_UNHOLY_PRESENCE_TRIGGERED, true);
if (AuraEffect const* impAurEff = target->GetAuraEffectOfRankedSpell(SPELL_DK_IMPROVED_UNHOLY_PRESENCE_R1, EFFECT_0)) if (AuraEffect const* impAurEff = target->GetAuraEffectOfRankedSpell(SPELL_DK_IMPROVED_UNHOLY_PRESENCE_R1, EFFECT_0))
{ {
if (GetId() == SPELL_DK_UNHOLY_PRESENCE) if (GetId() == SPELL_DK_UNHOLY_PRESENCE || GetId() == SPELL_PARAGON_ADV_UNHOLY_PRESENCE)
{ {
// Not listed as any effect, only base points set // Not listed as any effect, only base points set
int32 bp = impAurEff->GetSpellInfo()->Effects[EFFECT_1].CalcValue(); int32 bp = impAurEff->GetSpellInfo()->Effects[EFFECT_1].CalcValue();
+5 -4
View File
@@ -1793,8 +1793,10 @@ class spell_sha_maelstrom_weapon : public AuraScript
// Fractured / Paragon: spell_proc row 53817 is data-relaxed (wildcard // Fractured / Paragon: spell_proc row 53817 is data-relaxed (wildcard
// family/mask) so cross-class spells can reach this CheckProc. We // family/mask) so cross-class spells can reach this CheckProc. We
// restore the original Shaman gating here for stock callers and add // restore the original Shaman gating here for stock callers and add
// the Paragon-only Mage Fireball / Frostbolt / Arcane Blast allowlist // the Paragon-only Mage Fireball / Frostbolt allowlist mirroring the
// mirroring the IsAffectedBySpellMod hook in SpellInfo.cpp. // IsAffectedBySpellMod hook in SpellInfo.cpp. Arcane Blast was on the
// allowlist briefly but proved too potent stacked with its own
// self-buff -- removed.
bool CheckProc(ProcEventInfo& eventInfo) bool CheckProc(ProcEventInfo& eventInfo)
{ {
SpellInfo const* procSpell = eventInfo.GetSpellInfo(); SpellInfo const* procSpell = eventInfo.GetSpellInfo();
@@ -1820,8 +1822,7 @@ class spell_sha_maelstrom_weapon : public AuraScript
SpellInfo const* first = procSpell->GetFirstRankSpell(); SpellInfo const* first = procSpell->GetFirstRankSpell();
uint32 firstId = first ? first->Id : procSpell->Id; uint32 firstId = first ? first->Id : procSpell->Id;
if (firstId == 133 /*Fireball*/ if (firstId == 133 /*Fireball*/
|| firstId == 116 /*Frostbolt*/ || firstId == 116 /*Frostbolt*/)
|| firstId == 30451 /*Arcane Blast*/)
return true; return true;
} }
@@ -115,6 +115,17 @@ CI workflow **Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml
**Manual upload:** `bash scripts/upload-release-to-gitea.sh /path/to/files v1.0.0` with the same env vars as CI. **Manual upload:** `bash scripts/upload-release-to-gitea.sh /path/to/files v1.0.0` with the same env vars as CI.
**Legacy “bridge” after changing `baked-gitea-channel.js`:** Players still using the old Gitea URL only receive launcher updates from that host. Build **Windows + Linux** installers (e.g. download **Fractured launcher CI** artifacts, or run **`npm run pack:win`** / **`npm run pack:linux`**), put **`dist/`** contents in one folder if needed, then:
```bash
export GITEA_BASE_URL=http://your-old-host:port # legacy base, no trailing slash
export GITEA_TOKEN=... GITEA_OWNER=Dawnsorrow GITEA_REPO=Fractured-Distro
bash tools/fractured-launcher-electron/scripts/gitea-replace-launcher-only.sh \
tools/fractured-launcher-electron/dist latest
```
That script deletes only **`Fractured-Launcher*`**, **`latest.yml`** (only if you supply a new **`latest.yml`** in **`dist/`**), **`latest-linux.yml`** (only if supplied), **`*.blockmap`**, and **`builder-debug.yml`** on the release, then uploads the new files — **Wow.exe**, MPQs, and **`patch-manifest.json`** are left alone. If you only built Linux locally, merge **Windows CI `dist/`** files into the same folder first so **`latest.yml`** is not removed without a replacement. Use release tag **`latest`** if that is what **`release_tag`** points at.
### Sync did not run / Gitea unchanged — checklist ### Sync did not run / Gitea unchanged — checklist
1. **Git tag ≠ GitHub Release** — Only **Releases** (published on the GitHub **Releases** page) trigger this workflow. If your teammate only **`git push --tags`**, create a **Release** from that tag and click **Publish** (or run **Actions → Sync release to Gitea → Run workflow** and enter the tag). 1. **Git tag ≠ GitHub Release** — Only **Releases** (published on the GitHub **Releases** page) trigger this workflow. If your teammate only **`git push --tags`**, create a **Release** from that tag and click **Publish** (or run **Actions → Sync release to Gitea → Run workflow** and enter the tag).
@@ -130,6 +141,7 @@ CI workflow **Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml
11. **`EBUSY` / file locked on Windows** — The client (or antivirus) may keep **`.MPQ`** files open. The launcher **retries** for a short window and downloads the new file **before** replacing the old one; if sync still fails, **exit WoW** (and any tool previewing that folder) and run **Download updates** again. 11. **`EBUSY` / file locked on Windows** — The client (or antivirus) may keep **`.MPQ`** files open. The launcher **retries** for a short window and downloads the new file **before** replacing the old one; if sync still fails, **exit WoW** (and any tool previewing that folder) and run **Download updates** again.
12. **`.bak-*` clutter** — When **Download updates** finishes without error, the launcher removes matching **`*.bak-YYYYMMDD-HHmmss`** files from earlier runs (same pattern it uses when replacing files). Failed syncs do not delete backups. 12. **`.bak-*` clutter** — When **Download updates** finishes without error, the launcher removes matching **`*.bak-YYYYMMDD-HHmmss`** files from earlier runs (same pattern it uses when replacing files). Failed syncs do not delete backups.
13. **Gitea still shows an old launcher version** — The sync workflow overlays **`tools/fractured-launcher-electron` from the default branch**, so **`package.json`** there defines the built version. Ensure launcher changes are **merged to `main`** before publishing the GitHub release (or re-run **Actions → Sync release to Gitea** after merging). Previously, **Fractured-Launcher\*** files **attached on the GitHub release** were merged too, which could leave **two** installer versions on Gitea; those assets are now skipped in favor of CI-only builds. 13. **Gitea still shows an old launcher version** — The sync workflow overlays **`tools/fractured-launcher-electron` from the default branch**, so **`package.json`** there defines the built version. Ensure launcher changes are **merged to `main`** before publishing the GitHub release (or re-run **Actions → Sync release to Gitea** after merging). Previously, **Fractured-Launcher\*** files **attached on the GitHub release** were merged too, which could leave **two** installer versions on Gitea; those assets are now skipped in favor of CI-only builds.
14. **Migrating `baked-gitea-channel.js` to a new host** — Publish **`gitea-replace-launcher-only.sh`** (see **Manual upload** above) on the **old** Gitea **`latest`** release so auto-update still works until clients move to the new URL.
### Private Gitea token for players ### Private Gitea token for players
@@ -4,10 +4,12 @@
* Production Gitea mirror (non-secret). Edit here and ship no inject script, * Production Gitea mirror (non-secret). Edit here and ship no inject script,
* no fractured-release-channel.json, no CI env needed for these fields. * no fractured-release-channel.json, no CI env needed for these fields.
* Token stays in env: GITEA_TOKEN or launcher.json gitea.token_env. * Token stays in env: GITEA_TOKEN or launcher.json gitea.token_env.
*
* Use origin only (no /releases path): API is {base_url}/api/v1/
* Web: https://git.hisora.dev/Dawnsorrow/Fractured-Distro/releases
*/ */
module.exports = { module.exports = {
// http:// kept as-is; bare host gets https in gitea-release.js base_url: 'https://git.hisora.dev',
base_url: 'http://brassnet.ddns.net:33983',
owner: 'Dawnsorrow', owner: 'Dawnsorrow',
repo: 'Fractured-Distro', repo: 'Fractured-Distro',
release_tag: 'latest', release_tag: 'latest',
@@ -1,6 +1,6 @@
{ {
"name": "fractured-launcher-electron", "name": "fractured-launcher-electron",
"version": "1.0.12", "version": "1.0.13",
"description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update", "description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update",
"main": "main.js", "main": "main.js",
"repository": { "repository": {
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Replace only launcher installer + latest.yml attachments on a Gitea release.
# Does NOT delete Wow.exe, MPQs, or patch-manifest — use this to publish a
# "bridge" build (e.g. 1.0.13 with new baked Gitea URL) on a legacy host while
# keeping game assets already on that release.
#
# Usage:
# export GITEA_BASE_URL=http://legacy-host:port # or https://...
# export GITEA_TOKEN=gta_...
# export GITEA_OWNER=Dawnsorrow
# export GITEA_REPO=Fractured-Distro
# ./gitea-replace-launcher-only.sh /path/to/electron/dist latest
#
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=release-sync-filters.sh
. "$SCRIPT_DIR/release-sync-filters.sh"
DIST_DIR="${1:?first arg: electron-builder dist directory (contains .exe / .AppImage / latest*.yml)}"
TAG="${2:?second arg: release tag (e.g. latest)}"
: "${GITEA_BASE_URL:?Set GITEA_BASE_URL}"
: "${GITEA_TOKEN:?Set GITEA_TOKEN}"
: "${GITEA_OWNER:?Set GITEA_OWNER}"
: "${GITEA_REPO:?Set GITEA_REPO}"
BASE="${GITEA_BASE_URL%/}"
API="$BASE/api/v1"
AUTH_H=(-H "Authorization: token ${GITEA_TOKEN}" -H "Accept: application/json")
TAG_ENC=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$TAG")
REL_JSON=$(mktemp)
trap 'rm -f "$REL_JSON"' EXIT
code=$(curl -sS -o "$REL_JSON" -w "%{http_code}" "${AUTH_H[@]}" \
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/tags/${TAG_ENC}")
if [ "$code" != "200" ]; then
echo "Gitea GET release by tag failed HTTP $code (release must already exist):" >&2
cat "$REL_JSON" >&2
exit 1
fi
rel_id=$(jq -r '.id' "$REL_JSON")
if [ -z "$rel_id" ] || [ "$rel_id" = "null" ]; then
echo "Could not resolve Gitea release id" >&2
exit 1
fi
should_delete_attachment() {
local l
l=$(printf '%s' "${1##*/}" | tr '[:upper:]' '[:lower:]')
case "$l" in
fractured-launcher*) return 0 ;;
*.blockmap) return 0 ;;
builder-debug.yml|builder-debug.yaml) return 0 ;;
esac
return 1
}
should_delete_yml_attachment() {
local l
l=$(printf '%s' "${1##*/}" | tr '[:upper:]' '[:lower:]')
case "$l" in
latest.yml) [ -f "$DIST_DIR/latest.yml" ] ;;
latest-linux.yml) [ -f "$DIST_DIR/latest-linux.yml" ] ;;
latest-mac.yml) [ -f "$DIST_DIR/latest-mac.yml" ] ;;
*) return 1 ;;
esac
}
while read -r line; do
[ -z "$line" ] && continue
aid=$(printf '%s' "$line" | cut -f1)
aname=$(printf '%s' "$line" | cut -f2-)
if should_delete_attachment "$aname" || should_delete_yml_attachment "$aname"; then
echo "Removing old attachment: $aname (id=$aid)"
curl -fsS -X DELETE "${AUTH_H[@]}" \
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${rel_id}/assets/${aid}" || true
fi
done < <(jq -r '(.attachments // .assets // [])[] | "\(.id)\t\(.name)"' "$REL_JSON")
shopt -s nullglob
upload_paths=()
for f in "$DIST_DIR"/Fractured-Launcher*.exe "$DIST_DIR"/Fractured-Launcher*.AppImage \
"$DIST_DIR"/latest.yml "$DIST_DIR"/latest-linux.yml "$DIST_DIR"/latest-mac.yml; do
[ -f "$f" ] || continue
bn=$(basename "$f")
if should_skip_gitea_upload "$bn"; then
continue
fi
upload_paths+=("$f")
done
if [ "${#upload_paths[@]}" -eq 0 ]; then
echo "No launcher files to upload under $DIST_DIR (expected Fractured-Launcher*.exe, *.AppImage, latest.yml, latest-linux.yml)." >&2
exit 1
fi
for f in "${upload_paths[@]}"; do
bn=$(basename "$f")
echo "Uploading $bn"
curl -fsS -X POST "${AUTH_H[@]}" \
-F "attachment=@${f}" \
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${rel_id}/assets"
done
echo "Done. Release $TAG (id=$rel_id): replaced ${#upload_paths[@]} launcher file(s); game assets left intact."