Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e649402163 | |||
| a1c9172beb | |||
| b408c8a95d | |||
| f88a303327 |
@@ -74,8 +74,6 @@ jobs:
|
||||
if (Test-Path tools/fractured-launcher-electron/dist/latest.yml) {
|
||||
Copy-Item tools/fractured-launcher-electron/dist/latest.yml launcher-publish/
|
||||
}
|
||||
Get-ChildItem tools/fractured-launcher-electron/dist/*.blockmap -ErrorAction SilentlyContinue |
|
||||
Copy-Item -Destination launcher-publish/
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -87,6 +85,13 @@ jobs:
|
||||
if: github.repository == 'Dawnforger/Fractured'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: |
|
||||
tools/fractured-launcher-electron/scripts
|
||||
sparse-checkout-cone-mode: true
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: electron-dist
|
||||
@@ -97,6 +102,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. tools/fractured-launcher-electron/scripts/release-sync-filters.sh
|
||||
TAG="${{ needs.meta.outputs.tag }}"
|
||||
mkdir -p combined
|
||||
mkdir -p /tmp/from-main
|
||||
@@ -104,6 +110,11 @@ jobs:
|
||||
shopt -s nullglob
|
||||
for f in /tmp/from-main/*; do
|
||||
if [ -f "$f" ]; then
|
||||
bn=$(basename "$f")
|
||||
if should_skip_merge_from_github "$bn"; then
|
||||
echo "Skipping GitHub release asset (CI launcher or excluded): $bn"
|
||||
continue
|
||||
fi
|
||||
cp -f "$f" combined/
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -49,7 +49,6 @@ jobs:
|
||||
path: |
|
||||
tools/fractured-launcher-electron/dist/*.exe
|
||||
tools/fractured-launcher-electron/dist/latest.yml
|
||||
tools/fractured-launcher-electron/dist/*.blockmap
|
||||
|
||||
electron-launcher-linux:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -77,5 +76,5 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
path: |
|
||||
tools/fractured-launcher-electron/dist/*.AppImage
|
||||
tools/fractured-launcher-electron/dist/*.yml
|
||||
tools/fractured-launcher-electron/dist/*.blockmap
|
||||
tools/fractured-launcher-electron/dist/latest.yml
|
||||
tools/fractured-launcher-electron/dist/latest-linux.yml
|
||||
|
||||
@@ -104,6 +104,7 @@ jobs:
|
||||
working-directory: tools/fractured-launcher-electron
|
||||
run: |
|
||||
npm ci
|
||||
node -p "'Launcher package.json version: ' + require('./package.json').version"
|
||||
npm run pack:win
|
||||
|
||||
- name: Stage launcher files for upload
|
||||
@@ -114,8 +115,6 @@ jobs:
|
||||
if (Test-Path tools/fractured-launcher-electron/dist/latest.yml) {
|
||||
Copy-Item tools/fractured-launcher-electron/dist/latest.yml launcher-publish/
|
||||
}
|
||||
Get-ChildItem tools/fractured-launcher-electron/dist/*.blockmap -ErrorAction SilentlyContinue |
|
||||
Copy-Item -Destination launcher-publish/
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -150,6 +149,7 @@ jobs:
|
||||
working-directory: tools/fractured-launcher-electron
|
||||
run: |
|
||||
npm ci
|
||||
node -p "'Launcher package.json version: ' + require('./package.json').version"
|
||||
npm run pack:linux
|
||||
|
||||
- name: Stage Linux launcher for upload
|
||||
@@ -159,8 +159,9 @@ jobs:
|
||||
mkdir -p launcher-linux-publish
|
||||
shopt -s nullglob
|
||||
cp -f tools/fractured-launcher-electron/dist/*.AppImage launcher-linux-publish/ 2>/dev/null || true
|
||||
cp -f tools/fractured-launcher-electron/dist/*.yml launcher-linux-publish/ 2>/dev/null || true
|
||||
cp -f tools/fractured-launcher-electron/dist/*.blockmap launcher-linux-publish/ 2>/dev/null || true
|
||||
for f in tools/fractured-launcher-electron/dist/latest.yml tools/fractured-launcher-electron/dist/latest-linux.yml; do
|
||||
if [ -f "$f" ]; then cp -f "$f" launcher-linux-publish/; fi
|
||||
done
|
||||
ls -la launcher-linux-publish/
|
||||
if ! compgen -G "launcher-linux-publish/*.AppImage" > /dev/null; then
|
||||
echo "No AppImage under dist/ — electron-builder linux target failed" >&2
|
||||
@@ -206,6 +207,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. tools/fractured-launcher-electron/scripts/release-sync-filters.sh
|
||||
TAG="${{ needs.meta.outputs.tag }}"
|
||||
mkdir -p combined
|
||||
mkdir -p /tmp/from-main
|
||||
@@ -213,6 +215,11 @@ jobs:
|
||||
shopt -s nullglob
|
||||
for f in /tmp/from-main/*; do
|
||||
if [ -f "$f" ]; then
|
||||
bn=$(basename "$f")
|
||||
if should_skip_merge_from_github "$bn"; then
|
||||
echo "Skipping GitHub release asset (CI launcher or excluded): $bn"
|
||||
continue
|
||||
fi
|
||||
cp -f "$f" combined/
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -21,7 +21,7 @@ when a release is published here (workflow **Sync release to Fractured-Distro**)
|
||||
| Artifact | Size | Purpose |
|
||||
|---|---|---|
|
||||
| `patch-enUS-4.MPQ` | ~5 MB | DBC + GlueXML bake. Adds `CLASS_PARAGON` (id 12), the character-create slot, glue strings, game-table DBCs, and a patched `Spell.dbc`: **(1)** `RuneCostID` zeroed on every rune-cost spell so non–Death Knight clients still send DK casts (rune costs are shown via `RuneFrame.lua`); **(2)** `Reagent[]` / `ReagentCount[]` zeroed on every spell whose `SpellFamilyName` is non-zero (all class abilities), while profession crafts (`SpellFamilyName == 0`) keep their materials. Both edits mirror server load-time corrections so client preflight and server validation stay aligned. Required for character creation as Paragon to even show up. |
|
||||
| `patch-enUS-5.MPQ` | ~57 KB | FrameXML overrides. Replaces stock `PlayerFrame.lua` / `RuneFrame.lua` / `ComboFrame.lua` / `UnitFrame.lua` / `SpellBookFrame.lua` + `SpellBookFrame.xml` with Paragon-aware versions: rune simulator, combo-point simulator, server-authoritative resource sync over the `PARAA` addon channel, action-button usability + click guards, an expanded spellbook (higher `MAX_SPELLS`, 24 skill-line tabs instead of stock 8) so all-class spells render, Paragon stat tooltips on the character sheet (including filtering duplicate “attack power from strength” lines so the paper doll matches server AP), a tooltip post-processor that appends ", Paragon" to the "Classes:" line on class-restricted gear / glyphs (the server bypasses `AllowableClass` for class 12, but the engine paints the line red and omits Paragon — the Lua hook recolors it green and adds the name so the player can tell it's wearable), and **PetFrame** re-anchored so the **pet unit frame sits below the rune row** for Paragon (stock layout had runes overlapping the pet portrait). The paper-doll **ammo slot** follows stock visibility rules (shown for hunters / ranged weapons; hidden when `UnitHasRelicSlot` applies). |
|
||||
| `patch-enUS-5.MPQ` | ~64 KB | FrameXML overrides. Replaces stock `PlayerFrame.lua` / `RuneFrame.lua` / `ComboFrame.lua` / `UnitFrame.lua` / `SpellBookFrame.lua` + `SpellBookFrame.xml` with Paragon-aware versions: rune simulator, combo-point simulator, server-authoritative resource sync over the `PARAA` addon channel, action-button usability + click guards, an expanded spellbook (higher `MAX_SPELLS`, 24 skill-line tabs instead of stock 8) so all-class spells render, Paragon stat tooltips on the character sheet (including filtering duplicate “attack power from strength” lines so the paper doll matches server AP), a tooltip post-processor that appends ", Paragon" to the "Classes:" line on class-restricted gear / glyphs (the server bypasses `AllowableClass` for class 12, but the engine paints the line red and omits Paragon — the Lua hook recolors it green and adds the name so the player can tell it's wearable), a **spell-tooltip post-processor** that (1) recolors and appends "(Paragon: bypassed)" to "Requires *Stance*" lines on Warrior abilities (server-side `SpellInfo::CheckShapeshift` skips stance enforcement for Paragons on `SPELLFAMILY_WARRIOR` spells, but the client still renders the requirement from the stock `Stances` DBC field which we deliberately leave unzeroed so stock Warriors keep enforcement), (2) appends a Paragon line to **Maelstrom Weapon** (53817) noting that Fireball / Frostbolt / Arcane Blast also benefit, and (3) appends a Paragon line to **Mirror Image** (55342) noting that the images cast random damage spells with a cast time from the caster's spellbook instead of Frostbolt — all three patches gate on `UnitClass("player") == "PARAGON"` so stock-class tooltips are byte-identical to vanilla, and **PetFrame** re-anchored so the **pet unit frame sits below the rune row** for Paragon (stock layout had runes overlapping the pet portrait). A **Warrior stance click bypass** wraps `UseAction` so that for Paragon characters, clicking an action slot bound to a stance-gated Warrior ability (Whirlwind, Charge, Pummel, Shield Slam, Hamstring, Overpower, Shield Bash, Shield Block, Disarm, Revenge, Spell Reflection, Recklessness, Bladestorm, Shockwave, Concussion Blow, Last Stand, Sweeping Strikes, Mocking Blow, Heroic Fury, Slam, Devastate, Intercept) routes through `CastSpellByName(name)` instead of the engine's stance-gated `UseAction` path; the engine's stance pre-check inside `UseAction` would otherwise drop the cast packet client-side and our server-side `CheckShapeshift` bypass would never get to run. Stock classes never enter the bypass branch. The paper-doll **ammo slot** follows stock visibility rules (shown for hunters / ranged weapons; hidden when `UnitHasRelicSlot` applies). |
|
||||
| `patch-enUS-6.MPQ` | ~134 KB | The `ParagonAdvancement` addon. Replaces the talent pane (`N` key) for Paragon characters with the Character Advancement panel: per-class spell tabs, talent grid, Overview/Search tabs, AE/TE currency, commit / reset / preview, login-time toast suppression, a **PETS** tab with live hunter pet talent trees (preview learn, no TE/AE cost), a dedicated **Reset Pet Talents** control (server `PARAA` `C RESET PET TALENTS` — instant, no gold, no confirmation; requires matching worldserver), bottom-row **Reset all Abilities / Reset Build / Reset all Talents** disabled while on the PETS tab so those paths cannot dismiss the pet or unlearn Tame Beast, and a **Builds** page (full-pane overlay opened from the bottom-row Builds button) for saving named, icon-tagged loadouts: New Build (+) icon picker reuses `MACRO_ICON_FILENAMES`, right-click for edit/delete, shift-left-click to favorite (favorites bubble to the top), left-click pops a Load Build confirm. Build swaps reset + refund AE/TE, re-spend on the saved recipe, and **park hunter pets** to `PET_SAVE_NOT_IN_SLOT` so their name/talents/exp are preserved across swaps. |
|
||||
| `Wow.exe` | ~7.5 MB | 3.3.5a (build 12340) client byte-patched to skip the MPQ signature check so custom `patch-enUS-N.MPQ` files load. Diff against stock is a few bytes; everything else is unchanged. |
|
||||
|
||||
@@ -251,7 +251,8 @@ order on a maintainer machine:
|
||||
|
||||
1. `fractured-tooling/from-workspace-root/_patch_spell_dbc_runes.py` — stage `Spell.dbc` with `RuneCostID` cleared.
|
||||
2. `fractured-tooling/from-workspace-root/_patch_spell_dbc_reagents.py` — same staged `Spell.dbc`, clear class-spell reagents for client preflight.
|
||||
3. `fractured-tooling/from-workspace-root/_make_paragon_dbc_patch.py` — rebuild `ChrClasses` / `CharBaseInfo` / game tables, then pack `patch-enUS-4.MPQ`.
|
||||
3. `fractured-tooling/from-workspace-root/_patch_spell_dbc_stances.py` — same staged `Spell.dbc`, zero the `Stances` field on every `SpellFamilyName == 4` (Warrior) row so the client engine's "Must be in Battle/Defensive/Berserker Stance" pre-cast check stops eating `CMSG_CAST_SPELL` packets for Paragon casters who never picked the stance form. The server's `SpellInfo::CheckShapeshift` Paragon bypass takes over from there. Stock Warriors still see the server-side stance error mid-cast if they actually click while out of stance.
|
||||
4. `fractured-tooling/from-workspace-root/_make_paragon_dbc_patch.py` — rebuild `ChrClasses` / `CharBaseInfo` / game tables, then pack `patch-enUS-4.MPQ`.
|
||||
|
||||
The patched `Wow.exe` is a one-time hex-edit of the stock 3.3.5a
|
||||
client. The diff is publicly documented in the WoW emulation community
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- mod-paragon: Predatory Strikes (16972 / 16974 / 16975) Cataclysm-style
|
||||
-- finisher proc for CLASS_PARAGON characters.
|
||||
--
|
||||
-- The 3.3.5 Predatory Strikes is a passive AP / ranged-attack-power talent
|
||||
-- with no proc payload. The Cataclysm redesign added "Predator's Swiftness"
|
||||
-- (69369), which makes the next Nature spell <10s base cast time instant.
|
||||
-- That buff already exists in the WotLK Spell.dbc (because Blizzard reused
|
||||
-- the spell id), but no server-side trigger ever calls CastSpell(69369) on
|
||||
-- a 3.3.5 server. We need both halves: the proc handler AND a spell_proc
|
||||
-- row so the proc evaluator actually invokes our AuraScript.
|
||||
--
|
||||
-- AuraScript binding: spell_paragon_predatory_strikes is registered in
|
||||
-- modules/mod-paragon/src/Paragon_SC.cpp. It checks
|
||||
-- (a) caster is CLASS_PARAGON,
|
||||
-- (b) source spell consumes combo points (NeedsComboPoints),
|
||||
-- (c) source spell deals damage (DmgClass MELEE/RANGED + at least one
|
||||
-- damage effect or periodic-damage aura -- filters Slice and Dice,
|
||||
-- Savage Roar, Maim, Kidney Shot, Expose Armor, Recuperate),
|
||||
-- then rolls a per-rank chance of (CP * 3 / 5 / 7)% to cast 69369 on
|
||||
-- the caster.
|
||||
--
|
||||
-- spell_proc row params:
|
||||
-- ProcFlags = 0x40000 = PROC_FLAG_DONE_SPELL_MELEE_DMG_CLASS
|
||||
-- SpellTypeMask = 0x3 (DAMAGE | HEAL bitmask in proc engine; we
|
||||
-- filter precisely in CheckProc anyway, the
|
||||
-- mask just gates "spell-type events" through)
|
||||
-- SpellPhaseMask = 0x2 = PROC_SPELL_PHASE_CAST -- fires DURING cast
|
||||
-- so player->GetComboPoints() inside HandleProc
|
||||
-- still returns the pre-_handle_finish_phase
|
||||
-- value.
|
||||
-- Chance = 100 (the per-CP chance is rolled inside the script)
|
||||
--
|
||||
-- Note: this row's SpellFamilyName / SpellFamilyMask are 0 so the proc
|
||||
-- engine's IsAffected check is a wildcard at the entry-level. The
|
||||
-- AuraScript's CheckProc owns all real filtering. Combined with Phase A
|
||||
-- (Paragon SpellFamilyName wildcard) this is harmless on stock classes
|
||||
-- because non-Paragon characters cannot learn Predatory Strikes via the
|
||||
-- Character Advancement panel.
|
||||
|
||||
DELETE FROM `spell_script_names`
|
||||
WHERE `spell_id` IN (16972, 16974, 16975)
|
||||
AND `ScriptName` = 'spell_paragon_predatory_strikes';
|
||||
|
||||
INSERT INTO `spell_script_names` (`spell_id`, `ScriptName`) VALUES
|
||||
(16972, 'spell_paragon_predatory_strikes'),
|
||||
(16974, 'spell_paragon_predatory_strikes'),
|
||||
(16975, 'spell_paragon_predatory_strikes');
|
||||
|
||||
DELETE FROM `spell_proc` WHERE `SpellId` IN (16972, 16974, 16975);
|
||||
|
||||
INSERT INTO `spell_proc`
|
||||
(`SpellId`, `SchoolMask`, `SpellFamilyName`,
|
||||
`SpellFamilyMask0`, `SpellFamilyMask1`, `SpellFamilyMask2`,
|
||||
`ProcFlags`, `SpellTypeMask`, `SpellPhaseMask`, `HitMask`,
|
||||
`AttributesMask`, `DisableEffectsMask`, `ProcsPerMinute`,
|
||||
`Chance`, `Cooldown`, `Charges`)
|
||||
VALUES
|
||||
(16972, 0, 0, 0, 0, 0, 0x40000, 0x3, 0x2, 0, 0, 0, 0, 100.0, 0, 0),
|
||||
(16974, 0, 0, 0, 0, 0, 0x40000, 0x3, 0x2, 0, 0, 0, 0, 100.0, 0, 0),
|
||||
(16975, 0, 0, 0, 0, 0, 0x40000, 0x3, 0x2, 0, 0, 0, 0, 100.0, 0, 0);
|
||||
@@ -0,0 +1,46 @@
|
||||
-- mod-paragon: Vampiric Embrace (15286) cross-family wildcard.
|
||||
--
|
||||
-- Stock 3.3.5 spell_proc row for Vampiric Embrace gates by SpellFamilyName=6
|
||||
-- (PRIEST) plus a Priest-Shadow-damage SpellFamilyMask. That blocks the proc
|
||||
-- engine from ever calling the AuraScript's CheckProc when a Paragon casts a
|
||||
-- non-Priest Shadow-school spell (e.g. Warlock Shadow Bolt, Death Knight
|
||||
-- Death Coil, etc.), because IsAffected's familyFlags gate fails before
|
||||
-- CheckProc runs even though the Phase A wildcard already loosens the
|
||||
-- familyName equality test (see SpellInfo::IsAffected, listenerOwner overload).
|
||||
--
|
||||
-- We relax this row so:
|
||||
-- * SchoolMask=32 (SHADOW) kept -- proc-engine still gates by school
|
||||
-- * SpellTypeMask=1 (DAMAGE) kept -- only damage events trigger CheckProc
|
||||
-- * SpellPhaseMask=2 (HIT) kept -- post-hit phase
|
||||
-- * AttributesMask=2 (TRIGGERED) kept -- triggered-spell payloads still proc
|
||||
-- * SpellFamilyName=0 wildcard -- IsAffected short-circuits to true
|
||||
-- * SpellFamilyMask{0,1,2}=0 wildcard -- no flag-bit gating at this layer
|
||||
--
|
||||
-- Real filtering moves into spell_pri_vampiric_embrace::CheckProc, which
|
||||
-- branches on IsParagonWildcardCaller(GetTarget()):
|
||||
--
|
||||
-- * For Paragon owners with `Paragon.WildcardFamilyMatching = 1`, accept any
|
||||
-- single-target Shadow-school spell (Mind Sear / AoE shadow spells like
|
||||
-- Seed of Corruption / Hellfire are filtered there via IsAffectingArea
|
||||
-- and the existing Mind Sear bit-mask).
|
||||
--
|
||||
-- * For stock Priest owners (and for the non-wildcard runtime path), the
|
||||
-- CheckProc re-enforces the EXACT original gate -- SpellFamilyName=6 plus
|
||||
-- the original 0x0280A010 / 0x00002402 / 0x00000008 SpellFamilyMask bits
|
||||
-- -- so behavior is byte-identical to before this change for any caster
|
||||
-- that is not a Paragon.
|
||||
--
|
||||
-- Net effect: Paragon characters with VE learned now leech-heal off any
|
||||
-- single-target Shadow spell they cast (Death Coil, Shadow Bolt, Searing
|
||||
-- Pain, Drain Soul, etc.); stock Shadow Priests are unchanged.
|
||||
|
||||
DELETE FROM `spell_proc` WHERE `SpellId` = 15286;
|
||||
|
||||
INSERT INTO `spell_proc`
|
||||
(`SpellId`, `SchoolMask`, `SpellFamilyName`,
|
||||
`SpellFamilyMask0`, `SpellFamilyMask1`, `SpellFamilyMask2`,
|
||||
`ProcFlags`, `SpellTypeMask`, `SpellPhaseMask`, `HitMask`,
|
||||
`AttributesMask`, `DisableEffectsMask`, `ProcsPerMinute`,
|
||||
`Chance`, `Cooldown`, `Charges`)
|
||||
VALUES
|
||||
(15286, 32, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 0, 0, 0);
|
||||
@@ -0,0 +1,51 @@
|
||||
-- mod-paragon: Maelstrom Weapon (53817) cross-family wildcard.
|
||||
--
|
||||
-- Stock 3.3.5 spell_proc row for Maelstrom Weapon gates by SpellFamilyName=11
|
||||
-- (SHAMAN) plus a Shaman SpellFamilyMask covering Lightning Bolt, Chain
|
||||
-- Lightning, Lesser Healing Wave, Healing Wave and Hex (Mask0=451,
|
||||
-- Mask1=32768). The proc engine therefore never delivers an event to the
|
||||
-- AuraScript when a Paragon casts a non-Shaman cast-time spell, even if the
|
||||
-- IsAffected wildcard relaxes SpellFamilyName equality (the SpellFamilyMask
|
||||
-- AND-with-target-FamilyFlags check still fails because Mage / Warlock /
|
||||
-- Druid spell-class bits do not overlap with Shaman bits).
|
||||
--
|
||||
-- We relax this row so:
|
||||
-- * SchoolMask=0 wildcard -- proc engine no longer gates by school
|
||||
-- * SpellTypeMask=1 (DAMAGE) kept -- only damage spells trigger CheckProc
|
||||
-- * SpellPhaseMask=8 (FINISH) kept -- post-cast phase, on cast finish
|
||||
-- * SpellFamilyName=0 wildcard -- IsAffected short-circuits to true
|
||||
-- * SpellFamilyMask{0,1,2}=0 wildcard -- no flag-bit gating at this layer
|
||||
--
|
||||
-- Real filtering moves into spell_sha_maelstrom_weapon::CheckProc:
|
||||
--
|
||||
-- * For stock Shaman owners (and for the non-wildcard runtime path), the
|
||||
-- CheckProc re-enforces the EXACT original gate -- SpellFamilyName=11
|
||||
-- plus the original Mask0=451 / Mask1=32768 bits -- so behavior is
|
||||
-- byte-identical to before this change for any caster that is not a
|
||||
-- Paragon.
|
||||
--
|
||||
-- * For Paragon owners with `Paragon.WildcardFamilyMatching = 1`, the
|
||||
-- stock allowlist still passes, AND we additionally accept the curated
|
||||
-- Mage cast-time nukes Fireball / Frostbolt / Arcane Blast (any rank,
|
||||
-- matched via GetFirstRankSpell).
|
||||
--
|
||||
-- The matching IsAffectedBySpellMod hook in SpellInfo.cpp ensures the cast
|
||||
-- time + power cost spellmods on aura 53817 also bridge across families for
|
||||
-- the same Mage spell allowlist, so Paragons get the full Maelstrom Weapon
|
||||
-- experience (instant cast at 5 stacks + reduced mana cost) on Fireball,
|
||||
-- Frostbolt and Arcane Blast.
|
||||
--
|
||||
-- Net effect: Paragon characters with Maelstrom Weapon learned now spend
|
||||
-- stacks on Mage cast-time nukes in addition to the stock Shaman list;
|
||||
-- stock Enhancement Shamans are unchanged.
|
||||
|
||||
DELETE FROM `spell_proc` WHERE `SpellId` = 53817;
|
||||
|
||||
INSERT INTO `spell_proc`
|
||||
(`SpellId`, `SchoolMask`, `SpellFamilyName`,
|
||||
`SpellFamilyMask0`, `SpellFamilyMask1`, `SpellFamilyMask2`,
|
||||
`ProcFlags`, `SpellTypeMask`, `SpellPhaseMask`, `HitMask`,
|
||||
`AttributesMask`, `DisableEffectsMask`, `ProcsPerMinute`,
|
||||
`Chance`, `Cooldown`, `Charges`)
|
||||
VALUES
|
||||
(53817, 0, 0, 0, 0, 0, 0, 1, 8, 0, 0, 0, 0, 0, 0, 0);
|
||||
@@ -0,0 +1,66 @@
|
||||
-- mod-paragon: Frostbite + Fingers of Frost cross-family wildcard.
|
||||
--
|
||||
-- Both talents are normally gated by SpellFamilyName=3 (MAGE) plus a Mage
|
||||
-- SpellFamilyMask covering specific Mage Frost spells (Frostbolt / Frost Nova
|
||||
-- / Cone of Cold / Blizzard / Frostfire Bolt / Deep Freeze for FoF; Frost
|
||||
-- slow-applying spells for Frostbite). That blocks the proc engine from
|
||||
-- delivering an event to the AuraScript when a Paragon casts a non-Mage
|
||||
-- Frost-school chill effect (DK Howling Blast / Icy Touch / Chains of Ice,
|
||||
-- Hunter Frost Trap, Shaman Frost Shock, etc.), because IsAffected's
|
||||
-- familyFlags AND-with-target-FamilyFlags check fails before CheckProc runs
|
||||
-- even after the Paragon family-name wildcard.
|
||||
--
|
||||
-- We relax these rows so:
|
||||
-- * SchoolMask=16 (FROST) gate by Frost school at the proc engine
|
||||
-- * SpellTypeMask=1 (DAMAGE) only damage events trigger CheckProc
|
||||
-- * SpellPhaseMask=2 (HIT) post-hit phase
|
||||
-- * AttributesMask=2 (TRIGGERED) triggered chill payloads still proc
|
||||
-- * SpellFamilyName=0 wildcard -- IsAffected short-circuits to true
|
||||
-- * SpellFamilyMask{0,1,2}=0 wildcard -- no flag-bit gating at this layer
|
||||
--
|
||||
-- Real filtering moves into the Mage AuraScripts:
|
||||
--
|
||||
-- spell_mage_fingers_of_frost_talent attached to 44543 / 44545
|
||||
-- stock Mage : SpellFamilyName=MAGE AND original Mask0 0x100120 / Mask1 0x1000
|
||||
-- Paragon : accept (FROST + DAMAGE gate already enforced)
|
||||
--
|
||||
-- spell_mage_frostbite attached to 11071 / 12496 / 12497
|
||||
-- stock Mage : SpellFamilyName=MAGE AND original Mage Frost-slow Mask
|
||||
-- (Frostbolt / Frost Nova / Cone of Cold / Blizzard / FFB)
|
||||
-- Paragon : accept iff proc spell applies SPELL_AURA_MOD_DECREASE_SPEED
|
||||
-- OR the Paragon already has a slow on the proc target
|
||||
-- (covers the Improved-Blizzard-style "chill via separate
|
||||
-- triggered aura" cross-class case)
|
||||
--
|
||||
-- Net effect: Paragon characters with these talents now have FoF / Frostbite
|
||||
-- proc off cross-class Frost-school chill effects; stock Mages are unchanged.
|
||||
|
||||
DELETE FROM `spell_proc` WHERE `SpellId` IN (44543, 44545, 11071, 12496, 12497);
|
||||
|
||||
INSERT INTO `spell_proc`
|
||||
(`SpellId`, `SchoolMask`, `SpellFamilyName`,
|
||||
`SpellFamilyMask0`, `SpellFamilyMask1`, `SpellFamilyMask2`,
|
||||
`ProcFlags`, `SpellTypeMask`, `SpellPhaseMask`, `HitMask`,
|
||||
`AttributesMask`, `DisableEffectsMask`, `ProcsPerMinute`,
|
||||
`Chance`, `Cooldown`, `Charges`)
|
||||
VALUES
|
||||
-- Fingers of Frost talent ranks (Chance 7% / 15% preserved from stock row).
|
||||
(44543, 16, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 7, 0, 0),
|
||||
(44545, 16, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 15, 0, 0),
|
||||
-- Frostbite talent ranks (5% / 10% / 15% per rank, leave Chance=0 to use
|
||||
-- the DBC ProcChance which already encodes the per-rank percentage).
|
||||
(11071, 16, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 0, 0, 0),
|
||||
(12496, 16, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 0, 0, 0),
|
||||
(12497, 16, 0, 0, 0, 0, 0, 1, 2, 0, 2, 0, 0, 0, 0, 0);
|
||||
|
||||
-- Bind the new AuraScripts (defined in src/server/scripts/Spells/spell_mage.cpp).
|
||||
DELETE FROM `spell_script_names`
|
||||
WHERE `spell_id` IN (44543, 44545, 11071, 12496, 12497)
|
||||
AND `ScriptName` IN ('spell_mage_fingers_of_frost_talent', 'spell_mage_frostbite');
|
||||
|
||||
INSERT INTO `spell_script_names` (`spell_id`, `ScriptName`) VALUES
|
||||
(44543, 'spell_mage_fingers_of_frost_talent'),
|
||||
(44545, 'spell_mage_fingers_of_frost_talent'),
|
||||
(11071, 'spell_mage_frostbite'),
|
||||
(12496, 'spell_mage_frostbite'),
|
||||
(12497, 'spell_mage_frostbite');
|
||||
@@ -0,0 +1,51 @@
|
||||
-- mod-paragon: Maelstrom Weapon (53817) spell_proc fixup.
|
||||
--
|
||||
-- The previous migration (2026_05_11_02.sql) had two bugs in the rewritten
|
||||
-- spell_proc row that prevented stack consumption from firing at all -- not
|
||||
-- just for our new Mage targets, but also for the stock Shaman cast-time
|
||||
-- spells (Lightning Bolt, Chain Lightning, Lesser Healing Wave, etc.).
|
||||
--
|
||||
-- Bugs:
|
||||
--
|
||||
-- * SpellPhaseMask was set to 8. The valid values for SpellPhaseMask are
|
||||
-- PROC_SPELL_PHASE_CAST = 1
|
||||
-- PROC_SPELL_PHASE_HIT = 2
|
||||
-- PROC_SPELL_PHASE_FINISH = 4
|
||||
-- (see SpellMgr.h). Anything else, including 8, never matches a real
|
||||
-- proc event, so the proc engine silently dropped every event before it
|
||||
-- reached the AuraScript. The original stock row uses 1 (CAST), which
|
||||
-- is what fires when the cast packet's setup phase completes -- exactly
|
||||
-- when we want the spellmod-affected cast to consume the buff.
|
||||
--
|
||||
-- * AttributesMask was set to 0. The original stock row uses 8
|
||||
-- PROC_ATTR_REQ_SPELLMOD = 0x8
|
||||
-- which says "only proc on spells that were affected by one of this
|
||||
-- aura's spellmods". This is the bridge between IsAffectedBySpellMod
|
||||
-- (which records the aura into Spell::m_appliedMods when calculating
|
||||
-- cast time / cost) and the proc system (which then knows that the cast
|
||||
-- used the buff and should consume a charge). Without this attribute,
|
||||
-- the proc would either fire too aggressively or not at all depending
|
||||
-- on subsequent gating, but in practice the engine relies on it to
|
||||
-- correlate spellmod use with stack consumption.
|
||||
--
|
||||
-- Fixed row keeps the same family/mask wildcards from the previous
|
||||
-- migration (so the Paragon Mage allowlist in spell_sha_maelstrom_weapon's
|
||||
-- CheckProc still gets the chance to filter), restores SpellPhaseMask=1
|
||||
-- (CAST) and AttributesMask=8 (REQ_SPELLMOD) to match stock semantics, and
|
||||
-- resets SpellTypeMask to 0 (any spell type -- the REQ_SPELLMOD attribute
|
||||
-- already gates by "was the buff actually used", so an extra DAMAGE filter
|
||||
-- is redundant and would block e.g. Lesser Healing Wave on stock Shamans).
|
||||
--
|
||||
-- This restores stock Shaman behavior byte-identically and lets Paragon
|
||||
-- Mage casts also consume stacks via the IsAffectedBySpellMod allowlist.
|
||||
|
||||
DELETE FROM `spell_proc` WHERE `SpellId` = 53817;
|
||||
|
||||
INSERT INTO `spell_proc`
|
||||
(`SpellId`, `SchoolMask`, `SpellFamilyName`,
|
||||
`SpellFamilyMask0`, `SpellFamilyMask1`, `SpellFamilyMask2`,
|
||||
`ProcFlags`, `SpellTypeMask`, `SpellPhaseMask`, `HitMask`,
|
||||
`AttributesMask`, `DisableEffectsMask`, `ProcsPerMinute`,
|
||||
`Chance`, `Cooldown`, `Charges`)
|
||||
VALUES
|
||||
(53817, 0, 0, 0, 0, 0, 0, 0, 1, 0, 8, 0, 0, 0, 0, 0);
|
||||
@@ -514,8 +514,136 @@ class spell_paragon_arcane_torrent : public SpellScript
|
||||
}
|
||||
};
|
||||
|
||||
// Predatory Strikes (16972 / 16974 / 16975) for Paragon: re-implements the
|
||||
// Cataclysm-era proc behavior of the talent so a Paragon's damaging
|
||||
// finishers (Eviscerate / Envenom / Ferocious Bite / Rip / Rupture) can
|
||||
// roll Predator's Swiftness (69369) -- the same buff that real druids
|
||||
// get from the Cata redesign of this talent. Combined with the
|
||||
// Spell::prepare interception in core (Spell.cpp), 69369 makes the
|
||||
// Paragon's NEXT Nature-school spell with a base cast time below 10s
|
||||
// instant cast: Chain Lightning, Lightning Bolt, Healing Touch, Wrath,
|
||||
// Nourish, etc. -- not just the Druid-family Nature subset that the
|
||||
// stock SPELLMOD_CASTING_TIME mask on 69369 covers.
|
||||
//
|
||||
// Filter logic:
|
||||
// - Source spell must consume combo points (NeedsComboPoints() — gates
|
||||
// out non-finisher combo-point builders).
|
||||
// - "Damaging finisher": SPELL_ATTR1_FINISHING_MOVE_DAMAGE (Eviscerate,
|
||||
// Envenom, Ferocious Bite, ...) OR a SPELL_ATTR1_FINISHING_MOVE_DURATION
|
||||
// finisher that applies periodic damage (Rip, Rupture). Duration
|
||||
// finishers that only heal (Recuperate) or only buff / CC / armor shred
|
||||
// (Slice and Dice, Savage Roar, Kidney Shot, Maim, Expose Armor) are
|
||||
// rejected.
|
||||
//
|
||||
// Chance per combo point matches the Cataclysm tuning that the user's
|
||||
// client tooltip text reflects: rank 1 = 3% per CP, rank 2 = 5% per CP,
|
||||
// rank 3 = 7% per CP. At 5 CP that is 15% / 25% / 35%, capped at 100%.
|
||||
//
|
||||
// Combo-point read happens during PROC_SPELL_PHASE_CAST, which fires in
|
||||
// Spell::cast → Spell::ProcReflectProcs / Unit::ProcDamageAndSpellFor
|
||||
// BEFORE Spell::_handle_finish_phase clears the player's combo points
|
||||
// (see Spell.cpp:_handle_finish_phase clearing combo points). So
|
||||
// player->GetComboPoints() inside HandleProc returns the pre-clear value.
|
||||
class spell_paragon_predatory_strikes : public AuraScript
|
||||
{
|
||||
PrepareAuraScript(spell_paragon_predatory_strikes);
|
||||
|
||||
static constexpr uint32 SPELL_PARAGON_PREDATORS_SWIFTNESS = 69369;
|
||||
|
||||
bool Validate(SpellInfo const* /*spellInfo*/) override
|
||||
{
|
||||
return ValidateSpellInfo({ SPELL_PARAGON_PREDATORS_SWIFTNESS });
|
||||
}
|
||||
|
||||
bool CheckProc(ProcEventInfo& eventInfo)
|
||||
{
|
||||
SpellInfo const* spellInfo = eventInfo.GetSpellInfo();
|
||||
if (!spellInfo || !spellInfo->NeedsComboPoints())
|
||||
return false;
|
||||
|
||||
if (spellInfo->HasAttribute(SPELL_ATTR1_FINISHING_MOVE_DAMAGE))
|
||||
return true;
|
||||
|
||||
if (spellInfo->HasAttribute(SPELL_ATTR1_FINISHING_MOVE_DURATION))
|
||||
{
|
||||
bool periodicHeal = false;
|
||||
bool periodicDamage = false;
|
||||
for (SpellEffectInfo const& eff : spellInfo->Effects)
|
||||
{
|
||||
if (eff.Effect != SPELL_EFFECT_APPLY_AURA && eff.Effect != SPELL_EFFECT_APPLY_AREA_AURA_PARTY
|
||||
&& eff.Effect != SPELL_EFFECT_PERSISTENT_AREA_AURA)
|
||||
continue;
|
||||
|
||||
switch (eff.ApplyAuraName)
|
||||
{
|
||||
case SPELL_AURA_PERIODIC_HEAL:
|
||||
case SPELL_AURA_PERIODIC_HEALTH_FUNNEL:
|
||||
case SPELL_AURA_OBS_MOD_HEALTH:
|
||||
periodicHeal = true;
|
||||
break;
|
||||
case SPELL_AURA_PERIODIC_DAMAGE:
|
||||
case SPELL_AURA_PERIODIC_DAMAGE_PERCENT:
|
||||
case SPELL_AURA_PERIODIC_LEECH:
|
||||
periodicDamage = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (periodicHeal)
|
||||
return false;
|
||||
|
||||
return periodicDamage;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void HandleProc(ProcEventInfo& eventInfo)
|
||||
{
|
||||
PreventDefaultAction();
|
||||
|
||||
Unit* actor = eventInfo.GetActor();
|
||||
Player* player = actor ? actor->ToPlayer() : nullptr;
|
||||
if (!player || player->getClass() != CLASS_PARAGON)
|
||||
return;
|
||||
|
||||
uint8 const cp = player->GetComboPoints();
|
||||
if (cp == 0)
|
||||
return;
|
||||
|
||||
SpellInfo const* talent = GetSpellInfo();
|
||||
if (!talent)
|
||||
return;
|
||||
|
||||
uint32 pctPerCP = 0;
|
||||
switch (talent->Id)
|
||||
{
|
||||
case 16972: pctPerCP = 3; break;
|
||||
case 16974: pctPerCP = 5; break;
|
||||
case 16975: pctPerCP = 7; break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
uint32 const chance = std::min<uint32>(100u, pctPerCP * uint32(cp));
|
||||
if (!roll_chance_i(int32(chance)))
|
||||
return;
|
||||
|
||||
player->CastSpell(player, SPELL_PARAGON_PREDATORS_SWIFTNESS, true);
|
||||
}
|
||||
|
||||
void Register() override
|
||||
{
|
||||
DoCheckProc += AuraCheckProcFn(spell_paragon_predatory_strikes::CheckProc);
|
||||
OnProc += AuraProcFn(spell_paragon_predatory_strikes::HandleProc);
|
||||
}
|
||||
};
|
||||
|
||||
void AddSC_paragon()
|
||||
{
|
||||
new Paragon_PlayerScript();
|
||||
RegisterSpellScript(spell_paragon_arcane_torrent);
|
||||
RegisterSpellScript(spell_paragon_predatory_strikes);
|
||||
}
|
||||
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start AzerothCore authserver + worldserver detached from the SSH session (nohup + disown).
|
||||
# Stops any already-running authserver/worldserver processes first.
|
||||
#
|
||||
# Usage:
|
||||
# sudo bash scripts/start-azeroth-servers.sh
|
||||
# AZEROTH_BIN=/path/to/azeroth-server/bin bash scripts/start-azeroth-servers.sh
|
||||
#
|
||||
# Environment:
|
||||
# AZEROTH_BIN — directory with authserver and worldserver (default: /root/azeroth-server/bin)
|
||||
# AZEROTH_LOG_DIR — log directory (default: <parent of bin>/logs)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BIN_DIR="${AZEROTH_BIN:-/root/azeroth-server/bin}"
|
||||
LOG_DIR="${AZEROTH_LOG_DIR:-$(cd "$(dirname "$BIN_DIR")" && pwd)/logs}"
|
||||
|
||||
AUTH_BIN="${BIN_DIR}/authserver"
|
||||
WORLD_BIN="${BIN_DIR}/worldserver"
|
||||
|
||||
if [[ ! -x "$AUTH_BIN" ]]; then
|
||||
echo "error: not found or not executable: $AUTH_BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -x "$WORLD_BIN" ]]; then
|
||||
echo "error: not found or not executable: $WORLD_BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pkill -x authserver 2>/dev/null || true
|
||||
pkill -x worldserver 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
cd "$BIN_DIR"
|
||||
|
||||
nohup "$AUTH_BIN" >>"$LOG_DIR/authserver.log" 2>&1 &
|
||||
disown || true
|
||||
|
||||
sleep 2
|
||||
|
||||
nohup "$WORLD_BIN" >>"$LOG_DIR/worldserver.log" 2>&1 &
|
||||
disown || true
|
||||
|
||||
echo "Started authserver and worldserver (survives SSH disconnect)."
|
||||
echo "Bin: $BIN_DIR"
|
||||
echo "Logs: $LOG_DIR/authserver.log"
|
||||
echo " $LOG_DIR/worldserver.log"
|
||||
@@ -4767,6 +4767,36 @@ Respawn.DynamicEscortNPC = 0
|
||||
|
||||
Respawn.ForceCompatibilityMode = 0
|
||||
|
||||
#
|
||||
# Paragon.WildcardFamilyMatching
|
||||
# Description: Fractured / Paragon class (CLASS_PARAGON, id 12) only.
|
||||
# When enabled, the SpellFamilyName equality check is
|
||||
# wildcarded for Paragon characters in proc evaluation
|
||||
# (SpellMgr::CanSpellTriggerProcOnEvent), talent
|
||||
# SpellMod application (Player::ApplySpellMod /
|
||||
# SpellInfo::IsAffectedBySpellMod), and the
|
||||
# ParagonFamilyMatches() helper used by ad-hoc
|
||||
# `switch (SpellFamilyName)` listener gates in
|
||||
# Unit/SpellEffects/SpellAuraEffects code.
|
||||
# This makes cross-class talent procs and modifiers
|
||||
# (e.g. Predator's Swiftness 69369 making Shaman
|
||||
# Chain Lightning instant cast off a Rogue Eviscerate
|
||||
# finisher) apply to Paragon characters even when the
|
||||
# listener was authored for one specific class family.
|
||||
# SpellFamilyFlags / class-mask flag-bit checks still
|
||||
# run, so listener gates that explicitly opt into a
|
||||
# subset of spells via flag bits are still respected.
|
||||
# Stock classes (Warrior / Paladin / etc.) are NEVER
|
||||
# wildcarded; this only affects players whose class
|
||||
# id is CLASS_PARAGON. Set to 0 to disable the
|
||||
# wildcard at runtime (no rebuild required) if a
|
||||
# regression appears.
|
||||
# Default: 1 - (Enabled, Paragon characters get cross-class procs/mods)
|
||||
# 0 - (Disabled, Paragon characters are gated by stock family equality)
|
||||
#
|
||||
|
||||
Paragon.WildcardFamilyMatching = 1
|
||||
|
||||
#
|
||||
###################################################################################################
|
||||
|
||||
|
||||
@@ -7208,7 +7208,7 @@ void Player::ApplyEquipSpell(SpellInfo const* spellInfo, Item* item, bool apply,
|
||||
return;
|
||||
|
||||
// Cannot be used in this stance/form
|
||||
if (spellInfo->CheckShapeshift(GetShapeshiftForm()) != SPELL_CAST_OK)
|
||||
if (spellInfo->CheckShapeshift(GetShapeshiftForm(), this) != SPELL_CAST_OK)
|
||||
return;
|
||||
|
||||
if (form_change) // check aura active state from other form
|
||||
@@ -7228,7 +7228,7 @@ void Player::ApplyEquipSpell(SpellInfo const* spellInfo, Item* item, bool apply,
|
||||
if (form_change) // check aura compatibility
|
||||
{
|
||||
// Cannot be used in this stance/form
|
||||
if (spellInfo->CheckShapeshift(GetShapeshiftForm()) == SPELL_CAST_OK)
|
||||
if (spellInfo->CheckShapeshift(GetShapeshiftForm(), this) == SPELL_CAST_OK)
|
||||
return; // and remove only not compatible at form change
|
||||
}
|
||||
|
||||
@@ -9773,7 +9773,11 @@ bool Player::IsAffectedBySpellmod(SpellInfo const* spellInfo, SpellModifier* mod
|
||||
if (mod->op == SPELLMOD_DURATION && spellInfo->GetDuration() == -1)
|
||||
return false;
|
||||
|
||||
return spellInfo->IsAffectedBySpellMod(mod);
|
||||
// Fractured / Paragon: pass the player owning the modifier aura so the
|
||||
// SpellFamilyName equality check can be wildcarded for CLASS_PARAGON.
|
||||
// Stock classes hit the same code path with `this` as a non-Paragon
|
||||
// unit, which makes IsAffected behave identically to the 2-arg form.
|
||||
return spellInfo->IsAffectedBySpellMod(mod, this);
|
||||
}
|
||||
|
||||
template <class T>
|
||||
|
||||
@@ -72,11 +72,37 @@
|
||||
#include "Util.h"
|
||||
#include "Vehicle.h"
|
||||
#include "World.h"
|
||||
#include "WorldConfig.h"
|
||||
#include "WorldPacket.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
// Fractured / Paragon: single source of truth for the runtime "is this
|
||||
// caller eligible for the cross-class wildcard?" question. Centralizing
|
||||
// here keeps every dependent behavior (family-name skip in
|
||||
// SpellInfo::IsAffected, PERIODIC_LEECH disease counting in
|
||||
// GetDiseasesByCaster, instant-cast intercept in Spell::prepare for
|
||||
// Predator's / Nature's Swiftness, Vampiric Embrace CheckProc cross-family
|
||||
// path, etc.) flipping in lockstep when the config flag is toggled.
|
||||
bool IsParagonWildcardCaller(Unit const* listener)
|
||||
{
|
||||
return listener && listener->getClass() == CLASS_PARAGON
|
||||
&& sWorld->getBoolConfig(CONFIG_PARAGON_WILDCARD_FAMILY);
|
||||
}
|
||||
|
||||
// Fractured / Paragon: cross-class wildcard helper used by ad-hoc
|
||||
// `switch (SpellFamilyName)` listener gates in Unit / SpellEffects /
|
||||
// SpellAuraEffects. Returns true when the listener is a CLASS_PARAGON
|
||||
// player and the wildcard config flag is enabled, otherwise falls back
|
||||
// to strict family-name equality.
|
||||
bool ParagonFamilyMatches(Unit const* listener, uint32 expectedFamily, uint32 actualFamily)
|
||||
{
|
||||
if (IsParagonWildcardCaller(listener))
|
||||
return true;
|
||||
return expectedFamily == actualFamily;
|
||||
}
|
||||
|
||||
float baseMoveSpeed[MAX_MOVE_TYPE] =
|
||||
{
|
||||
2.5f, // MOVE_WALK
|
||||
@@ -6122,17 +6148,40 @@ AuraEffect* Unit::IsScriptOverriden(SpellInfo const* spell, int32 script) const
|
||||
|
||||
uint32 Unit::GetDiseasesByCaster(ObjectGuid casterGUID, uint8 mode)
|
||||
{
|
||||
static const AuraType diseaseAuraTypes[] =
|
||||
ObjectGuid drwGUID;
|
||||
|
||||
// Fractured / Paragon: when the caller (the unit whose strike is
|
||||
// counting diseases -- e.g. Death Strike heal, Blood Strike / Heart
|
||||
// Strike / Obliterate per-disease damage, Glyph of Scourge Strike
|
||||
// refresh) is a CLASS_PARAGON player AND Paragon.WildcardFamilyMatching
|
||||
// is on, also walk SPELL_AURA_PERIODIC_LEECH. That picks up Priest
|
||||
// Devouring Plague, which uses ApplyAuraName 53 (PERIODIC_LEECH) instead
|
||||
// of 3 (PERIODIC_DAMAGE) and is therefore invisible to the stock loop
|
||||
// even though its Dispel field is DISPEL_DISEASE. A full Spell.dbc scan
|
||||
// confirms Devouring Plague is the ONLY entry that satisfies both
|
||||
// `Dispel == DISPEL_DISEASE` and a leech periodic effect, so this does
|
||||
// not accidentally drag any other spell into the disease pool. Stock
|
||||
// (non-Paragon) callers fall through to the original 2-entry iteration
|
||||
// and observe identical behavior.
|
||||
bool paragonWildcardLeech = false;
|
||||
if (Player* playerCaster = ObjectAccessor::GetPlayer(*this, casterGUID))
|
||||
{
|
||||
drwGUID = playerCaster->getRuneWeaponGUID();
|
||||
paragonWildcardLeech = IsParagonWildcardCaller(playerCaster);
|
||||
}
|
||||
|
||||
AuraType diseaseAuraTypes[4] =
|
||||
{
|
||||
SPELL_AURA_PERIODIC_DAMAGE, // Frost Fever and Blood Plague
|
||||
SPELL_AURA_LINKED, // Crypt Fever and Ebon Plague
|
||||
SPELL_AURA_NONE,
|
||||
SPELL_AURA_NONE
|
||||
};
|
||||
|
||||
ObjectGuid drwGUID;
|
||||
|
||||
if (Player* playerCaster = ObjectAccessor::GetPlayer(*this, casterGUID))
|
||||
drwGUID = playerCaster->getRuneWeaponGUID();
|
||||
if (paragonWildcardLeech)
|
||||
{
|
||||
diseaseAuraTypes[2] = SPELL_AURA_PERIODIC_LEECH; // Priest Devouring Plague (Paragon-only)
|
||||
diseaseAuraTypes[3] = SPELL_AURA_NONE;
|
||||
}
|
||||
|
||||
uint32 diseases = 0;
|
||||
for (uint8 index = 0; diseaseAuraTypes[index] != SPELL_AURA_NONE; ++index)
|
||||
@@ -9702,7 +9751,7 @@ uint32 Unit::SpellHealingBonusTaken(Unit* caster, SpellInfo const* spellProto, u
|
||||
|
||||
// Nourish cast - 20% bonus if target has Rejuvenation, Regrowth, Lifebloom, or Wild Growth from caster
|
||||
// Glyph of Nourish is handled by spell_dru_nourish script
|
||||
if (spellProto->SpellFamilyName == SPELLFAMILY_DRUID && spellProto->SpellFamilyFlags[1] & 0x2000000 && caster)
|
||||
if (ParagonFamilyMatches(caster, SPELLFAMILY_DRUID, spellProto->SpellFamilyName) && spellProto->SpellFamilyFlags[1] & 0x2000000 && caster)
|
||||
{
|
||||
AuraEffectList const& auras = GetAuraEffectsByType(SPELL_AURA_PERIODIC_HEAL);
|
||||
for (AuraEffectList::const_iterator i = auras.begin(); i != auras.end(); ++i)
|
||||
@@ -10421,7 +10470,7 @@ uint32 Unit::MeleeDamageBonusTaken(Unit* attacker, uint32 pdamage, WeaponAttackT
|
||||
uint64 mechanicMask = spellProto->GetAllEffectsMechanicMask();
|
||||
|
||||
// Shred, Maul - "Effects which increase Bleed damage also increase Shred damage"
|
||||
if (spellProto->SpellFamilyName == SPELLFAMILY_DRUID && spellProto->SpellFamilyFlags[0] & 0x00008800)
|
||||
if (ParagonFamilyMatches(attacker, SPELLFAMILY_DRUID, spellProto->SpellFamilyName) && spellProto->SpellFamilyFlags[0] & 0x00008800)
|
||||
mechanicMask |= (1ULL << MECHANIC_BLEED);
|
||||
|
||||
if (mechanicMask)
|
||||
|
||||
@@ -2268,6 +2268,25 @@ private:
|
||||
ValuesUpdateCache _valuesUpdateCache;
|
||||
};
|
||||
|
||||
// Fractured / Paragon: returns true iff `listener` is a CLASS_PARAGON player
|
||||
// AND `Paragon.WildcardFamilyMatching` is enabled. Single source of truth for
|
||||
// the gate that controls every cross-class wildcard path (family-name skip in
|
||||
// SpellInfo::IsAffected, leech-aura disease counting in
|
||||
// Unit::GetDiseasesByCaster, the cross-school instant-cast intercept in
|
||||
// Spell::prepare for Predator's / Nature's Swiftness, the Vampiric Embrace
|
||||
// CheckProc cross-family path, etc.). Centralizing the check means runtime
|
||||
// kill-switching the wildcard config flips every behavior together.
|
||||
[[nodiscard]] bool IsParagonWildcardCaller(Unit const* listener);
|
||||
|
||||
// Fractured / Paragon: helper for ad-hoc `switch (SpellFamilyName)` listener
|
||||
// gates scattered across Unit / SpellEffects / SpellAuraEffects. When the
|
||||
// listener (i.e. the unit holding the gating talent / aura) is a Paragon
|
||||
// AND `Paragon.WildcardFamilyMatching` is enabled, accept any source family
|
||||
// so cross-class procs / bonuses can fire. Stock classes use stock equality.
|
||||
// Defined inline here so call sites do not need an extra include for World.h
|
||||
// beyond what they already include via Unit.h's transitive headers.
|
||||
[[nodiscard]] bool ParagonFamilyMatches(Unit const* listener, uint32 expectedFamily, uint32 actualFamily);
|
||||
|
||||
namespace Acore
|
||||
{
|
||||
// Binary predicate for sorting Units based on percent value of a power
|
||||
|
||||
@@ -1630,7 +1630,7 @@ void AuraEffect::HandleShapeshiftBoosts(Unit* target, bool apply) const
|
||||
|
||||
// Xinef: Remove autoattack spells
|
||||
if (Spell* spell = target->GetCurrentSpell(CURRENT_MELEE_SPELL))
|
||||
if (spell->GetSpellInfo()->CheckShapeshift(newAura ? newAura->GetMiscValue() : 0) != SPELL_CAST_OK)
|
||||
if (spell->GetSpellInfo()->CheckShapeshift(newAura ? newAura->GetMiscValue() : 0, target) != SPELL_CAST_OK)
|
||||
spell->cancel(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2175,7 +2175,9 @@ uint8 Aura::GetProcEffectMask(AuraApplication* aurApp, ProcEventInfo& eventInfo,
|
||||
return 0;
|
||||
|
||||
// do checks against db data
|
||||
if (!sSpellMgr->CanSpellTriggerProcOnEvent(*procEntry, eventInfo))
|
||||
// Fractured / Paragon: the unit that owns this aura is the listener;
|
||||
// pass it through so cross-family procs can match for Paragon players.
|
||||
if (!sSpellMgr->CanSpellTriggerProcOnEvent(*procEntry, eventInfo, aurApp->GetTarget()))
|
||||
return 0;
|
||||
|
||||
// check if spell was affected by this aura's spellmod (used by Arcane Potency and similar effects)
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
#include "Vehicle.h"
|
||||
#include "World.h"
|
||||
#include "WorldPacket.h"
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
/// @todo: this import is not necessary for compilation and marked as unused by the IDE
|
||||
@@ -3540,6 +3541,52 @@ SpellCastResult Spell::prepare(SpellCastTargets const* targets, AuraEffect const
|
||||
if (m_caster->ToPlayer()->GetCommandStatus(CHEAT_CASTTIME))
|
||||
m_casttime = 0;
|
||||
|
||||
// Fractured / Paragon: cross-class "next Nature spell becomes instant"
|
||||
// intercept for the three buffs that share that semantic in 3.3.5:
|
||||
//
|
||||
// 69369 - Predator's Swiftness (Cataclysm proc payload triggered by
|
||||
// our spell_paragon_predatory_strikes; see Paragon_SC.cpp)
|
||||
// 17116 - Druid Nature's Swiftness
|
||||
// 16188 - Shaman Nature's Swiftness
|
||||
//
|
||||
// All three apply SPELL_AURA_ADD_PCT_MODIFIER on SPELLMOD_CASTING_TIME
|
||||
// gated by a Druid- or Shaman-only SpellClassMask, so a Paragon with the
|
||||
// buff cannot instant-cast a Nature spell from a different family
|
||||
// (e.g. a Druid NS Paragon casting Shaman Chain Lightning, or a Shaman
|
||||
// NS Paragon casting Druid Healing Touch). Tooltip text on all three
|
||||
// promises "next Nature spell with a base cast time below 10 sec becomes
|
||||
// instant"; honor that here for CLASS_PARAGON callers when the wildcard
|
||||
// config is on. The stock SpellMod path is untouched -- real Druids /
|
||||
// Shamans / proc consumers continue to hit the existing class-mask code
|
||||
// path unchanged.
|
||||
if (Player* paragonCaster = m_caster->ToPlayer())
|
||||
{
|
||||
if (m_casttime > 0
|
||||
&& IsParagonWildcardCaller(paragonCaster)
|
||||
&& (m_spellInfo->SchoolMask & SPELL_SCHOOL_MASK_NATURE)
|
||||
&& m_spellInfo->CastTimeEntry
|
||||
&& !m_spellInfo->IsChanneled()
|
||||
&& !HasTriggeredCastFlag(TRIGGERED_FULL_MASK)
|
||||
&& m_spellInfo->CalcCastTime() < 10 * IN_MILLISECONDS)
|
||||
{
|
||||
static constexpr std::array<uint32, 3> kParagonNatureInstantBuffs =
|
||||
{
|
||||
69369u, // Predator's Swiftness (Paragon proc payload)
|
||||
17116u, // Druid Nature's Swiftness
|
||||
16188u // Shaman Nature's Swiftness
|
||||
};
|
||||
for (uint32 buffId : kParagonNatureInstantBuffs)
|
||||
{
|
||||
if (paragonCaster->HasAura(buffId))
|
||||
{
|
||||
m_casttime = 0;
|
||||
paragonCaster->RemoveAurasDueToSpell(buffId);
|
||||
break; // consume only one buff per cast
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if ((m_spellInfo->IsChanneled() || m_casttime) && m_caster->IsPlayer() && m_caster->isMoving() && m_spellInfo->InterruptFlags & SPELL_INTERRUPT_FLAG_MOVEMENT && !IsTriggered())
|
||||
@@ -5722,7 +5769,7 @@ SpellCastResult Spell::CheckCast(bool strict, uint32* /*param1*/, uint32* /*para
|
||||
if (checkForm)
|
||||
{
|
||||
// Cannot be used in this stance/form
|
||||
SpellCastResult shapeError = m_spellInfo->CheckShapeshift(m_caster->GetShapeshiftForm());
|
||||
SpellCastResult shapeError = m_spellInfo->CheckShapeshift(m_caster->GetShapeshiftForm(), m_caster);
|
||||
if (shapeError != SPELL_CAST_OK)
|
||||
return shapeError;
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
#include "SpellAuraDefines.h"
|
||||
#include "SpellAuraEffects.h"
|
||||
#include "SpellMgr.h"
|
||||
#include "World.h"
|
||||
#include "WorldConfig.h"
|
||||
|
||||
uint32 GetTargetFlagMask(SpellTargetObjectTypes objType)
|
||||
{
|
||||
@@ -1323,11 +1325,26 @@ bool SpellInfo::HasInitialAggro() const
|
||||
}
|
||||
|
||||
bool SpellInfo::IsAffected(uint32 familyName, flag96 const& familyFlags) const
|
||||
{
|
||||
return IsAffected(familyName, familyFlags, nullptr);
|
||||
}
|
||||
|
||||
bool SpellInfo::IsAffected(uint32 familyName, flag96 const& familyFlags,
|
||||
Unit const* listenerOwner) const
|
||||
{
|
||||
if (!familyName)
|
||||
return true;
|
||||
|
||||
if (familyName != SpellFamilyName)
|
||||
// Fractured / Paragon: when the unit that owns the listening proc /
|
||||
// spellmod aura is a Paragon, accept any source family. The class
|
||||
// mask flag-bit check below still runs, so listeners that explicitly
|
||||
// opt into a subset of spells via SpellFamilyFlags / class mask are
|
||||
// still respected; only the family-name equality gate is wildcarded.
|
||||
bool const wildcardFamily = listenerOwner
|
||||
&& listenerOwner->getClass() == CLASS_PARAGON
|
||||
&& sWorld->getBoolConfig(CONFIG_PARAGON_WILDCARD_FAMILY);
|
||||
|
||||
if (!wildcardFamily && familyName != SpellFamilyName)
|
||||
return false;
|
||||
|
||||
if (familyFlags && !(familyFlags & SpellFamilyFlags))
|
||||
@@ -1342,6 +1359,11 @@ bool SpellInfo::IsAffectedBySpellMods() const
|
||||
}
|
||||
|
||||
bool SpellInfo::IsAffectedBySpellMod(SpellModifier const* mod) const
|
||||
{
|
||||
return IsAffectedBySpellMod(mod, nullptr);
|
||||
}
|
||||
|
||||
bool SpellInfo::IsAffectedBySpellMod(SpellModifier const* mod, Unit const* listenerOwner) const
|
||||
{
|
||||
// xinef: dont check duration mod
|
||||
if (mod->op != SPELLMOD_DURATION)
|
||||
@@ -1356,7 +1378,42 @@ bool SpellInfo::IsAffectedBySpellMod(SpellModifier const* mod) const
|
||||
if (!sScriptMgr->OnIsAffectedBySpellModCheck(affectSpell, this, mod))
|
||||
return true;
|
||||
|
||||
return IsAffected(affectSpell->SpellFamilyName, mod->mask);
|
||||
if (IsAffected(affectSpell->SpellFamilyName, mod->mask, listenerOwner))
|
||||
return true;
|
||||
|
||||
// Fractured / Paragon: explicit cross-family allowlist for specific
|
||||
// listener auras whose SpellClassMask cannot otherwise bridge classes.
|
||||
// The standard IsAffected wildcard relaxes SpellFamilyName equality but
|
||||
// still requires SpellClassMask & SpellFamilyFlags to overlap; for these
|
||||
// Paragon-only cross-class enablers the source spells live in different
|
||||
// families with non-overlapping class bits, so we whitelist by mod owner
|
||||
// spell ID + target spell first-rank ID. Stock classes never enter here
|
||||
// because IsParagonWildcardCaller short-circuits on non-Paragon owners.
|
||||
if (IsParagonWildcardCaller(listenerOwner))
|
||||
{
|
||||
switch (mod->spellId)
|
||||
{
|
||||
case 53817: // Shaman: Maelstrom Weapon
|
||||
{
|
||||
// Allow any rank of Mage Fireball / Frostbolt / Arcane Blast to
|
||||
// benefit from the cast-time + cost reduction spellmod.
|
||||
if (SpellFamilyName == SPELLFAMILY_MAGE)
|
||||
{
|
||||
SpellInfo const* first = GetFirstRankSpell();
|
||||
uint32 firstId = first ? first->Id : Id;
|
||||
if (firstId == 133 /*Fireball*/
|
||||
|| firstId == 116 /*Frostbolt*/
|
||||
|| firstId == 30451 /*Arcane Blast*/)
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool SpellInfo::CanPierceImmuneAura(SpellInfo const* auraSpellInfo) const
|
||||
@@ -1441,7 +1498,7 @@ bool SpellInfo::IsAuraExclusiveBySpecificPerCasterWith(SpellInfo const* spellInf
|
||||
}
|
||||
}
|
||||
|
||||
SpellCastResult SpellInfo::CheckShapeshift(uint32 form) const
|
||||
SpellCastResult SpellInfo::CheckShapeshift(uint32 form, Unit const* caster /*= nullptr*/) const
|
||||
{
|
||||
// talents that learn spells can have stance requirements that need ignore
|
||||
// (this requirement only for client-side stance show in talent description)
|
||||
@@ -1449,6 +1506,38 @@ SpellCastResult SpellInfo::CheckShapeshift(uint32 form) const
|
||||
(Effects[0].Effect == SPELL_EFFECT_LEARN_SPELL || Effects[1].Effect == SPELL_EFFECT_LEARN_SPELL || Effects[2].Effect == SPELL_EFFECT_LEARN_SPELL))
|
||||
return SPELL_CAST_OK;
|
||||
|
||||
// Fractured / Paragon: Paragons learn Warrior abilities through Advancement
|
||||
// without picking up Battle/Defensive/Berserker Stance, so stance-gated
|
||||
// Warrior spells (e.g. Whirlwind, Sunder Armor, Shield Slam) would otherwise
|
||||
// be uncastable. Bypass the stance check for Paragon casters on any spell
|
||||
// that has a non-zero Stances bitmask, regardless of SpellFamilyName.
|
||||
//
|
||||
// We previously gated this on SpellFamilyName == SPELLFAMILY_WARRIOR, but a
|
||||
// number of SPELLFAMILY_GENERIC spells (notably the iconic Warrior toolbox
|
||||
// -- Berserker Rage 18499, Sunder Armor 7405 / 11596 / 11597 / 25225 /
|
||||
// 47467, Charge 100 / 6178 / 11578, Pummel 6552 / 6554, Shield Bash 72 /
|
||||
// 1671 / 1672 / 29704, Retaliation 20230, Recklessness 1719, Shield Wall
|
||||
// 871, etc.) carry the Stances bitmask but live under SPELLFAMILY_GENERIC
|
||||
// (family 0). The previous narrower gate let those re-trigger the stance
|
||||
// failure for Paragons. Widening to "any non-zero Stances + Paragon" is
|
||||
// safe because:
|
||||
//
|
||||
// * The bypass returns SPELL_CAST_OK only when IsParagonWildcardCaller
|
||||
// is true -- stock classes never enter this branch.
|
||||
// * Druid form-gated spells (Cat Form / Bear Form / Moonkin / Tree)
|
||||
// still fire the Druid GCD/form rules elsewhere; CheckShapeshift is
|
||||
// about *requiring* a form to cast, which is exactly what we want
|
||||
// to bypass for Paragons (they never picked the form).
|
||||
// * Item enchant scrolls and other shapeshift-marked utility spells
|
||||
// remain unaffected because they aren't in a Paragon's spellbook.
|
||||
if (Stances != 0 && IsParagonWildcardCaller(caster))
|
||||
{
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] CheckShapeshift bypass: spell={} family={} stances=0x{:x} form={}",
|
||||
Id, SpellFamilyName, Stances, form);
|
||||
return SPELL_CAST_OK;
|
||||
}
|
||||
|
||||
uint32 stanceMask = (form ? 1 << (form - 1) : 0);
|
||||
|
||||
if (stanceMask & StancesNot) // can explicitly not be casted in this stance
|
||||
|
||||
@@ -494,9 +494,21 @@ public:
|
||||
bool HasInitialAggro() const;
|
||||
|
||||
[[nodiscard]] bool IsAffected(uint32 familyName, flag96 const& familyFlags) const;
|
||||
// Fractured / Paragon overload. When `listenerOwner` is a CLASS_PARAGON
|
||||
// unit and Paragon.WildcardFamilyMatching is enabled, the
|
||||
// SpellFamilyName equality check is skipped (flag-bit check still runs)
|
||||
// so cross-class procs / spellmods can react to the spell. Passing
|
||||
// nullptr (or any non-Paragon unit) reproduces the stock 2-arg
|
||||
// behavior; the 2-arg form forwards to this overload with nullptr.
|
||||
[[nodiscard]] bool IsAffected(uint32 familyName, flag96 const& familyFlags,
|
||||
Unit const* listenerOwner) const;
|
||||
|
||||
bool IsAffectedBySpellMods() const;
|
||||
bool IsAffectedBySpellMod(SpellModifier const* mod) const;
|
||||
// Fractured / Paragon overload: pass the player who owns the modifier
|
||||
// aura so wildcard-family matching can apply when that player is a
|
||||
// Paragon. Stock callers may forward to this with nullptr.
|
||||
bool IsAffectedBySpellMod(SpellModifier const* mod, Unit const* listenerOwner) const;
|
||||
|
||||
bool CanPierceImmuneAura(SpellInfo const* auraSpellInfo) const;
|
||||
bool CanDispelAura(SpellInfo const* auraSpellInfo) const;
|
||||
@@ -509,7 +521,7 @@ public:
|
||||
bool IsAuraExclusiveBySpecificWith(SpellInfo const* spellInfo) const;
|
||||
bool IsAuraExclusiveBySpecificPerCasterWith(SpellInfo const* spellInfo) const;
|
||||
|
||||
SpellCastResult CheckShapeshift(uint32 form) const;
|
||||
SpellCastResult CheckShapeshift(uint32 form, Unit const* caster = nullptr) const;
|
||||
SpellCastResult CheckLocation(uint32 map_id, uint32 zone_id, uint32 area_id, Player* player = nullptr, bool strict = true) const;
|
||||
SpellCastResult CheckTarget(Unit const* caster, WorldObject const* target, bool implicit = true) const;
|
||||
SpellCastResult CheckExplicitTarget(Unit const* caster, WorldObject const* target, Item const* itemTarget = nullptr) const;
|
||||
|
||||
@@ -842,7 +842,8 @@ SpellProcEntry const* SpellMgr::GetSpellProcEntry(uint32 spellId) const
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool SpellMgr::CanSpellTriggerProcOnEvent(SpellProcEntry const& procEntry, ProcEventInfo& eventInfo) const
|
||||
bool SpellMgr::CanSpellTriggerProcOnEvent(SpellProcEntry const& procEntry, ProcEventInfo& eventInfo,
|
||||
Unit const* procOwner /*= nullptr*/) const
|
||||
{
|
||||
// proc type doesn't match
|
||||
if (!(eventInfo.GetTypeMask() & procEntry.ProcFlags))
|
||||
@@ -873,7 +874,10 @@ bool SpellMgr::CanSpellTriggerProcOnEvent(SpellProcEntry const& procEntry, ProcE
|
||||
// check spell family name/flags (if set) for spells
|
||||
if (eventInfo.GetTypeMask() & SPELL_PROC_FLAG_MASK)
|
||||
if (SpellInfo const* eventSpellInfo = eventInfo.GetSpellInfo())
|
||||
if (!eventSpellInfo->IsAffected(procEntry.SpellFamilyName, procEntry.SpellFamilyMask))
|
||||
// Fractured / Paragon: thread the proc-aura owner so a Paragon
|
||||
// listener accepts cross-family source spells. See
|
||||
// SpellInfo::IsAffected(family, flags, listenerOwner).
|
||||
if (!eventSpellInfo->IsAffected(procEntry.SpellFamilyName, procEntry.SpellFamilyMask, procOwner))
|
||||
return false;
|
||||
|
||||
// check spell type mask (if set)
|
||||
|
||||
@@ -699,7 +699,12 @@ public:
|
||||
|
||||
// Spell proc table
|
||||
[[nodiscard]] SpellProcEntry const* GetSpellProcEntry(uint32 spellId) const;
|
||||
bool CanSpellTriggerProcOnEvent(SpellProcEntry const& procEntry, ProcEventInfo& eventInfo) const;
|
||||
// Fractured / Paragon: `procOwner` is the unit that holds the listening
|
||||
// proc aura. Passing it lets SpellInfo::IsAffected wildcard the family
|
||||
// check when the listener is on a CLASS_PARAGON player. Non-Paragon
|
||||
// owners (or nullptr) reproduce stock behavior exactly.
|
||||
bool CanSpellTriggerProcOnEvent(SpellProcEntry const& procEntry, ProcEventInfo& eventInfo,
|
||||
Unit const* procOwner = nullptr) const;
|
||||
|
||||
// Spell bonus data table
|
||||
[[nodiscard]] SpellBonusEntry const* GetSpellBonusData(uint32 spellId) const;
|
||||
|
||||
@@ -684,4 +684,10 @@ void WorldConfig::BuildConfigCache()
|
||||
|
||||
// Achievement
|
||||
SetConfigValue<uint32>(CONFIG_ACHIEVEMENT_REALM_FIRST_KILL_WINDOW, "Achievement.RealmFirstKillWindow", 60);
|
||||
|
||||
// Fractured / Paragon: cross-class wildcard for SpellFamilyName gating.
|
||||
// Default ON because the Paragon class is designed around it; flip to 0
|
||||
// (no rebuild required) if a regression appears and stock family
|
||||
// gating needs to be restored without backing out the code.
|
||||
SetConfigValue<bool>(CONFIG_PARAGON_WILDCARD_FAMILY, "Paragon.WildcardFamilyMatching", true);
|
||||
}
|
||||
|
||||
@@ -495,6 +495,12 @@ enum ServerConfigs
|
||||
CONFIG_NEW_CHAR_STRING,
|
||||
CONFIG_VALIDATE_SKILL_LEARNED_BY_SPELLS,
|
||||
CONFIG_ACHIEVEMENT_REALM_FIRST_KILL_WINDOW,
|
||||
// Fractured / Paragon: when true, CLASS_PARAGON characters bypass the
|
||||
// SpellFamilyName equality check in proc / spellmod / aura listener
|
||||
// gates so cross-class talent procs and modifiers can interact with
|
||||
// spells learned from other classes (e.g. Predator's Swiftness 69369
|
||||
// making Shaman Chain Lightning instant). Stock classes are unaffected.
|
||||
CONFIG_PARAGON_WILDCARD_FAMILY,
|
||||
|
||||
MAX_NUM_SERVER_CONFIGS
|
||||
};
|
||||
|
||||
@@ -64,10 +64,227 @@ struct npc_pet_mage_mirror_image : CasterAI
|
||||
uint32 dist = urand(1, 5);
|
||||
bool _delayAttack;
|
||||
|
||||
// Fractured / Paragon: when the owner is a Paragon character with the
|
||||
// wildcard config enabled, replace the stock Frostbolt + Fireblast
|
||||
// allowlist (loaded by CombatAI from creature_template_spell for
|
||||
// creature 31216) with a curated list of damaging spells from the
|
||||
// owner's spellbook. UpdateAI's override picks a random spell from
|
||||
// the list per cast so the rotation isn't deterministic.
|
||||
//
|
||||
// The image still casts as itself (not via the owner), so spell
|
||||
// coefficients apply to the image's stats -- spells naturally do less
|
||||
// damage than they would in the owner's hands. We accept that as the
|
||||
// cost of "free cross-class spell variety" rather than try to rebalance
|
||||
// every player spell here.
|
||||
static bool IsDamagingForMirrorImage(SpellInfo const* si)
|
||||
{
|
||||
// Direct damage effect.
|
||||
if (si->HasEffect(SPELL_EFFECT_SCHOOL_DAMAGE))
|
||||
return true;
|
||||
|
||||
// Spells like Arcane Missiles (TRIGGER_MISSILE) and most channeled
|
||||
// multi-tick nukes route their damage through a child spell, so the
|
||||
// parent has no SCHOOL_DAMAGE effect of its own. Accept that here.
|
||||
if (si->HasEffect(SPELL_EFFECT_TRIGGER_MISSILE))
|
||||
return true;
|
||||
|
||||
// DoTs and channels-as-aura (Mind Flay, Curse of Doom, Immolate,
|
||||
// Corruption, Vampiric Touch, Drain Life leech, etc.). Also accept
|
||||
// PERIODIC_TRIGGER_SPELL auras -- that's how Arcane Missiles fires
|
||||
// each individual missile (parent has Aura=23 -> child damaging
|
||||
// spell). Same pattern is used by Hunter Volley, Curse of Doom (in
|
||||
// some ranks), and similar tick-by-trigger spells.
|
||||
for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i)
|
||||
{
|
||||
uint32 aura = si->Effects[i].ApplyAuraName;
|
||||
if (aura == SPELL_AURA_PERIODIC_DAMAGE
|
||||
|| aura == SPELL_AURA_PERIODIC_DAMAGE_PERCENT
|
||||
|| aura == SPELL_AURA_PERIODIC_LEECH
|
||||
|| aura == SPELL_AURA_PERIODIC_TRIGGER_SPELL)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void RebuildSpellsFromOwnerSpellbookForParagon(Player* owner)
|
||||
{
|
||||
SpellVct curated;
|
||||
curated.reserve(8);
|
||||
|
||||
uint32 scanned = 0, kept = 0, rejInactive = 0, rejPassive = 0, rejWeaponStrike = 0,
|
||||
rejNoDmg = 0, rejAoe = 0, rejGate = 0, rejLongCD = 0, rejLowRank = 0;
|
||||
|
||||
// For diagnosis: collect IDs of spells we'd expect to keep (Fireball,
|
||||
// Frostbolt, Lightning Bolt, Mind Blast, Shadow Bolt, etc.) but that
|
||||
// we instead reject. The sample is small so per-spell logging is OK.
|
||||
auto trackProbe = [&](uint32 spellId, char const* phase)
|
||||
{
|
||||
// Only log "interesting" spell IDs to avoid 177-line spam per image.
|
||||
// These are first-rank IDs of common cross-class single-target nukes.
|
||||
static constexpr uint32 probes[] = {
|
||||
133, 116, 30451, // Mage: Fireball, Frostbolt, Arcane Blast
|
||||
5143, // Mage: Arcane Missiles (channel via PERIODIC_TRIGGER_SPELL)
|
||||
403, 529, 8042, // Shaman: Lightning Bolt, Chain Lightning, Earth Shock
|
||||
585, 14914, // Priest: Smite, Holy Fire
|
||||
8092, 15407, // Priest: Mind Blast, Mind Flay
|
||||
686, 348, // Warlock: Shadow Bolt, Immolate (DoT w/ cast time)
|
||||
5176, 2912, // Druid: Wrath, Starfire
|
||||
635, // Paladin: Holy Light
|
||||
};
|
||||
for (uint32 probe : probes)
|
||||
{
|
||||
if (spellId == probe)
|
||||
{
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage probe spell={} phase={}",
|
||||
spellId, phase);
|
||||
return;
|
||||
}
|
||||
// Also walk rank chain: if the spellbook has rank N of probe,
|
||||
// probe matches via GetFirstRankSpell.
|
||||
if (SpellInfo const* si = sSpellMgr->GetSpellInfo(spellId))
|
||||
if (SpellInfo const* first = si->GetFirstRankSpell())
|
||||
if (first->Id == probe)
|
||||
{
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage probe spell={} (rank of {}) phase={}",
|
||||
spellId, probe, phase);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (auto const& kv : owner->GetSpellMap())
|
||||
{
|
||||
++scanned;
|
||||
uint32 spellId = kv.first;
|
||||
PlayerSpell const* ps = kv.second;
|
||||
if (!ps || ps->State == PLAYERSPELL_REMOVED || !ps->Active)
|
||||
{
|
||||
++rejInactive;
|
||||
trackProbe(spellId, "inactive");
|
||||
continue;
|
||||
}
|
||||
|
||||
SpellInfo const* spellInfo = sSpellMgr->GetSpellInfo(spellId);
|
||||
if (!spellInfo)
|
||||
continue;
|
||||
|
||||
// Spec (per user): damaging single-target spells, instant or
|
||||
// cast-time or channeled all OK, no melee/ranged "strike" style
|
||||
// weapon-attack abilities, and no long-cooldown spells (>10s) so
|
||||
// the image cycles through a varied rotation rather than blowing
|
||||
// a 2-min cooldown once.
|
||||
if (spellInfo->IsPassive()) { ++rejPassive; trackProbe(spellId, "passive"); continue; }
|
||||
if (!IsDamagingForMirrorImage(spellInfo)) { ++rejNoDmg; trackProbe(spellId, "noDmg"); continue; }
|
||||
if (spellInfo->IsAffectingArea()) { ++rejAoe; trackProbe(spellId, "aoe"); continue; }
|
||||
if (spellInfo->DmgClass == SPELL_DAMAGE_CLASS_MELEE
|
||||
|| spellInfo->DmgClass == SPELL_DAMAGE_CLASS_RANGED) { ++rejWeaponStrike; trackProbe(spellId, "weaponStrike"); continue; }
|
||||
// Reject anything with a base cooldown longer than 10s (either
|
||||
// RecoveryTime or CategoryRecoveryTime). A 0/very-short CD is
|
||||
// fine. The mage Mirror Image only lives for 30s, so anything
|
||||
// gated by a long CD would only ever fire once anyway.
|
||||
uint32 cd = std::max(spellInfo->RecoveryTime, spellInfo->CategoryRecoveryTime);
|
||||
if (cd > 10000) { ++rejLongCD; trackProbe(spellId, "longCD"); continue; }
|
||||
|
||||
// Skip spells the image would never realistically be able to
|
||||
// cast successfully or whose side-effects don't make sense on a
|
||||
// pet (totems, summons, item / reagent / focus requirements,
|
||||
// ranged-weapon / shapeshift / stealth gates, profession spells,
|
||||
// teleports, etc.).
|
||||
char const* gateReason = nullptr;
|
||||
if (spellInfo->RequiresSpellFocus) gateReason = "focus";
|
||||
else if (spellInfo->Reagent[0] > 0) gateReason = "reagent";
|
||||
else if (spellInfo->Stances || spellInfo->StancesNot) gateReason = "stance";
|
||||
else if (spellInfo->EquippedItemClass >= 0) gateReason = "equipped";
|
||||
else if (spellInfo->IsCooldownStartedOnEvent()) gateReason = "cdEvent";
|
||||
else if (spellInfo->HasAttribute(SPELL_ATTR0_PASSIVE)) gateReason = "attrPassive";
|
||||
else if (spellInfo->HasAttribute(SPELL_ATTR0_DO_NOT_DISPLAY)) gateReason = "attrHidden";
|
||||
// SPELL_ATTR0_NOT_SHAPESHIFTED is intentionally NOT a gate -- it
|
||||
// means "cannot be cast while caster IS shapeshifted", not "this
|
||||
// spell requires a shapeshift". The attribute is set on every
|
||||
// standard caster nuke (Fireball, Frostbolt, Lightning Bolt,
|
||||
// Shadow Bolt, etc.) and Mirror Images are never shapeshifted,
|
||||
// so the runtime check trivially passes for them. Filtering on
|
||||
// it here was the bug that left the curated list empty.
|
||||
else if (spellInfo->HasAttribute(SPELL_ATTR0_ONLY_STEALTHED)) gateReason = "attrStealth";
|
||||
// SPELL_ATTR1_NO_AUTOCAST_AI is intentionally NOT a gate -- it is set
|
||||
// on most player nukes (Fireball / Lightning Bolt / Shadow Bolt) to
|
||||
// stop class pets from auto-casting them. Mirror Images are
|
||||
// server-curated player-spell mimics, so we WANT to auto-cast
|
||||
// those exact spells.
|
||||
else if (spellInfo->HasAttribute(SPELL_ATTR2_FAIL_ON_ALL_TARGETS_IMMUNE)) gateReason = "attrFailImmune";
|
||||
else if (spellInfo->HasEffect(SPELL_EFFECT_SUMMON)) gateReason = "fxSummon";
|
||||
else if (spellInfo->HasEffect(SPELL_EFFECT_SUMMON_PET)) gateReason = "fxSummonPet";
|
||||
else if (spellInfo->HasEffect(SPELL_EFFECT_TELEPORT_UNITS)) gateReason = "fxTeleport";
|
||||
else if (spellInfo->HasEffect(SPELL_EFFECT_TRANS_DOOR)) gateReason = "fxTransDoor";
|
||||
else if (spellInfo->HasEffect(SPELL_EFFECT_OPEN_LOCK)) gateReason = "fxOpenLock";
|
||||
else if (spellInfo->HasEffect(SPELL_EFFECT_INSTAKILL)) gateReason = "fxInstakill";
|
||||
else if (spellInfo->HasEffect(SPELL_EFFECT_LEARN_SPELL)) gateReason = "fxLearn";
|
||||
if (gateReason) { ++rejGate; trackProbe(spellId, gateReason); continue; }
|
||||
|
||||
// Ignore spell ranks below the highest the player owns -- the
|
||||
// spellbook contains all learned ranks; we want only the latest.
|
||||
if (SpellInfo const* nextRank = spellInfo->GetNextRankSpell())
|
||||
if (owner->HasSpell(nextRank->Id))
|
||||
{ ++rejLowRank; trackProbe(spellId, "lowRank"); continue; }
|
||||
|
||||
++kept;
|
||||
curated.push_back(spellId);
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage kept spell={} ({})",
|
||||
spellId,
|
||||
spellInfo->SpellName[0] ? spellInfo->SpellName[0] : "?");
|
||||
}
|
||||
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage rebuild owner={} scanned={} kept={} "
|
||||
"rejInactive={} rejPassive={} rejNoDmg={} rejAoe={} rejWeaponStrike={} rejLongCD={} rejGate={} rejLowRank={}",
|
||||
owner->GetName(), scanned, kept,
|
||||
rejInactive, rejPassive, rejNoDmg, rejAoe, rejWeaponStrike, rejLongCD, rejGate, rejLowRank);
|
||||
|
||||
if (curated.empty())
|
||||
{
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage rebuild for {} produced empty list, keeping stock 59637/59638",
|
||||
owner->GetName());
|
||||
return; // keep stock 59637 / 59638 fallback
|
||||
}
|
||||
|
||||
// Log the first few spell IDs we picked so we can verify the list.
|
||||
std::string sample;
|
||||
for (size_t i = 0; i < curated.size() && i < 8; ++i)
|
||||
{
|
||||
if (!sample.empty())
|
||||
sample += ',';
|
||||
sample += std::to_string(curated[i]);
|
||||
}
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage rebuild swapping spells for {} (sample: {})",
|
||||
owner->GetName(), sample);
|
||||
|
||||
spells.swap(curated);
|
||||
}
|
||||
|
||||
void InitializeAI() override
|
||||
{
|
||||
CasterAI::InitializeAI();
|
||||
|
||||
// Fractured / Paragon: do the spellbook rebuild EARLY -- before
|
||||
// owner->CastSpell(CLONE_ME) and before any threat-list inheritance,
|
||||
// because any of those can synchronously fire JustEngagedWith on the
|
||||
// image and cause CasterAI::JustEngagedWith to schedule events from
|
||||
// the stock [59638 Frostbolt, 59637 Fireblast] m_spells[] entries
|
||||
// before our swap takes effect. The override of JustEngagedWith
|
||||
// below also reasserts the swap + flushes events, so even if a later
|
||||
// combat-entry path fires JustEngagedWith again it picks up the
|
||||
// curated list.
|
||||
if (Unit* owner = me->GetOwner())
|
||||
if (Player* playerOwner = owner->ToPlayer())
|
||||
if (IsParagonWildcardCaller(playerOwner))
|
||||
RebuildSpellsFromOwnerSpellbookForParagon(playerOwner);
|
||||
|
||||
_delayAttack = true;
|
||||
me->m_Events.AddEventAtOffset([this]()
|
||||
{
|
||||
@@ -76,11 +293,21 @@ struct npc_pet_mage_mirror_image : CasterAI
|
||||
|
||||
Unit* owner = me->GetOwner();
|
||||
if (!owner)
|
||||
{
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage InitializeAI: no owner, spells.size={} (stock)",
|
||||
spells.size());
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone Me!
|
||||
owner->CastSpell(me, SPELL_MAGE_CLONE_ME, true);
|
||||
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage InitializeAI: post-rebuild spells.size={} first={}",
|
||||
spells.size(),
|
||||
spells.empty() ? 0u : spells.front());
|
||||
|
||||
// xinef: Glyph of Mirror Image (4th copy)
|
||||
float angle = 0.0f;
|
||||
switch (me->GetUInt32Value(UNIT_CREATED_BY_SPELL))
|
||||
@@ -139,6 +366,37 @@ struct npc_pet_mage_mirror_image : CasterAI
|
||||
me->m_Events.AddEventAtOffset(new DeathEvent(*me), 29500ms);
|
||||
}
|
||||
|
||||
void JustEngagedWith(Unit* who) override
|
||||
{
|
||||
// Fractured / Paragon: re-apply the spellbook rebuild here as well,
|
||||
// because the engagement can fire synchronously from inside
|
||||
// InitializeAI (via owner->CastSpell(CLONE_ME) or summon-side threat
|
||||
// propagation) BEFORE InitializeAI's own rebuild call has run.
|
||||
// Re-running it here is cheap and idempotent: the curated list is
|
||||
// re-derived from the owner's current spellbook, and we wipe any
|
||||
// previously-scheduled events so the stock 59637 / 59638 entries
|
||||
// CasterAI may already have queued get evicted before scheduling.
|
||||
if (Unit* owner = me->GetOwner())
|
||||
if (Player* playerOwner = owner->ToPlayer())
|
||||
if (IsParagonWildcardCaller(playerOwner))
|
||||
{
|
||||
RebuildSpellsFromOwnerSpellbookForParagon(playerOwner);
|
||||
events.Reset();
|
||||
}
|
||||
|
||||
std::string sample;
|
||||
for (size_t i = 0; i < spells.size() && i < 8; ++i)
|
||||
{
|
||||
if (!sample.empty()) sample += ',';
|
||||
sample += std::to_string(spells[i]);
|
||||
}
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage JustEngagedWith: spells.size={} sample=[{}] who={}",
|
||||
spells.size(), sample, who ? who->GetName() : "<null>");
|
||||
|
||||
CasterAI::JustEngagedWith(who);
|
||||
}
|
||||
|
||||
// Do not reload Creature templates on evade mode enter - prevent visual lost
|
||||
void EnterEvadeMode(EvadeReason /*why*/) override
|
||||
{
|
||||
@@ -217,10 +475,61 @@ struct npc_pet_mage_mirror_image : CasterAI
|
||||
if (me->HasUnitState(UNIT_STATE_CASTING))
|
||||
return;
|
||||
|
||||
if (uint32 spellId = events.ExecuteEvent())
|
||||
if (uint32 queuedId = events.ExecuteEvent())
|
||||
{
|
||||
events.RescheduleEvent(spellId, spellId == 59637 ? 6500ms : 2500ms);
|
||||
me->CastSpell(me->GetVictim(), spellId, false);
|
||||
// Fractured / Paragon: when the curated spellbook list is in
|
||||
// play, pick a random spell from it for THIS cast instead of
|
||||
// using the EventMap-scheduled spellId directly. The events
|
||||
// queue (populated by CasterAI::JustEngagedWith) is otherwise
|
||||
// deterministic for our small list and the image ends up
|
||||
// rotating in lockstep; randomizing here makes each image
|
||||
// (and each cast) feel like a mage ad-libbing from the
|
||||
// player's repertoire.
|
||||
uint32 actualId = queuedId;
|
||||
bool isParagon = false;
|
||||
if (Unit* owner = me->GetOwner())
|
||||
if (Player* playerOwner = owner->ToPlayer())
|
||||
if (IsParagonWildcardCaller(playerOwner) && !spells.empty())
|
||||
{
|
||||
actualId = spells[urand(0, uint32(spells.size()) - 1)];
|
||||
isParagon = true;
|
||||
}
|
||||
|
||||
// Reschedule the queue based on the spell we actually cast,
|
||||
// not the one originally queued. For channeled spells this
|
||||
// matters: Arcane Missiles is a 5s channel, so if we keep
|
||||
// rescheduling every 2.5s the image is always either mid-
|
||||
// channel or immediately re-rolling for another channel,
|
||||
// and over four images you see effectively continuous
|
||||
// Arcane Missiles. Wait for cast/channel to finish + a
|
||||
// small breather before picking again.
|
||||
Milliseconds nextDelay = (queuedId == 59637 ? 6500ms : 2500ms);
|
||||
if (isParagon)
|
||||
{
|
||||
if (SpellInfo const* picked = sSpellMgr->GetSpellInfo(actualId))
|
||||
{
|
||||
uint32 castMs = picked->CalcCastTime();
|
||||
uint32 chanMs = 0;
|
||||
if (picked->IsChanneled())
|
||||
{
|
||||
int32 dur = picked->GetDuration();
|
||||
if (dur > 0)
|
||||
chanMs = uint32(dur);
|
||||
}
|
||||
uint32 minMs = std::max(castMs, chanMs) + 750; // breather
|
||||
if (Milliseconds(minMs) > nextDelay)
|
||||
nextDelay = Milliseconds(minMs);
|
||||
}
|
||||
}
|
||||
events.RescheduleEvent(queuedId, nextDelay);
|
||||
|
||||
SpellCastResult castRes = me->CastSpell(me->GetVictim(), actualId, false);
|
||||
LOG_DEBUG("server.scripts",
|
||||
"[paragon-diag] MirrorImage cast spell={} victim={} result={} nextDelay={}ms",
|
||||
actualId,
|
||||
me->GetVictim() ? me->GetVictim()->GetName() : "<null>",
|
||||
uint32(castRes),
|
||||
uint32(nextDelay.count()));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -490,12 +490,22 @@ class spell_mage_cold_snap : public SpellScript
|
||||
{
|
||||
Player* caster = GetCaster()->ToPlayer();
|
||||
// immediately finishes the cooldown on Frost spells
|
||||
|
||||
//
|
||||
// Fractured / Paragon: ParagonFamilyMatches() drops the
|
||||
// SpellFamilyName == SPELLFAMILY_MAGE gate when the caster is a
|
||||
// CLASS_PARAGON player AND Paragon.WildcardFamilyMatching is on,
|
||||
// so any Frost-school spell in the Paragon's spellbook with a real
|
||||
// recovery time (Howling Blast, Frost Shock, Frost Trap, etc.)
|
||||
// also gets its cooldown wiped. Stock Mage callers fall through to
|
||||
// strict family-name equality and observe identical behavior.
|
||||
PlayerSpellMap const& spellMap = caster->GetSpellMap();
|
||||
for (PlayerSpellMap::const_iterator itr = spellMap.begin(); itr != spellMap.end(); ++itr)
|
||||
{
|
||||
SpellInfo const* spellInfo = sSpellMgr->AssertSpellInfo(itr->first);
|
||||
if (spellInfo->SpellFamilyName == SPELLFAMILY_MAGE && (spellInfo->GetSchoolMask() & SPELL_SCHOOL_MASK_FROST) && spellInfo->Id != SPELL_MAGE_COLD_SNAP && spellInfo->GetRecoveryTime() > 0)
|
||||
if (ParagonFamilyMatches(caster, SPELLFAMILY_MAGE, spellInfo->SpellFamilyName)
|
||||
&& (spellInfo->GetSchoolMask() & SPELL_SCHOOL_MASK_FROST)
|
||||
&& spellInfo->Id != SPELL_MAGE_COLD_SNAP
|
||||
&& spellInfo->GetRecoveryTime() > 0)
|
||||
{
|
||||
SpellCooldowns::iterator citr = caster->GetSpellCooldownMap().find(spellInfo->Id);
|
||||
if (citr != caster->GetSpellCooldownMap().end() && citr->second.needSendToClient)
|
||||
@@ -946,6 +956,107 @@ class spell_mage_summon_water_elemental : public SpellScript
|
||||
}
|
||||
};
|
||||
|
||||
// 44543, 44545 - Fingers of Frost (talent ranks - the proc-trigger aura, NOT the
|
||||
// 74396 buff aura that is APPLIED when this talent fires).
|
||||
//
|
||||
// Stock spell_proc gates this talent by SpellFamilyName=MAGE plus a
|
||||
// SpellFamilyMask covering the Mage Frost spells that count as "chill-effect
|
||||
// dealers" (Frostbolt / Frost Nova / Cone of Cold / Blizzard / Frostfire Bolt /
|
||||
// Deep Freeze etc.). For Paragon characters with `Paragon.WildcardFamilyMatching`
|
||||
// enabled, we relax the spell_proc row to wildcard family/mask + SchoolMask=
|
||||
// FROST + SpellTypeMask=DAMAGE so that any Frost-school damage spell (DK Howling
|
||||
// Blast / Icy Touch, Hunter Frost Trap / Wing Clip-as-frost, Shaman Frost Shock,
|
||||
// Druid Hibernate damage payload, etc.) reaches this CheckProc; this script
|
||||
// then re-enforces the stock Mage allowlist for non-Paragon owners and lets
|
||||
// Paragons through unconditionally (the FROST + DAMAGE gate already happens at
|
||||
// the spell_proc layer, so any spell reaching us here is safe to accept).
|
||||
class spell_mage_fingers_of_frost_talent : public AuraScript
|
||||
{
|
||||
PrepareAuraScript(spell_mage_fingers_of_frost_talent);
|
||||
|
||||
bool CheckProc(ProcEventInfo& eventInfo)
|
||||
{
|
||||
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
|
||||
if (!procSpell)
|
||||
return false;
|
||||
|
||||
// Stock Mage allowlist: re-derive from this talent's own effect-0
|
||||
// SpellClassMask so behavior matches the original auto-generated
|
||||
// proc filter exactly (no risk of mask drift across DBC versions).
|
||||
if (procSpell->SpellFamilyName == SPELLFAMILY_MAGE
|
||||
&& (GetSpellInfo()->Effects[EFFECT_0].SpellClassMask & procSpell->SpellFamilyFlags))
|
||||
return true;
|
||||
|
||||
return IsParagonWildcardCaller(GetUnitOwner());
|
||||
}
|
||||
|
||||
void Register() override
|
||||
{
|
||||
DoCheckProc += AuraCheckProcFn(spell_mage_fingers_of_frost_talent::CheckProc);
|
||||
}
|
||||
};
|
||||
|
||||
// 11071, 12496, 12497 - Frostbite (talent ranks - the proc-trigger aura that
|
||||
// chains into 12494 Frostbite freeze).
|
||||
//
|
||||
// Stock spell_proc (auto-generated from DBC) gates this talent by Mage family +
|
||||
// the talent's effect SpellClassMask (Mage Frost slow-applying spells). For
|
||||
// Paragon characters we relax the row to SchoolMask=FROST wildcard so that
|
||||
// chill-applying Frost spells from any class can reach this CheckProc; the
|
||||
// Paragon path additionally requires the proc spell to actually apply a slow
|
||||
// (SPELL_AURA_MOD_DECREASE_SPEED) so that pure damage Frost spells without a
|
||||
// chill component (e.g. raw Ice Lance on a non-frozen target) do NOT freeze.
|
||||
// Stock Mage owners get the original behavior re-enforced here.
|
||||
class spell_mage_frostbite : public AuraScript
|
||||
{
|
||||
PrepareAuraScript(spell_mage_frostbite);
|
||||
|
||||
bool CheckProc(ProcEventInfo& eventInfo)
|
||||
{
|
||||
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
|
||||
if (!procSpell)
|
||||
return false;
|
||||
|
||||
// Stock Mage path: re-derive from this talent's own effect-0
|
||||
// SpellClassMask so behavior matches the original auto-generated
|
||||
// proc filter exactly (no risk of mask drift across DBC versions).
|
||||
if (procSpell->SpellFamilyName == SPELLFAMILY_MAGE
|
||||
&& (GetSpellInfo()->Effects[EFFECT_0].SpellClassMask & procSpell->SpellFamilyFlags))
|
||||
return true;
|
||||
|
||||
if (!IsParagonWildcardCaller(GetUnitOwner()))
|
||||
return false;
|
||||
|
||||
// Paragon path: any Frost-school spell that applies a chill effect
|
||||
// (decrease-speed aura). The spell_proc row already gates by
|
||||
// SchoolMask=FROST so we only need to verify chill semantics here.
|
||||
for (uint8 i = 0; i < MAX_SPELL_EFFECTS; ++i)
|
||||
{
|
||||
if (procSpell->Effects[i].ApplyAuraName == SPELL_AURA_MOD_DECREASE_SPEED)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also accept the Improved-Blizzard-style cross-class case where the
|
||||
// chill is applied by a separate triggered aura: if the proc spell's
|
||||
// damage hit landed and the target already has a chill from us, treat
|
||||
// it as eligible. Cheap and matches player expectations for Paragon.
|
||||
if (Unit* procTarget = eventInfo.GetProcTarget())
|
||||
{
|
||||
Unit::AuraEffectList const& slows = procTarget->GetAuraEffectsByType(SPELL_AURA_MOD_DECREASE_SPEED);
|
||||
for (AuraEffect const* slowEff : slows)
|
||||
if (slowEff->GetCasterGUID() == GetUnitOwner()->GetGUID())
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void Register() override
|
||||
{
|
||||
DoCheckProc += AuraCheckProcFn(spell_mage_frostbite::CheckProc);
|
||||
}
|
||||
};
|
||||
|
||||
// 74396 - Fingers of Frost
|
||||
class spell_mage_fingers_of_frost : public AuraScript
|
||||
{
|
||||
@@ -1631,5 +1742,7 @@ void AddSC_mage_spell_scripts()
|
||||
RegisterSpellScript(spell_mage_polymorph_cast_visual);
|
||||
RegisterSpellScript(spell_mage_summon_water_elemental);
|
||||
RegisterSpellScript(spell_mage_fingers_of_frost);
|
||||
RegisterSpellScript(spell_mage_fingers_of_frost_talent);
|
||||
RegisterSpellScript(spell_mage_frostbite);
|
||||
RegisterSpellScript(spell_mage_magic_absorption);
|
||||
}
|
||||
|
||||
@@ -1005,12 +1005,38 @@ class spell_pri_vampiric_embrace : public AuraScript
|
||||
|
||||
bool CheckProc(ProcEventInfo& eventInfo)
|
||||
{
|
||||
// Not proc from Mind Sear
|
||||
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
|
||||
if (!procSpell)
|
||||
return false;
|
||||
|
||||
return !(procSpell->SpellFamilyFlags[1] & 0x80000);
|
||||
// Stock: filter Mind Sear (the damage-tick spell carries this
|
||||
// SpellFamilyFlags[1] bit; the channel itself is filtered by the
|
||||
// standard data-row mask). Kept as a bit-test so the stock priest
|
||||
// path is byte-identical to before this change.
|
||||
if (procSpell->SpellFamilyFlags[1] & 0x80000)
|
||||
return false;
|
||||
|
||||
// Fractured / Paragon: any single-target Shadow-school damage spell
|
||||
// procs Vampiric Embrace, not just Priest Shadow spells. The
|
||||
// SchoolMask=Shadow gate is enforced by the spell_proc data row
|
||||
// (SchoolMask=32). The data-row family/mask was wildcarded in
|
||||
// mod-paragon's 2026_05_11_01.sql update so this CheckProc fires for
|
||||
// cross-family Shadow spells; here we add the single-target
|
||||
// requirement (Mind Sear was already filtered above; this also
|
||||
// catches AoE Warlock Shadow spells like Seed of Corruption,
|
||||
// Hellfire, etc. that a Paragon could otherwise cast).
|
||||
if (IsParagonWildcardCaller(GetTarget()))
|
||||
return !procSpell->IsAffectingArea();
|
||||
|
||||
// Stock priest path: re-enforce the original Priest Shadow damage
|
||||
// gate that used to live entirely in the data row. Without this,
|
||||
// wildcarding the data row would let item-cast Shadow effects
|
||||
// (consumables, trinkets) accidentally proc VE on stock priests.
|
||||
if (procSpell->SpellFamilyName != SPELLFAMILY_PRIEST)
|
||||
return false;
|
||||
return (procSpell->SpellFamilyFlags[0] & 0x0280A010)
|
||||
|| (procSpell->SpellFamilyFlags[1] & 0x00002402)
|
||||
|| (procSpell->SpellFamilyFlags[2] & 0x00000008);
|
||||
}
|
||||
|
||||
void HandleProc(AuraEffect const* aurEff, ProcEventInfo& eventInfo)
|
||||
|
||||
@@ -1790,6 +1790,44 @@ class spell_sha_maelstrom_weapon : public AuraScript
|
||||
});
|
||||
}
|
||||
|
||||
// Fractured / Paragon: spell_proc row 53817 is data-relaxed (wildcard
|
||||
// family/mask) so cross-class spells can reach this CheckProc. We
|
||||
// restore the original Shaman gating here for stock callers and add
|
||||
// the Paragon-only Mage Fireball / Frostbolt / Arcane Blast allowlist
|
||||
// mirroring the IsAffectedBySpellMod hook in SpellInfo.cpp.
|
||||
bool CheckProc(ProcEventInfo& eventInfo)
|
||||
{
|
||||
SpellInfo const* procSpell = eventInfo.GetSpellInfo();
|
||||
if (!procSpell)
|
||||
return false;
|
||||
|
||||
// Stock allowlist (Shaman): Lightning Bolt, Chain Lightning,
|
||||
// Lesser Healing Wave, Healing Wave, Hex. Encoded as the original
|
||||
// SpellFamilyMask values from the pre-relaxation spell_proc row
|
||||
// (Mask0 = 451, Mask1 = 32768).
|
||||
bool stockMatch = procSpell->SpellFamilyName == SPELLFAMILY_SHAMAN
|
||||
&& ((procSpell->SpellFamilyFlags[0] & 451u)
|
||||
|| (procSpell->SpellFamilyFlags[1] & 32768u));
|
||||
if (stockMatch)
|
||||
return true;
|
||||
|
||||
if (!IsParagonWildcardCaller(GetUnitOwner()))
|
||||
return false;
|
||||
|
||||
// Paragon path: also accept the curated Mage cast-time nukes.
|
||||
if (procSpell->SpellFamilyName == SPELLFAMILY_MAGE)
|
||||
{
|
||||
SpellInfo const* first = procSpell->GetFirstRankSpell();
|
||||
uint32 firstId = first ? first->Id : procSpell->Id;
|
||||
if (firstId == 133 /*Fireball*/
|
||||
|| firstId == 116 /*Frostbolt*/
|
||||
|| firstId == 30451 /*Arcane Blast*/)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void HandleBonus(AuraEffect const* /*aurEff*/, AuraEffectHandleModes /*mode*/)
|
||||
{
|
||||
if (GetStackAmount() < int32(GetSpellInfo()->StackAmount))
|
||||
@@ -1805,6 +1843,7 @@ class spell_sha_maelstrom_weapon : public AuraScript
|
||||
|
||||
void Register() override
|
||||
{
|
||||
DoCheckProc += AuraCheckProcFn(spell_sha_maelstrom_weapon::CheckProc);
|
||||
OnEffectApply += AuraEffectApplyFn(spell_sha_maelstrom_weapon::HandleBonus, EFFECT_0, SPELL_AURA_ADD_PCT_MODIFIER, AURA_EFFECT_HANDLE_CHANGE_AMOUNT);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -88,7 +88,7 @@ CI workflow **Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml
|
||||
1. Triggers on **release published** on **`Dawnforger/Fractured`** (or **workflow_dispatch** with a tag).
|
||||
2. Builds **Windows** (NSIS + portable) and **Linux** (AppImage) in parallel, each using **`tools/fractured-launcher-electron` from the default branch** (overlaid onto the tag checkout), so older release tags never ship a launcher missing new **`lib/*.js`** files.
|
||||
3. Downloads **all assets** attached to that **GitHub** release (MPQs, patched `Wow.exe`, etc.).
|
||||
4. Merges with the built launcher artifacts and uploads everything to a **Gitea release** with the **same tag** (existing attachments on that Gitea release are replaced).
|
||||
4. Merges with the built launcher artifacts and uploads to a **Gitea release** with the **same tag** (existing attachments on that Gitea release are replaced). **Launcher installers** attached on GitHub (**`Fractured-Launcher*`**, case-insensitive) are **not** merged — the **CI build from the default branch** is the only source of launcher binaries, so an old installer on the GitHub release cannot “stick” on Gitea next to a newer build. **`*.blockmap`** and **`builder-debug.yml`** are omitted from the merge and from Gitea uploads.
|
||||
|
||||
**GitHub Actions secrets** (repository → Settings → Secrets and variables → Actions):
|
||||
|
||||
@@ -127,6 +127,9 @@ CI workflow **Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml
|
||||
8. **HTTP 422 `repo is empty`** — The Gitea repo has **no commits** yet. Push any initial commit (e.g. **Add README** in the Gitea web UI, or `git push` to **`main`**). Optionally set **`GITEA_TARGET_REF`** to match your real default branch if it is not **`main`**. From this repo you can run **`scripts/bootstrap-gitea-repo.sh`** (see script header for `GITEA_*` env or pass the HTTPS/SSH clone URL as the first argument).
|
||||
9. **`sync Wow.exe: fetch failed`** — Often **HTTPS/TLS** to Gitea; use **`http://…`** in **`lib/baked-gitea-channel.js`** if you only serve plain HTTP, or fix certs / **`NODE_EXTRA_CA_CERTS`**. Ensure **`Wow-patched.exe`** exists on the release (**`release_tag`**: `latest` vs pinned). Errors include the failing URL when possible.
|
||||
10. **Wine + Windows portable** — If the folder picker returns **`/home/...`**, the launcher maps it to **`Z:\home\...`** (Wine’s Unix root). **`Wow.exe`** is matched case-insensitively for Linux-backed folders. Re-save the WoW folder after upgrading if validation still fails.
|
||||
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.
|
||||
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.
|
||||
|
||||
### Private Gitea token for players
|
||||
|
||||
@@ -170,4 +173,4 @@ Schema is defined by **`default-launcher.json`** (shipped in the app; first run
|
||||
- **`github`**: used for **non-release** repo paths (`from_release`: false) and for **GitHub** **`from_release`** when **`gitea.base_url`** is empty.
|
||||
- **`patch_manifest`**: **`enabled`**, **`source`** (default `patch-manifest.json`), **`from_release`** — checksum-based skip + verify (see above).
|
||||
- **`files`**: default **`[]`**. **Download updates** resolves what to pull in order: (**1**) non-empty **`files`** if you set explicit **`source`** → **`dest`** pairs; (**2**) else each key in **`patch-manifest.json`** on the release (recommended); (**3**) else release attachments except launcher artifacts (`Fractured-Launcher*`, `*.blockmap`, `latest*.yml`, `.AppImage`, `patch-manifest.json`): **`.MPQ`** → **`Data/enUS/<name>.MPQ`** (extension forced to **`.MPQ`** caps for client compatibility), one **`.exe`** → **`launch.exe`**. Multiple `.exe` attachments require a manifest. Legacy **`Wow-patched.exe`** entries are removed when merging **`launcher.json`**.
|
||||
- **`realmlist`**, **`auth`**, **`launch`**.
|
||||
- **`realmlist`**, **`auth`**, **`launch`** (`**exe**`, **`args`**). Only **`Data/enUS/realmlist.wtf`** is written: any **`realmlist.paths`** entry that is not the enUS file is ignored (so **`enGB`** is never created). On **Linux**, **Play** never runs `Wow.exe` as a native process (that yields **EACCES**). Use **`launch.linux_wrapper`** (default **`["wine"]`**) so the launcher runs e.g. **`wine /path/Wow.exe` …`args`**, or set **`launch.linux_steam_uri`** to a Steam URI (e.g. **`steam://rungameid/…`** for a **non-Steam game** shortcut — the number is shown in Steam’s shortcut properties). Optional **`linux_steam_binary`** defaults to **`steam`**; for Flatpak Steam use **`linux_steam_spawn`**: **`["flatpak", "run", "com.valvesoftware.Steam"]`** (the URI is appended as the last argument). After a **successful** **Download updates** run, the launcher deletes prior **`*.bak-YYYYMMDD-HHmmss`** backup files it created under the WoW folder.
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"realmlist": {
|
||||
"enabled": true,
|
||||
"line": "set realmlist fracturedwow.ddns.net:47497",
|
||||
"paths": ["Data/enUS/realmlist.wtf", "Data/enGB/realmlist.wtf"]
|
||||
"paths": ["Data/enUS/realmlist.wtf"]
|
||||
},
|
||||
"auth": {
|
||||
"enabled": false,
|
||||
@@ -37,6 +37,9 @@
|
||||
"launch": {
|
||||
"exe": "Wow.exe",
|
||||
"args": [],
|
||||
"linux_wrapper": ["wine"]
|
||||
"linux_wrapper": ["wine"],
|
||||
"linux_steam_uri": "",
|
||||
"linux_steam_binary": "",
|
||||
"linux_steam_spawn": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,42 @@ function backupSuffix() {
|
||||
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/** Windows often returns EBUSY/EPERM when WoW or AV still has an MPQ open. */
|
||||
function isRetryableFsLockError(e) {
|
||||
const c = e && e.code;
|
||||
if (!c) return false;
|
||||
if (c === 'EBUSY' || c === 'EPERM' || c === 'EACCES') return true;
|
||||
if (process.platform === 'win32' && (c === 'UNKNOWN' || c === 'EUNKNOWN')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function retryFsLock(op, opts) {
|
||||
const attempts = (opts && opts.attempts) || (process.platform === 'win32' ? 30 : 10);
|
||||
const delayMs = (opts && opts.delayMs) || 500;
|
||||
let last;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
return await op();
|
||||
} catch (e) {
|
||||
last = e;
|
||||
if (!isRetryableFsLockError(e)) throw e;
|
||||
if (i === attempts - 1) break;
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
const hint =
|
||||
process.platform === 'win32'
|
||||
? ' Close World of Warcraft and any launcher using this folder, then try again.'
|
||||
: ' Close programs using this file, then try again.';
|
||||
const err = new Error(String((last && last.message) || last) + hint);
|
||||
err.code = last && last.code;
|
||||
throw err;
|
||||
}
|
||||
|
||||
function wowExePath(cfg) {
|
||||
const gd = normalizeWinGameDir(cfg.game_dir || '');
|
||||
const exe = (cfg.launch && cfg.launch.exe) || 'Wow.exe';
|
||||
@@ -51,34 +87,88 @@ function normalizeMpqDestinationPath(absPath) {
|
||||
return /\.mpq$/i.test(s) ? s.replace(/\.mpq$/i, '.MPQ') : s;
|
||||
}
|
||||
|
||||
/** Matches backup names from installFile: `<orig>.bak-YYYYMMDD-HHmmss`. */
|
||||
const LAUNCHER_BACKUP_BASENAME_RE = /\.bak-\d{8}-\d{6}$/;
|
||||
|
||||
async function removeLauncherBackupFiles(gameDir) {
|
||||
const root = normalizeWinGameDir(gameDir || '');
|
||||
if (!root) return;
|
||||
const stack = [root];
|
||||
while (stack.length) {
|
||||
const dir = stack.pop();
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
for (const d of entries) {
|
||||
const abs = path.join(dir, d.name);
|
||||
if (d.isDirectory()) {
|
||||
stack.push(abs);
|
||||
} else if (d.isFile() && LAUNCHER_BACKUP_BASENAME_RE.test(d.name)) {
|
||||
try {
|
||||
await fs.unlink(abs);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') {
|
||||
/* best effort: sync already succeeded */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isEnUsRealmlistPath(rel) {
|
||||
const n = String(rel || '')
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
.toLowerCase();
|
||||
return n.endsWith('/enus/realmlist.wtf') || n === 'enus/realmlist.wtf';
|
||||
}
|
||||
|
||||
async function installFile(cfg, entry) {
|
||||
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
const root = normalizeWinGameDir(cfg.game_dir || '');
|
||||
const destAbs = normalizeMpqDestinationPath(path.join(root, ...parts));
|
||||
if (entry.backup) {
|
||||
try {
|
||||
const st = await fs.stat(destAbs);
|
||||
if (st.isFile()) {
|
||||
const bak = `${destAbs}.bak-${backupSuffix()}`;
|
||||
await fs.rename(destAbs, bak);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await fs.unlink(destAbs);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
}
|
||||
}
|
||||
const tmp = destAbs + '.new';
|
||||
|
||||
if (entry.from_release) {
|
||||
await downloadReleaseAsset(cfg, entry.source, tmp);
|
||||
} else {
|
||||
await downloadGitHubRepoFile(cfg, entry.source, tmp);
|
||||
}
|
||||
await fs.rename(tmp, destAbs);
|
||||
|
||||
async function removeOrBackupExisting() {
|
||||
if (entry.backup) {
|
||||
try {
|
||||
const st = await fs.stat(destAbs);
|
||||
if (st.isFile()) {
|
||||
const bak = `${destAbs}.bak-${backupSuffix()}`;
|
||||
await fs.rename(destAbs, bak);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await fs.unlink(destAbs);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await retryFsLock(() => removeOrBackupExisting());
|
||||
await retryFsLock(() => fs.rename(tmp, destAbs));
|
||||
|
||||
if (process.platform === 'linux' && /\.exe$/i.test(destAbs)) {
|
||||
try {
|
||||
await fs.chmod(destAbs, 0o755);
|
||||
} catch (_) {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function applyRealmlist(cfg) {
|
||||
@@ -91,6 +181,8 @@ async function applyRealmlist(cfg) {
|
||||
const content = line + '\n';
|
||||
let paths = cfg.realmlist.paths;
|
||||
if (!paths || !paths.length) paths = ['Data/enUS/realmlist.wtf'];
|
||||
paths = paths.filter(isEnUsRealmlistPath);
|
||||
if (!paths.length) paths = ['Data/enUS/realmlist.wtf'];
|
||||
for (const rel of paths) {
|
||||
const r = String(rel).trim().replace(/\\/g, '/');
|
||||
if (!r) continue;
|
||||
@@ -119,6 +211,12 @@ async function applyPatches(cfg, onStatus) {
|
||||
if (onStatus) onStatus('Applying realmlist …');
|
||||
await applyRealmlist(cfg);
|
||||
}
|
||||
if (onStatus) onStatus('Removing old backup copies …');
|
||||
try {
|
||||
await removeLauncherBackupFiles(cfg.game_dir);
|
||||
} catch (_) {
|
||||
/* Patches and realmlist already applied; leave .bak files if cleanup cannot run. */
|
||||
}
|
||||
if (onStatus) onStatus('All patches applied.');
|
||||
}
|
||||
|
||||
|
||||
@@ -145,9 +145,44 @@ ipcMain.handle('launcher:checkUpdates', async () => {
|
||||
ipcMain.handle('launcher:play', async () => {
|
||||
const { config } = await readMergedConfig();
|
||||
const exe = wowExePath(config);
|
||||
const args = (config.launch && config.launch.args) || [];
|
||||
const child = spawn(exe, args, {
|
||||
cwd: config.game_dir,
|
||||
const gameArgs = (config.launch && config.launch.args) || [];
|
||||
const lc = config.launch || {};
|
||||
const cwd = config.game_dir;
|
||||
|
||||
let cmd;
|
||||
let spawnArgs;
|
||||
if (process.platform === 'linux') {
|
||||
const steamUri = String(lc.linux_steam_uri || '').trim();
|
||||
const steamSpawn = Array.isArray(lc.linux_steam_spawn) ? lc.linux_steam_spawn.filter(Boolean) : [];
|
||||
if (steamUri) {
|
||||
if (steamSpawn.length) {
|
||||
cmd = steamSpawn[0];
|
||||
spawnArgs = [...steamSpawn.slice(1), steamUri];
|
||||
} else {
|
||||
const bin = String(lc.linux_steam_binary || 'steam').trim() || 'steam';
|
||||
cmd = bin;
|
||||
spawnArgs = [steamUri];
|
||||
}
|
||||
} else {
|
||||
const wrap = Array.isArray(lc.linux_wrapper) ? lc.linux_wrapper.filter(Boolean) : [];
|
||||
if (wrap.length) {
|
||||
cmd = wrap[0];
|
||||
spawnArgs = [...wrap.slice(1), exe, ...gameArgs];
|
||||
} else {
|
||||
throw new Error(
|
||||
'On Linux, Wow.exe is a Windows program and cannot be run directly. ' +
|
||||
'Set launch.linux_steam_uri (e.g. steam://rungameid/… for your Steam shortcut) ' +
|
||||
'or launch.linux_wrapper (e.g. ["wine"]) in launcher.json.'
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cmd = exe;
|
||||
spawnArgs = gameArgs;
|
||||
}
|
||||
|
||||
const child = spawn(cmd, spawnArgs, {
|
||||
cwd,
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fractured-launcher-electron",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.12",
|
||||
"description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update",
|
||||
"main": "main.js",
|
||||
"repository": {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared filters for GitHub → Gitea / distro release merges and Gitea uploads.
|
||||
# shellcheck shell=bash
|
||||
|
||||
# Skip when copying assets from `gh release download` into combined/: CI-built launcher is authoritative.
|
||||
should_skip_merge_from_github() {
|
||||
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
|
||||
}
|
||||
|
||||
# Skip when POSTing attachments to Gitea (belt-and-suspenders if something slips into combined/).
|
||||
should_skip_gitea_upload() {
|
||||
local l
|
||||
l=$(printf '%s' "${1##*/}" | tr '[:upper:]' '[:lower:]')
|
||||
case "$l" in
|
||||
*.blockmap) return 0 ;;
|
||||
builder-debug.yml|builder-debug.yaml) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
@@ -11,6 +11,10 @@
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
# shellcheck source=release-sync-filters.sh
|
||||
. "$SCRIPT_DIR/release-sync-filters.sh"
|
||||
|
||||
COMBINED_DIR="${1:?first arg: directory of files to attach}"
|
||||
TAG="${2:?second arg: release tag (e.g. v1.0.0)}"
|
||||
|
||||
@@ -67,12 +71,6 @@ if [ -z "$rel_id" ] || [ "$rel_id" = "null" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while read -r aid; do
|
||||
[ -z "$aid" ] || [ "$aid" = "null" ] && continue
|
||||
curl -fsS -X DELETE "${AUTH_H[@]}" \
|
||||
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${rel_id}/assets/${aid}" || true
|
||||
done < <(jq -r '(.attachments // .assets // [])[] | .id' "$REL_JSON")
|
||||
|
||||
shopt -s nullglob
|
||||
files=("$COMBINED_DIR"/*)
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
@@ -80,12 +78,39 @@ if [ "${#files[@]}" -eq 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
uploadable=0
|
||||
for f in "${files[@]}"; do
|
||||
[ -f "$f" ] || continue
|
||||
echo "Uploading $(basename "$f") …"
|
||||
bn=$(basename "$f")
|
||||
if should_skip_gitea_upload "$bn"; then
|
||||
continue
|
||||
fi
|
||||
uploadable=$((uploadable + 1))
|
||||
done
|
||||
if [ "$uploadable" -eq 0 ]; then
|
||||
echo "No files to upload after exclusions (check $COMBINED_DIR) — not clearing Gitea attachments." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while read -r aid; do
|
||||
[ -z "$aid" ] || [ "$aid" = "null" ] && continue
|
||||
curl -fsS -X DELETE "${AUTH_H[@]}" \
|
||||
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${rel_id}/assets/${aid}" || true
|
||||
done < <(jq -r '(.attachments // .assets // [])[] | .id' "$REL_JSON")
|
||||
|
||||
uploaded=0
|
||||
for f in "${files[@]}"; do
|
||||
[ -f "$f" ] || continue
|
||||
bn=$(basename "$f")
|
||||
if should_skip_gitea_upload "$bn"; then
|
||||
echo "Skipping upload (excluded): $bn"
|
||||
continue
|
||||
fi
|
||||
echo "Uploading $bn …"
|
||||
curl -fsS -X POST "${AUTH_H[@]}" \
|
||||
-F "attachment=@${f}" \
|
||||
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${rel_id}/assets"
|
||||
uploaded=$((uploaded + 1))
|
||||
done
|
||||
|
||||
echo "Gitea release $TAG (id=$rel_id) updated with ${#files[@]} file(s)."
|
||||
echo "Gitea release $TAG (id=$rel_id) updated with $uploaded file(s)."
|
||||
|
||||
Reference in New Issue
Block a user