Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1c9172beb | |||
| b408c8a95d | |||
| f88a303327 | |||
| 8ad6a2aca3 | |||
| 36ac3dbd1d | |||
| 24d1ae71d9 | |||
| 9cef99f0ff | |||
| f409ffad12 | |||
| c1f7eaa153 | |||
| b455db0db8 | |||
| 1fb284cb5c | |||
| ebd8d81924 | |||
| 362084b829 |
@@ -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
|
||||
|
||||
@@ -29,7 +29,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag on this GitHub repo (must exist; e.g. v1.0.0)'
|
||||
description: 'Git tag only (e.g. v0.7.11-paragon-foo). NOT the release title — open the release and copy the tag next to the title.'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -51,11 +51,28 @@ jobs:
|
||||
id: t
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
RAW="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
|
||||
RAW="${{ github.event.release.tag_name }}"
|
||||
fi
|
||||
TAG="$(printf '%s' "$RAW" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
if [ -z "$TAG" ]; then
|
||||
echo '::error::Tag input is empty. Paste the git tag (e.g. v0.7.11-…).'
|
||||
exit 1
|
||||
fi
|
||||
if printf '%s' "$TAG" | grep -q '[[:space:]]'; then
|
||||
echo '::error::Tag contains whitespace — that is usually the **release title**, not the tag. On GitHub → Releases → open the release → copy the **tag** (short ref like v0.7.11-…), not the long title line.'
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
{
|
||||
echo "tag<<__TAG_EOF__"
|
||||
echo "$TAG"
|
||||
echo "__TAG_EOF__"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-electron:
|
||||
needs: meta
|
||||
@@ -87,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
|
||||
@@ -97,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:
|
||||
@@ -133,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
|
||||
@@ -142,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
|
||||
@@ -189,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
|
||||
@@ -196,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,6 +21,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(10, 1),
|
||||
(17, 1),
|
||||
(53, 1),
|
||||
(66, 1),
|
||||
(72, 1),
|
||||
(75, 1),
|
||||
(78, 1),
|
||||
@@ -30,6 +31,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(118, 1),
|
||||
(120, 1),
|
||||
(122, 1),
|
||||
(126, 1),
|
||||
(130, 1),
|
||||
(131, 1),
|
||||
(132, 1),
|
||||
@@ -52,6 +54,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(469, 1),
|
||||
(475, 1),
|
||||
(498, 1),
|
||||
(526, 1),
|
||||
(527, 1),
|
||||
(528, 1),
|
||||
(543, 1),
|
||||
@@ -73,22 +76,28 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(676, 1),
|
||||
(686, 1),
|
||||
(687, 1),
|
||||
(688, 1),
|
||||
(689, 1),
|
||||
(691, 1),
|
||||
(693, 1),
|
||||
(694, 1),
|
||||
(697, 1),
|
||||
(698, 1),
|
||||
(702, 1),
|
||||
(703, 1),
|
||||
(706, 1),
|
||||
(710, 1),
|
||||
(712, 1),
|
||||
(740, 1),
|
||||
(755, 1),
|
||||
(759, 1),
|
||||
(768, 1),
|
||||
(770, 1),
|
||||
(772, 1),
|
||||
(774, 1),
|
||||
(779, 1),
|
||||
(781, 1),
|
||||
(783, 1),
|
||||
(845, 1),
|
||||
(853, 1),
|
||||
(871, 1),
|
||||
@@ -104,6 +113,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(1038, 1),
|
||||
(1044, 1),
|
||||
(1064, 1),
|
||||
(1066, 1),
|
||||
(1079, 1),
|
||||
(1082, 1),
|
||||
(1098, 1),
|
||||
@@ -176,6 +186,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(2812, 1),
|
||||
(2825, 1),
|
||||
(2893, 1),
|
||||
(2894, 1),
|
||||
(2908, 1),
|
||||
(2912, 1),
|
||||
(2944, 1),
|
||||
@@ -194,6 +205,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(3565, 1),
|
||||
(3566, 1),
|
||||
(3567, 1),
|
||||
(3714, 1),
|
||||
(3738, 1),
|
||||
(4987, 1),
|
||||
(5116, 1),
|
||||
@@ -205,7 +217,9 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(5185, 1),
|
||||
(5209, 1),
|
||||
(5211, 1),
|
||||
(5215, 1),
|
||||
(5215, 1);
|
||||
|
||||
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(5217, 1),
|
||||
(5221, 1),
|
||||
(5225, 1),
|
||||
@@ -215,11 +229,10 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(5308, 1),
|
||||
(5384, 1),
|
||||
(5484, 1),
|
||||
(5487, 1),
|
||||
(5500, 1),
|
||||
(5502, 1),
|
||||
(5504, 1);
|
||||
|
||||
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(5504, 1),
|
||||
(5675, 1),
|
||||
(5676, 1),
|
||||
(5697, 1),
|
||||
@@ -244,6 +257,8 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(6770, 1),
|
||||
(6785, 1),
|
||||
(6789, 1),
|
||||
(6795, 1),
|
||||
(6807, 1),
|
||||
(6940, 1),
|
||||
(7294, 1),
|
||||
(7302, 1),
|
||||
@@ -283,6 +298,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(11418, 1),
|
||||
(11419, 1),
|
||||
(11420, 1),
|
||||
(12051, 1),
|
||||
(13159, 1),
|
||||
(13161, 1),
|
||||
(13163, 1),
|
||||
@@ -297,6 +313,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(16857, 1),
|
||||
(16914, 1),
|
||||
(18499, 1),
|
||||
(19263, 1),
|
||||
(19740, 1),
|
||||
(19742, 1),
|
||||
(19746, 1),
|
||||
@@ -323,7 +340,6 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(20252, 1),
|
||||
(20484, 1),
|
||||
(20736, 1),
|
||||
(21084, 1),
|
||||
(21562, 1),
|
||||
(21849, 1),
|
||||
(22568, 1),
|
||||
@@ -331,6 +347,8 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(22812, 1),
|
||||
(22842, 1),
|
||||
(23028, 1),
|
||||
(23161, 1),
|
||||
(23214, 1),
|
||||
(23920, 1),
|
||||
(23922, 1),
|
||||
(24275, 1),
|
||||
@@ -349,6 +367,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(29722, 1),
|
||||
(29858, 1),
|
||||
(29893, 1),
|
||||
(30449, 1),
|
||||
(30451, 1),
|
||||
(30455, 1),
|
||||
(30482, 1),
|
||||
@@ -372,12 +391,14 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(33745, 1),
|
||||
(33763, 1),
|
||||
(33786, 1),
|
||||
(33943, 1),
|
||||
(34026, 1),
|
||||
(34074, 1),
|
||||
(34428, 1),
|
||||
(34433, 1),
|
||||
(34477, 1),
|
||||
(34600, 1),
|
||||
(34767, 1),
|
||||
(35715, 1),
|
||||
(35717, 1),
|
||||
(36936, 1),
|
||||
@@ -398,7 +419,9 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(47541, 1),
|
||||
(47568, 1),
|
||||
(47897, 1),
|
||||
(48018, 1),
|
||||
(48018, 1);
|
||||
|
||||
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(48020, 1),
|
||||
(48045, 1),
|
||||
(48263, 1),
|
||||
@@ -416,14 +439,19 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(49576, 1),
|
||||
(49998, 1),
|
||||
(50464, 1),
|
||||
(50769, 1),
|
||||
(50842, 1),
|
||||
(51505, 1),
|
||||
(51514, 1),
|
||||
(51722, 1),
|
||||
(51723, 1),
|
||||
(52610, 1);
|
||||
|
||||
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(51730, 1),
|
||||
(52127, 1),
|
||||
(52610, 1),
|
||||
(53140, 1),
|
||||
(53142, 1),
|
||||
(53271, 1),
|
||||
(53351, 1),
|
||||
(53407, 1),
|
||||
(53408, 1),
|
||||
(53600, 1),
|
||||
@@ -432,15 +460,23 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(54428, 1),
|
||||
(55342, 1),
|
||||
(55694, 1),
|
||||
(56222, 1),
|
||||
(56641, 1),
|
||||
(56815, 1),
|
||||
(57330, 1),
|
||||
(57755, 1),
|
||||
(57934, 1),
|
||||
(57994, 1),
|
||||
(60192, 1),
|
||||
(61846, 1),
|
||||
(61999, 1),
|
||||
(62078, 1),
|
||||
(62124, 1),
|
||||
(62757, 1),
|
||||
(64382, 1),
|
||||
(64843, 1);
|
||||
(64843, 1),
|
||||
(64901, 1),
|
||||
(66842, 1),
|
||||
(66843, 1),
|
||||
(66844, 1);
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
-- mod-paragon: backfill paragon_spell_ae_cost rows for spells newly exposed
|
||||
-- by the Character Advancement panel after removing the over-aggressive
|
||||
-- ClassMask=0 filter from tools/_gen_paragon_advancement_spells_lua.py.
|
||||
--
|
||||
-- The base file (data/sql/db-world/base/paragon_spell_ae_cost.sql) was
|
||||
-- regenerated alongside this migration so fresh deployments already have
|
||||
-- these rows. Existing servers do not re-run base files on content change,
|
||||
-- so this update inserts the new (spell_id, ae_cost) pairs idempotently.
|
||||
-- INSERT IGNORE keeps any per-row tuning a server operator may have already
|
||||
-- applied to spell_ids that happen to overlap.
|
||||
--
|
||||
-- New ids include: 51505 Lava Burst (Shaman), 12051 Evocation / 1066 Aqueous
|
||||
-- Form / Hex / Mage Ward / Spellsteal (Mage), 53351 Kill Shot / 19263
|
||||
-- Deterrence / 53271 Master's Call (Hunter), 3714 Path of Frost / 57330
|
||||
-- Horn of Winter / 56815 Rune Strike / 61999 Raise Ally / 56222 Dark Command
|
||||
-- (DK), and 39 other trainer-taught class abilities whose stock
|
||||
-- SkillLineAbility.dbc rows have ClassMask=0 (the skill line itself pins the
|
||||
-- class for these rows; ClassMask is redundant on class-spec lines).
|
||||
|
||||
INSERT IGNORE INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||
(66, 1), -- Invisibility (Mage)
|
||||
(126, 1), -- Eye of Kilrogg (Warlock)
|
||||
(526, 1), -- Cure Toxins (Shaman)
|
||||
(688, 1), -- Summon Imp (Warlock)
|
||||
(691, 1), -- Summon Felhunter (Warlock)
|
||||
(697, 1), -- Summon Voidwalker (Warlock)
|
||||
(712, 1), -- Summon Succubus (Warlock)
|
||||
(768, 1), -- Cat Form (Druid)
|
||||
(783, 1), -- Travel Form (Druid)
|
||||
(1066, 1), -- Aqueous Form (Mage)
|
||||
(2894, 1), -- Fire Resistance Totem (Shaman)
|
||||
(3714, 1), -- Path of Frost (DK)
|
||||
(5215, 1), -- Prowl (Druid)
|
||||
(5487, 1), -- Bear Form (Druid)
|
||||
(5504, 1), -- Conjure Refreshment (Mage)
|
||||
(6795, 1), -- Growl (Druid)
|
||||
(6807, 1), -- Maul (Druid)
|
||||
(12051, 1), -- Evocation (Mage)
|
||||
(19263, 1), -- Deterrence (Hunter)
|
||||
(23161, 1), -- Summon Dreadsteed (Warlock)
|
||||
(23214, 1), -- Summon Charger (Paladin)
|
||||
(30449, 1), -- Spellsteal (Mage)
|
||||
(33943, 1), -- Flight Form (Druid)
|
||||
(34767, 1), -- Summon Felguard (Warlock)
|
||||
(48018, 1), -- Demonic Circle: Summon (Warlock)
|
||||
(50769, 1), -- Revive (Druid)
|
||||
(51505, 1), -- Lava Burst (Shaman)
|
||||
(51514, 1), -- Hex (Shaman)
|
||||
(51730, 1), -- Earthliving Weapon (Shaman)
|
||||
(52127, 1), -- Water Shield (Shaman)
|
||||
(52610, 1), -- Savage Roar (Druid)
|
||||
(53271, 1), -- Master's Call (Hunter)
|
||||
(53351, 1), -- Kill Shot (Hunter)
|
||||
(56222, 1), -- Dark Command (DK)
|
||||
(56815, 1), -- Rune Strike (DK)
|
||||
(57330, 1), -- Horn of Winter (DK)
|
||||
(61999, 1), -- Raise Ally (DK)
|
||||
(64843, 1), -- Divine Hymn (Priest)
|
||||
(64901, 1), -- Hymn of Hope (Priest)
|
||||
(66842, 1), -- Call of the Elements (Shaman totem set)
|
||||
(66843, 1), -- Call of the Ancestors (Shaman totem set)
|
||||
(66844, 1); -- Call of the Spirits (Shaman totem set)
|
||||
@@ -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);
|
||||
@@ -1203,10 +1203,21 @@ void PanelLearnSpellChain(Player* pl, uint32 baseSpellId)
|
||||
// spellbook icons. The correct *passive* spellbook entries the
|
||||
// player is supposed to see are 59879 / 59921 (the descriptive
|
||||
// "Passive disease" rows; SPELL_ATTR0_PASSIVE bit set).
|
||||
// After the Paragon class-skill cascade guard landed in
|
||||
// Player::learnSkillRewardedSpells, NONE of the DK skill-line
|
||||
// cascade rewards are auto-granted any more — so passives that
|
||||
// used to ride along on a class skill cascade (Forceful
|
||||
// Deflection on Blood Strike, Runic Focus on Icy Touch) must be
|
||||
// explicitly attached here, the same way Blood Plague / Frost
|
||||
// Fever are. Add new entries when a panel-purchased active is
|
||||
// expected to come with a passive spellbook entry that no
|
||||
// SPELL_EFFECT_LEARN_SPELL on the parent provides.
|
||||
struct AttachedPassive { uint32 parentHead; uint32 attachedSpell; };
|
||||
static AttachedPassive const kAttached[] = {
|
||||
{ 45462, 59879 }, // Plague Strike -> Blood Plague (passive entry)
|
||||
{ 45477, 59921 }, // Icy Touch -> Frost Fever (passive entry)
|
||||
{ 45477, 59921 }, // Icy Touch -> Frost Fever (passive entry)
|
||||
{ 45477, 61455 }, // Icy Touch -> Runic Focus (parry from spell power)
|
||||
{ 45902, 49410 }, // Blood Strike -> Forceful Deflection (parry from strength)
|
||||
};
|
||||
|
||||
// Self-heal: a previous build of mod-paragon (briefly shipped)
|
||||
@@ -3889,16 +3900,25 @@ public:
|
||||
lb.child, lb.parent, player->GetName());
|
||||
}
|
||||
}
|
||||
// 2b) Re-attach the correct passive spellbook entry (59879 /
|
||||
// 59921) for any panel-purchased Plague Strike / Icy Touch
|
||||
// that's missing it. `learnSpell` here can re-fire the DK
|
||||
// skill-line cascade and re-grant Blood Presence / Death
|
||||
// Coil / Death Grip / Forceful Deflection — Step 3's
|
||||
// scoped sweep is what cleans those up.
|
||||
// 2b) Re-attach the correct passive spellbook entries for any
|
||||
// panel-purchased parent that is missing them. After the
|
||||
// class-skill cascade guard in
|
||||
// Player::learnSkillRewardedSpells, the cascade no longer
|
||||
// fires for Paragons, so these attachments are the ONLY
|
||||
// source for the disease passive icons (Blood Plague /
|
||||
// Frost Fever) and the small DK weapon passives (Forceful
|
||||
// Deflection from Blood Strike, Runic Focus from Icy
|
||||
// Touch). Existing characters predating the guard may
|
||||
// have FD/RF in their spellbook from the cascade but no
|
||||
// panel_spell_children row tying them to the parent;
|
||||
// re-running learnSpell when they already have the spell
|
||||
// just records the child row and is a no-op otherwise.
|
||||
struct LegacyFix { uint32 parent; uint32 correctChild; };
|
||||
static LegacyFix const kFixup[] = {
|
||||
{ 45462, 59879 },
|
||||
{ 45477, 59921 },
|
||||
{ 45462, 59879 }, // Plague Strike -> Blood Plague (passive)
|
||||
{ 45477, 59921 }, // Icy Touch -> Frost Fever (passive)
|
||||
{ 45477, 61455 }, // Icy Touch -> Runic Focus
|
||||
{ 45902, 49410 }, // Blood Strike -> Forceful Deflection
|
||||
};
|
||||
for (auto const& lf : kFixup)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
#
|
||||
###################################################################################################
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -12017,6 +12021,28 @@ void Player::learnSkillRewardedSpells(uint32 skill_id, uint32 skill_value)
|
||||
uint32 raceMask = getRaceMask();
|
||||
uint32 classMask = getClassMask();
|
||||
|
||||
// Fractured / Paragon: the Character Advancement panel is the sole
|
||||
// authority over which class abilities a Paragon owns. The skill-line
|
||||
// cascade re-fires from _LoadSkills (every login), UpdateSkillsForLevel
|
||||
// (every level-up), UpdateSkillPro (every weapon-skill tick on a
|
||||
// training dummy), and SetSkill (first time a class skill is granted).
|
||||
// Each of those re-grants every SLA-tagged class ability on the
|
||||
// matching skill line — leaking Blood Presence / Death Coil / Death
|
||||
// Grip / etc. back into the spellbook within seconds even after the
|
||||
// player intentionally refunded them via the panel. Skip the cascade
|
||||
// for class-category skill lines on Paragon characters; mod-paragon
|
||||
// calls Player::learnSpell directly for the abilities the player
|
||||
// actually purchased, including their attached passives. Profession,
|
||||
// weapon, language, and racial skill cascades stay enabled so things
|
||||
// like recipe auto-learn, weapon proficiencies, and racial perks
|
||||
// still work.
|
||||
if (getClass() == CLASS_PARAGON)
|
||||
{
|
||||
if (SkillLineEntry const* sl = sSkillLineStore.LookupEntry(skill_id))
|
||||
if (sl->categoryId == SKILL_CATEGORY_CLASS)
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all abilities for this skill and sort by MinSkillLineRank (lowest to highest)
|
||||
auto abilities = GetSkillLineAbilitiesBySkillLine(skill_id);
|
||||
std::vector<SkillLineAbilityEntry const*> sortedAbilities(abilities.begin(), abilities.end());
|
||||
|
||||
@@ -72,11 +72,25 @@
|
||||
#include "Util.h"
|
||||
#include "Vehicle.h"
|
||||
#include "World.h"
|
||||
#include "WorldConfig.h"
|
||||
#include "WorldPacket.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
// 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 (listener && listener->getClass() == CLASS_PARAGON
|
||||
&& sWorld->getBoolConfig(CONFIG_PARAGON_WILDCARD_FAMILY))
|
||||
return true;
|
||||
return expectedFamily == actualFamily;
|
||||
}
|
||||
|
||||
float baseMoveSpeed[MAX_MOVE_TYPE] =
|
||||
{
|
||||
2.5f, // MOVE_WALK
|
||||
@@ -9702,7 +9716,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 +10435,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,15 @@ private:
|
||||
ValuesUpdateCache _valuesUpdateCache;
|
||||
};
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3540,6 +3540,32 @@ SpellCastResult Spell::prepare(SpellCastTargets const* targets, AuraEffect const
|
||||
if (m_caster->ToPlayer()->GetCommandStatus(CHEAT_CASTTIME))
|
||||
m_casttime = 0;
|
||||
|
||||
// Fractured / Paragon: cross-class Predator's Swiftness (69369).
|
||||
// Stock 3.3.5 only ADD_PCT_MODIFIER's the cast time of Druid-family
|
||||
// Nature spells via class mask, so a Paragon with the buff cannot
|
||||
// instant-cast Shaman Chain Lightning / Lightning Bolt or any other
|
||||
// non-Druid Nature spell. The tooltip ("next Nature spell with a
|
||||
// base cast time below 10 sec becomes instant") expects all-Nature
|
||||
// behavior; honor that here for CLASS_PARAGON. We deliberately do
|
||||
// not touch the stock SpellMod path -- real Druids continue to hit
|
||||
// the existing class-mask code path unchanged.
|
||||
if (Player* paragonCaster = m_caster->ToPlayer())
|
||||
{
|
||||
constexpr uint32 SPELL_PARAGON_PREDATORY_SWIFTNESS = 69369;
|
||||
if (m_casttime > 0
|
||||
&& paragonCaster->getClass() == CLASS_PARAGON
|
||||
&& (m_spellInfo->SchoolMask & SPELL_SCHOOL_MASK_NATURE)
|
||||
&& m_spellInfo->CastTimeEntry
|
||||
&& !m_spellInfo->IsChanneled()
|
||||
&& !HasTriggeredCastFlag(TRIGGERED_FULL_MASK)
|
||||
&& m_spellInfo->CalcCastTime() < 10 * IN_MILLISECONDS
|
||||
&& paragonCaster->HasAura(SPELL_PARAGON_PREDATORY_SWIFTNESS))
|
||||
{
|
||||
m_casttime = 0;
|
||||
paragonCaster->RemoveAurasDueToSpell(SPELL_PARAGON_PREDATORY_SWIFTNESS);
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
|
||||
@@ -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,7 @@ bool SpellInfo::IsAffectedBySpellMod(SpellModifier const* mod) const
|
||||
if (!sScriptMgr->OnIsAffectedBySpellModCheck(affectSpell, this, mod))
|
||||
return true;
|
||||
|
||||
return IsAffected(affectSpell->SpellFamilyName, mod->mask);
|
||||
return IsAffected(affectSpell->SpellFamilyName, mod->mask, listenerOwner);
|
||||
}
|
||||
|
||||
bool SpellInfo::CanPierceImmuneAura(SpellInfo const* auraSpellInfo) const
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
On first run, `launcher.json` is created next to the app (dev: in this folder).
|
||||
On first run, **`launcher.json`** is created: **dev** — next to the app in this folder; **Windows packaged** — beside the `.exe`; **Linux AppImage / macOS packaged** — under Electron **`app.getPath('userData')`** (typically under **`~/.config/`**, folder name from the app; AppImage mount is read-only so config cannot live beside the binary).
|
||||
|
||||
### Where patches download from
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -118,12 +118,18 @@ CI workflow **Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml
|
||||
### Sync did not run / Gitea unchanged — checklist
|
||||
|
||||
1. **Git tag ≠ GitHub Release** — Only **Releases** (published on the GitHub **Releases** page) trigger this workflow. If your teammate only **`git push --tags`**, create a **Release** from that tag and click **Publish** (or run **Actions → Sync release to Gitea → Run workflow** and enter the tag).
|
||||
2. **Draft release** — Must click **Publish release**; drafts do not mirror.
|
||||
3. **Workflow on default branch** — GitHub runs `release` workflows from the **default branch** (e.g. `main`). Ensure `.github/workflows/gitea-release-sync.yml` is merged there.
|
||||
4. **Repo name guard** — Jobs use `if: github.repository == 'Dawnforger/Fractured'`. Forks or renames must change that line or runs are skipped.
|
||||
5. **Secrets** — **`GITEA_BASE_URL`**, **`GITEA_TOKEN`**, **`GITEA_OWNER`**, **`GITEA_REPO`** must be set under **Settings → Secrets and variables → Actions**. A failed “Upload to Gitea” step usually prints which is missing.
|
||||
6. **Actions tab** — Open the latest **Sync release to Gitea** run; a red **build-electron** (old tag without `package-lock.json`, etc.) or **Upload to Gitea** step shows the real error.
|
||||
7. **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).
|
||||
2. **Manual run: tag vs title** — **Run workflow** must receive the **git tag** (e.g. `v0.7.11-paragon-…`), copied from the release page’s tag badge. Pasting the **release title** (long line with spaces/parentheses) breaks `git fetch` with `invalid refspec`.
|
||||
3. **Draft release** — Must click **Publish release**; drafts do not mirror.
|
||||
4. **Workflow on default branch** — GitHub runs `release` workflows from the **default branch** (e.g. `main`). Ensure `.github/workflows/gitea-release-sync.yml` is merged there.
|
||||
5. **Repo name guard** — Jobs use `if: github.repository == 'Dawnforger/Fractured'`. Forks or renames must change that line or runs are skipped.
|
||||
6. **Secrets** — **`GITEA_BASE_URL`**, **`GITEA_TOKEN`**, **`GITEA_OWNER`**, **`GITEA_REPO`** must be set under **Settings → Secrets and variables → Actions**. A failed “Upload to Gitea” step usually prints which is missing.
|
||||
7. **Actions tab** — Open the latest **Sync release to Gitea** run; a red **build-electron** (old tag without `package-lock.json`, etc.) or **Upload to Gitea** step shows the real error.
|
||||
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
|
||||
|
||||
@@ -133,7 +139,7 @@ Do **not** embed a shared admin PAT in a shipped `launcher.json`. Prefer read-on
|
||||
|
||||
## Patch versions (same filenames, different bytes)
|
||||
|
||||
The launcher does **not** read Git commits. For **turn-key** updates when asset names stay fixed (`patch-Z.MPQ`, `Wow-patched.exe`, …):
|
||||
The launcher does **not** read Git commits. For **turn-key** updates when asset names stay fixed (e.g. **`Wow-patched.exe`** — add more **`files`** entries for any extra MPQs you ship):
|
||||
|
||||
1. Ship **`patch-manifest.json`** next to those files on **every** release (Gitea/GitHub attachment). It lists a **`version`** label (any string you bump per release, e.g. `v0.9.0-client`) and a **`sha256`** per **`files[].source`** name.
|
||||
2. With **`patch_manifest.enabled`**: true (default in **`default-launcher.json`**), **Download updates** first fetches the manifest from the same release channel. If the files already on disk match those checksums, the player sees **“already match build … (nothing to download)”** — no redundant downloads.
|
||||
@@ -145,7 +151,7 @@ If **`patch-manifest.json`** is missing on a release, the launcher falls back to
|
||||
|
||||
```bash
|
||||
cd /path/to/staging
|
||||
node tools/fractured-launcher-electron/scripts/generate-patch-manifest.js v0.9.0-client patch-Z.MPQ Wow-patched.exe > patch-manifest.json
|
||||
node tools/fractured-launcher-electron/scripts/generate-patch-manifest.js v0.9.0-client Wow-patched.exe > patch-manifest.json
|
||||
```
|
||||
|
||||
Attach **`patch-manifest.json`** together with the MPQ/exe to the GitHub release (CI sync copies it to Gitea with everything else).
|
||||
@@ -158,7 +164,7 @@ Workflow **Fractured launcher CI** (`.github/workflows/fractured-launcher-ci.yml
|
||||
|
||||
## Config
|
||||
|
||||
Schema is defined by **`default-launcher.json`** (shipped in the app; first run copies to `launcher.json` beside the executable):
|
||||
Schema is defined by **`default-launcher.json`** (shipped in the app; first run copies to **`launcher.json`** — beside the **Windows** exe, or under **`userData`** on **Linux/macOS** packaged builds):
|
||||
|
||||
- **`game_dir`**: WoW 3.3.5a root (contains `Wow.exe`).
|
||||
- **`update_feed_url`**: optional generic HTTPS base for launcher auto-update.
|
||||
@@ -166,4 +172,5 @@ Schema is defined by **`default-launcher.json`** (shipped in the app; first run
|
||||
- **`gitea`**: **`base_url`**, **`owner`**, **`repo`**, **`release_tag`**, **`token_env`** — when **`base_url`** is set (and owner/repo set), **`from_release`** downloads and (with token if needed) the **generic** updater feed use **Gitea**. **Required** for players if your CI mirrors patches/launchers to Gitea only.
|
||||
- **`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`**, **`realmlist`**, **`auth`**, **`launch`**.
|
||||
- **`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`** (`**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.
|
||||
|
||||
@@ -21,24 +21,11 @@
|
||||
"source": "patch-manifest.json",
|
||||
"from_release": true
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"source": "patch-Z.MPQ",
|
||||
"dest": "Data/patch-Z.MPQ",
|
||||
"backup": true,
|
||||
"from_release": true
|
||||
},
|
||||
{
|
||||
"source": "Wow-patched.exe",
|
||||
"dest": "Wow.exe",
|
||||
"backup": true,
|
||||
"from_release": true
|
||||
}
|
||||
],
|
||||
"files": [],
|
||||
"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,
|
||||
@@ -50,6 +37,9 @@
|
||||
"launch": {
|
||||
"exe": "Wow.exe",
|
||||
"args": [],
|
||||
"linux_wrapper": ["wine"]
|
||||
"linux_wrapper": ["wine"],
|
||||
"linux_steam_uri": "",
|
||||
"linux_steam_binary": "",
|
||||
"linux_steam_spawn": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* Token stays in env: GITEA_TOKEN or launcher.json → gitea.token_env.
|
||||
*/
|
||||
module.exports = {
|
||||
// Scheme optional — gitea-release normalizes to https:// if missing.
|
||||
base_url: 'https://brassnet.ddns.net:33983',
|
||||
// http:// kept as-is; bare host gets https in gitea-release.js
|
||||
base_url: 'http://brassnet.ddns.net:33983',
|
||||
owner: 'Dawnsorrow',
|
||||
repo: 'Fractured-Distro',
|
||||
release_tag: 'latest',
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { normalizeWinGameDir } = require('./win-game-dir');
|
||||
|
||||
/** Sources no longer shipped; drop from merged files so old launcher.json does not keep fetching them. */
|
||||
const DEPRECATED_FILE_SOURCES = new Set(['patch-Z.MPQ', 'Wow-patched.exe']);
|
||||
|
||||
function mergeFilesList(defaults, user) {
|
||||
const dep = (e) => DEPRECATED_FILE_SOURCES.has(String(e && e.source ? e.source : '').trim());
|
||||
if (Array.isArray(user.files) && user.files.length) {
|
||||
const filtered = user.files.map((e) => ({ ...e })).filter((e) => !dep(e));
|
||||
if (filtered.length) return filtered;
|
||||
}
|
||||
const defList = Array.isArray(defaults.files) ? defaults.files : [];
|
||||
return defList.map((e) => ({ ...e })).filter((e) => !dep(e));
|
||||
}
|
||||
|
||||
function userFilesContainDeprecated(user) {
|
||||
const files = user && user.files;
|
||||
if (!Array.isArray(files)) return false;
|
||||
return files.some((e) => DEPRECATED_FILE_SOURCES.has(String(e && e.source ? e.source : '').trim()));
|
||||
}
|
||||
|
||||
function mergeConfig(defaults, user) {
|
||||
return {
|
||||
@@ -21,7 +41,7 @@ function mergeConfig(defaults, user) {
|
||||
launch: { ...defaults.launch, ...(user.launch || {}) },
|
||||
auth: user.auth != null ? { ...defaults.auth, ...user.auth } : defaults.auth,
|
||||
realmlist: user.realmlist != null ? { ...defaults.realmlist, ...user.realmlist } : defaults.realmlist,
|
||||
files: Array.isArray(user.files) && user.files.length ? user.files : defaults.files,
|
||||
files: mergeFilesList(defaults, user),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,6 +65,10 @@ function applyBakedGitea(cfg) {
|
||||
function getConfigPath(app) {
|
||||
if (process.env.FRACTURED_LAUNCHER_CONFIG) return process.env.FRACTURED_LAUNCHER_CONFIG;
|
||||
if (app && app.isPackaged) {
|
||||
// AppImage (and macOS .app) run from a read-only mount — cannot write beside execPath.
|
||||
if (process.platform === 'linux' || process.platform === 'darwin') {
|
||||
return path.join(app.getPath('userData'), 'launcher.json');
|
||||
}
|
||||
return path.join(path.dirname(process.execPath), 'launcher.json');
|
||||
}
|
||||
return path.join(__dirname, '..', 'launcher.json');
|
||||
@@ -56,7 +80,11 @@ async function loadConfig(app) {
|
||||
const defaults = JSON.parse(await fs.readFile(defPath, 'utf8'));
|
||||
try {
|
||||
const user = JSON.parse(await fs.readFile(p, 'utf8'));
|
||||
return { configPath: p, config: applyBakedGitea(mergeConfig(defaults, user)) };
|
||||
const config = applyBakedGitea(mergeConfig(defaults, user));
|
||||
if (userFilesContainDeprecated(user)) {
|
||||
await fs.writeFile(p, JSON.stringify(config, null, 2), 'utf8');
|
||||
}
|
||||
return { configPath: p, config };
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
const initial = applyBakedGitea(mergeConfig(defaults, {}));
|
||||
@@ -80,8 +108,9 @@ async function saveGameDir(configPath, gameDir) {
|
||||
function resolveGameDir(cfg, configPath) {
|
||||
const gd = cfg.game_dir;
|
||||
if (!gd) return '';
|
||||
if (path.isAbsolute(gd)) return path.normalize(gd);
|
||||
return path.normalize(path.join(path.dirname(configPath), gd));
|
||||
const abs = path.isAbsolute(gd) ? path.normalize(gd) : path.normalize(path.join(path.dirname(configPath), gd));
|
||||
if (process.platform === 'win32') return normalizeWinGameDir(abs);
|
||||
return abs;
|
||||
}
|
||||
|
||||
module.exports = { getConfigPath, loadConfig, saveGameDir, resolveGameDir, mergeConfig, applyBakedGitea };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const { downloadBodyToFile } = require('./http-download');
|
||||
const { downloadBodyToFile, fetchOrThrow } = require('./http-download');
|
||||
|
||||
function normalizeGiteaBaseUrl(raw) {
|
||||
let b = String(raw || '').trim().replace(/\/+$/, '');
|
||||
@@ -33,7 +33,7 @@ function useGiteaReleases(cfg) {
|
||||
return !!(String(g.base_url || '').trim() && String(g.owner || '').trim() && String(g.repo || '').trim());
|
||||
}
|
||||
|
||||
async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
|
||||
async function fetchGiteaReleaseRecord(cfg) {
|
||||
const api = giteaApiBase(cfg);
|
||||
const { owner, repo } = cfg.gitea;
|
||||
const tag = (cfg.gitea.release_tag || 'latest').trim() || 'latest';
|
||||
@@ -46,7 +46,7 @@ async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
|
||||
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`;
|
||||
}
|
||||
|
||||
const res = await fetch(listUrl, { headers: giteaHeaders(token, true) });
|
||||
const res = await fetchOrThrow(listUrl, { headers: giteaHeaders(token, true) });
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
let hint = '';
|
||||
@@ -54,7 +54,18 @@ async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
|
||||
if (res.status === 401 || res.status === 403) hint = ' (set GITEA_TOKEN or gitea.token_env)';
|
||||
throw new Error(`Gitea release ${res.status}${hint}: ${text.slice(0, 600)}`);
|
||||
}
|
||||
const rel = JSON.parse(text);
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
async function listGiteaReleaseAttachmentNames(cfg) {
|
||||
const rel = await fetchGiteaReleaseRecord(cfg);
|
||||
const list = rel.attachments || rel.assets || [];
|
||||
return list.map((x) => x.name).filter(Boolean);
|
||||
}
|
||||
|
||||
async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
|
||||
const token = giteaToken(cfg);
|
||||
const rel = await fetchGiteaReleaseRecord(cfg);
|
||||
const list = rel.attachments || rel.assets || [];
|
||||
let downloadUrl = '';
|
||||
for (const a of list) {
|
||||
@@ -69,7 +80,7 @@ async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
|
||||
|
||||
const h = { Accept: 'application/octet-stream' };
|
||||
if (token) h.Authorization = `token ${token}`;
|
||||
const dl = await fetch(downloadUrl, { headers: h, redirect: 'follow' });
|
||||
const dl = await fetchOrThrow(downloadUrl, { headers: h, redirect: 'follow' });
|
||||
await downloadBodyToFile(dl, destPath);
|
||||
}
|
||||
|
||||
@@ -89,7 +100,7 @@ async function getGiteaUpdaterFeedBase(cfg) {
|
||||
} else {
|
||||
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`;
|
||||
}
|
||||
const res = await fetch(listUrl, { headers: giteaHeaders(token, true) });
|
||||
const res = await fetchOrThrow(listUrl, { headers: giteaHeaders(token, true) });
|
||||
if (!res.ok) return null;
|
||||
const rel = await res.json();
|
||||
const tagName = rel.tag_name;
|
||||
@@ -101,6 +112,8 @@ async function getGiteaUpdaterFeedBase(cfg) {
|
||||
|
||||
module.exports = {
|
||||
downloadGiteaReleaseAsset,
|
||||
fetchGiteaReleaseRecord,
|
||||
listGiteaReleaseAttachmentNames,
|
||||
giteaToken,
|
||||
useGiteaReleases,
|
||||
getGiteaUpdaterFeedBase,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { githubToken } = require('./github-token');
|
||||
const { downloadGiteaReleaseAsset, useGiteaReleases } = require('./gitea-release');
|
||||
const { fetchToFile, downloadBodyToFile } = require('./http-download');
|
||||
const { downloadGiteaReleaseAsset, useGiteaReleases, listGiteaReleaseAttachmentNames } = require('./gitea-release');
|
||||
const { fetchToFile, downloadBodyToFile, fetchOrThrow } = require('./http-download');
|
||||
|
||||
function encodeRepoPath(repoPath) {
|
||||
let p = String(repoPath || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||
@@ -35,7 +35,7 @@ async function downloadGitHubRepoFile(cfg, repoPath, destPath) {
|
||||
}
|
||||
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${enc}?ref=${encodeURIComponent(ref)}`;
|
||||
const res = await fetch(apiUrl, { headers: ghHeaders(token, true) });
|
||||
const res = await fetchOrThrow(apiUrl, { headers: ghHeaders(token, true) });
|
||||
const body = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub contents API ${res.status}: ${body.slice(0, 800)}`);
|
||||
@@ -65,10 +65,7 @@ async function downloadGitHubRepoFile(cfg, repoPath, destPath) {
|
||||
throw new Error(`unexpected GitHub response for ${repoPath}`);
|
||||
}
|
||||
|
||||
async function downloadReleaseAsset(cfg, assetName, destPath) {
|
||||
if (useGiteaReleases(cfg)) {
|
||||
return downloadGiteaReleaseAsset(cfg, assetName, destPath);
|
||||
}
|
||||
async function fetchGitHubReleaseJson(cfg) {
|
||||
const token = githubToken(cfg);
|
||||
const tag = (cfg.github.release_tag || 'latest').trim() || 'latest';
|
||||
const { owner, repo } = cfg.github;
|
||||
@@ -78,7 +75,7 @@ async function downloadReleaseAsset(cfg, assetName, destPath) {
|
||||
} else {
|
||||
listUrl = `https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`;
|
||||
}
|
||||
const res = await fetch(listUrl, { headers: ghHeaders(token, true) });
|
||||
const res = await fetchOrThrow(listUrl, { headers: ghHeaders(token, true) });
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
let hint = '';
|
||||
@@ -89,7 +86,24 @@ async function downloadReleaseAsset(cfg, assetName, destPath) {
|
||||
if (res.status === 401 || res.status === 403) hint = ' (set GITHUB_TOKEN or token_env PAT)';
|
||||
throw new Error(`releases list ${res.status}${hint}: ${text.slice(0, 600)}`);
|
||||
}
|
||||
const rel = JSON.parse(text);
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
async function listReleaseAttachmentNames(cfg) {
|
||||
if (useGiteaReleases(cfg)) {
|
||||
return listGiteaReleaseAttachmentNames(cfg);
|
||||
}
|
||||
const rel = await fetchGitHubReleaseJson(cfg);
|
||||
const assets = rel.assets || [];
|
||||
return assets.map((a) => a.name).filter(Boolean);
|
||||
}
|
||||
|
||||
async function downloadReleaseAsset(cfg, assetName, destPath) {
|
||||
if (useGiteaReleases(cfg)) {
|
||||
return downloadGiteaReleaseAsset(cfg, assetName, destPath);
|
||||
}
|
||||
const token = githubToken(cfg);
|
||||
const rel = await fetchGitHubReleaseJson(cfg);
|
||||
const assets = rel.assets || [];
|
||||
let assetURL = '';
|
||||
for (const a of assets) {
|
||||
@@ -114,8 +128,14 @@ async function downloadReleaseAsset(cfg, assetName, destPath) {
|
||||
h.Authorization = `Bearer ${token}`;
|
||||
h['X-GitHub-Api-Version'] = '2022-11-28';
|
||||
}
|
||||
const dl = await fetch(assetURL, { headers: h, redirect: 'follow' });
|
||||
const dl = await fetchOrThrow(assetURL, { headers: h, redirect: 'follow' });
|
||||
await downloadBodyToFile(dl, destPath);
|
||||
}
|
||||
|
||||
module.exports = { downloadGitHubRepoFile, downloadReleaseAsset, encodeRepoPath };
|
||||
module.exports = {
|
||||
downloadGitHubRepoFile,
|
||||
downloadReleaseAsset,
|
||||
encodeRepoPath,
|
||||
fetchGitHubReleaseJson,
|
||||
listReleaseAttachmentNames,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,42 @@ const { createWriteStream } = require('fs');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const { Readable } = require('stream');
|
||||
|
||||
function safeUrlForLog(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return `${u.origin}${u.pathname}`;
|
||||
} catch {
|
||||
return String(url || '').split('?')[0].slice(0, 200);
|
||||
}
|
||||
}
|
||||
|
||||
function explainFetchFailure(err, url) {
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
const cause = err && err.cause;
|
||||
const code = cause && cause.code ? cause.code : '';
|
||||
const combined = `${msg} ${code}`;
|
||||
const hints = [];
|
||||
if (/CERT|TLS|SSL|UNABLE_TO_VERIFY|SELF_SIGNED|certificate|unknown ca|unable to verify/i.test(combined)) {
|
||||
hints.push(
|
||||
'TLS certificate not trusted — install a valid cert on Gitea, or trust your CA system-wide, or set NODE_EXTRA_CA_CERTS to a .pem bundle (self-signed mirrors)'
|
||||
);
|
||||
}
|
||||
if (/ECONNREFUSED/.test(combined)) hints.push('connection refused (wrong host/port or server down)');
|
||||
if (/ENOTFOUND|EAI_AGAIN/.test(combined)) hints.push('DNS lookup failed');
|
||||
if (/ETIMEDOUT|TIMEOUT/i.test(combined)) hints.push('connection timed out');
|
||||
const hintStr = hints.length ? ` ${hints.join(' ')}` : '';
|
||||
return new Error(`${msg}${hintStr} — ${safeUrlForLog(url)}`);
|
||||
}
|
||||
|
||||
/** Wrap global fetch with clearer errors for TLS/DNS/refused (Electron reports bare "fetch failed"). */
|
||||
async function fetchOrThrow(url, init) {
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (e) {
|
||||
throw explainFetchFailure(e, url);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBodyToFile(res, destPath) {
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => '');
|
||||
@@ -30,11 +66,11 @@ async function downloadBodyToFile(res, destPath) {
|
||||
}
|
||||
|
||||
async function fetchToFile(url, headers, destPath) {
|
||||
const res = await fetch(url, {
|
||||
const res = await fetchOrThrow(url, {
|
||||
headers,
|
||||
redirect: 'follow',
|
||||
});
|
||||
await downloadBodyToFile(res, destPath);
|
||||
}
|
||||
|
||||
module.exports = { fetchToFile, downloadBodyToFile };
|
||||
module.exports = { fetchToFile, downloadBodyToFile, fetchOrThrow, safeUrlForLog };
|
||||
|
||||
@@ -75,8 +75,10 @@ async function loadManifest(cfg) {
|
||||
*/
|
||||
async function patchesMatchManifest(cfg, manifest, onStatus) {
|
||||
if (!validateManifest(manifest)) return false;
|
||||
const { buildResolvedReleaseFiles } = require('./release-sync');
|
||||
const entries = await buildResolvedReleaseFiles(cfg, manifest);
|
||||
const gameDir = cfg.game_dir;
|
||||
for (const entry of cfg.files || []) {
|
||||
for (const entry of entries) {
|
||||
if (!entry.from_release) continue;
|
||||
const spec = manifest.files[entry.source];
|
||||
if (!spec || !spec.sha256) return false;
|
||||
@@ -98,7 +100,9 @@ async function patchesMatchManifest(cfg, manifest, onStatus) {
|
||||
|
||||
async function verifyInstalledAgainstManifest(cfg, manifest) {
|
||||
if (!validateManifest(manifest)) return;
|
||||
for (const entry of cfg.files || []) {
|
||||
const { buildResolvedReleaseFiles } = require('./release-sync');
|
||||
const entries = await buildResolvedReleaseFiles(cfg, manifest);
|
||||
for (const entry of entries) {
|
||||
if (!entry.from_release) continue;
|
||||
const spec = manifest.files[entry.source];
|
||||
if (!spec || !spec.sha256) {
|
||||
@@ -119,8 +123,10 @@ async function verifyInstalledAgainstManifest(cfg, manifest) {
|
||||
|
||||
async function recordPatchState(cfg, manifest) {
|
||||
if (!validateManifest(manifest)) return;
|
||||
const { buildResolvedReleaseFiles } = require('./release-sync');
|
||||
const entries = await buildResolvedReleaseFiles(cfg, manifest);
|
||||
const shas = {};
|
||||
for (const entry of cfg.files || []) {
|
||||
for (const entry of entries) {
|
||||
if (!entry.from_release) continue;
|
||||
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
const destAbs = path.join(cfg.game_dir, ...parts);
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const { downloadGitHubRepoFile, downloadReleaseAsset } = require('./github');
|
||||
const { normalizeWinGameDir } = require('./win-game-dir');
|
||||
const { loadManifest } = require('./patch-manifest');
|
||||
const { buildResolvedReleaseFiles } = require('./release-sync');
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
@@ -12,44 +16,159 @@ 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';
|
||||
const parts = exe.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
return path.join(cfg.game_dir, ...parts);
|
||||
const primary = path.join(gd, ...parts);
|
||||
if (process.platform === 'win32' && gd && fsSync.existsSync(primary)) return primary;
|
||||
if (process.platform === 'win32' && gd) {
|
||||
try {
|
||||
const base = path.basename(primary);
|
||||
const dir = path.dirname(primary);
|
||||
const names = fsSync.readdirSync(dir);
|
||||
const hit = names.find((n) => n.toLowerCase() === base.toLowerCase());
|
||||
if (hit) {
|
||||
const alt = path.join(dir, hit);
|
||||
if (fsSync.statSync(alt).isFile()) return alt;
|
||||
}
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
function wowInstallValid(cfg) {
|
||||
if (!cfg.game_dir) return false;
|
||||
return require('fs').existsSync(wowExePath(cfg));
|
||||
const p = wowExePath(cfg);
|
||||
return fsSync.existsSync(p) && fsSync.statSync(p).isFile();
|
||||
}
|
||||
|
||||
/** WoW expects patch MPQ names with a literal .MPQ extension (case-sensitive clients). */
|
||||
function normalizeMpqDestinationPath(absPath) {
|
||||
const s = String(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 destAbs = path.join(cfg.game_dir, ...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 root = normalizeWinGameDir(cfg.game_dir || '');
|
||||
const destAbs = normalizeMpqDestinationPath(path.join(root, ...parts));
|
||||
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) {
|
||||
@@ -62,18 +181,25 @@ 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;
|
||||
const segs = r.split('/').filter(Boolean);
|
||||
const abs = path.join(cfg.game_dir, ...segs);
|
||||
const abs = path.join(normalizeWinGameDir(cfg.game_dir || ''), ...segs);
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true });
|
||||
await fs.writeFile(abs, content, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPatches(cfg, onStatus) {
|
||||
for (const f of cfg.files || []) {
|
||||
let manifest = null;
|
||||
if (cfg.patch_manifest && cfg.patch_manifest.enabled) {
|
||||
manifest = await loadManifest(cfg);
|
||||
}
|
||||
const entries = await buildResolvedReleaseFiles(cfg, manifest);
|
||||
for (const f of entries) {
|
||||
if (onStatus) onStatus(`Updating ${f.dest} …`);
|
||||
try {
|
||||
await installFile(cfg, f);
|
||||
@@ -85,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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const { listReleaseAttachmentNames } = require('./github');
|
||||
|
||||
/** Legacy launcher.json rows — ignored when merging explicit files. */
|
||||
const DEPRECATED_SOURCES = new Set(['patch-Z.MPQ', 'Wow-patched.exe']);
|
||||
|
||||
function filterExplicitFiles(files) {
|
||||
if (!Array.isArray(files)) return [];
|
||||
return files
|
||||
.filter((e) => e && String(e.source || '').trim())
|
||||
.filter((e) => !DEPRECATED_SOURCES.has(String(e.source).trim()))
|
||||
.map((e) => ({
|
||||
source: String(e.source).trim(),
|
||||
dest: String(e.dest || '').trim(),
|
||||
backup: e.backup !== false,
|
||||
from_release: e.from_release !== false,
|
||||
}))
|
||||
.filter((e) => e.dest);
|
||||
}
|
||||
|
||||
function manifestLooksUsable(m) {
|
||||
return !!(m && m.files && typeof m.files === 'object' && Object.keys(m.files).length > 0);
|
||||
}
|
||||
|
||||
/** Launcher / updater attachments — never copied into the WoW folder. */
|
||||
function isExcludedFromGameSync(fileName) {
|
||||
const n = String(fileName || '');
|
||||
const lower = n.toLowerCase();
|
||||
if (lower === 'patch-manifest.json') return true;
|
||||
if (/^fractured-launcher/i.test(n)) return true;
|
||||
if (/\.blockmap$/i.test(n)) return true;
|
||||
if (/^latest.*\.ya?ml$/i.test(n) || lower === 'latest.yml') return true;
|
||||
if (lower.includes('builder-debug')) return true;
|
||||
if (/\.appimage$/i.test(n)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function mpqDestFromSource(source) {
|
||||
const base = path.basename(String(source || ''));
|
||||
const stem = base.replace(/\.mpq$/i, '');
|
||||
return `Data/enUS/${stem}.MPQ`;
|
||||
}
|
||||
|
||||
function destForReleaseSource(source, cfg) {
|
||||
const base = path.basename(String(source || ''));
|
||||
if (/\.mpq$/i.test(base)) return mpqDestFromSource(source);
|
||||
if (/\.exe$/i.test(base)) return (cfg.launch && cfg.launch.exe) || 'Wow.exe';
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit `files` in config wins. Otherwise use patch-manifest keys if present,
|
||||
* else discover attachments on the release (excluding launcher artifacts).
|
||||
*/
|
||||
async function buildResolvedReleaseFiles(cfg, manifestMaybeNull) {
|
||||
const explicit = filterExplicitFiles(cfg.files);
|
||||
if (explicit.length) return explicit;
|
||||
|
||||
const manifest = manifestMaybeNull;
|
||||
if (manifestLooksUsable(manifest)) {
|
||||
const keys = Object.keys(manifest.files).filter((k) => k && !isExcludedFromGameSync(k));
|
||||
if (!keys.length) {
|
||||
throw new Error('patch-manifest.json has no file entries — add files or attach assets to the release.');
|
||||
}
|
||||
return keys.map((source) => ({
|
||||
source,
|
||||
dest: destForReleaseSource(source, cfg),
|
||||
backup: true,
|
||||
from_release: true,
|
||||
}));
|
||||
}
|
||||
|
||||
const names = await listReleaseAttachmentNames(cfg);
|
||||
const game = names.filter((n) => n && !isExcludedFromGameSync(n));
|
||||
if (!game.length) {
|
||||
throw new Error(
|
||||
'No patch files on this release (after excluding launcher installers). ' +
|
||||
'Attach MPQ/exe assets or ship patch-manifest.json listing filenames.'
|
||||
);
|
||||
}
|
||||
|
||||
const exes = game.filter((n) => /\.exe$/i.test(n));
|
||||
const mpqs = game.filter((n) => /\.mpq$/i.test(n));
|
||||
const rest = game.filter((n) => !/\.(exe|mpq)$/i.test(n));
|
||||
|
||||
if (exes.length > 1) {
|
||||
throw new Error(
|
||||
`Release has multiple .exe files (${exes.join(', ')}). ` +
|
||||
'Remove extras or publish patch-manifest.json with the exact filenames to install.'
|
||||
);
|
||||
}
|
||||
|
||||
const out = [];
|
||||
for (const n of mpqs) {
|
||||
out.push({
|
||||
source: n,
|
||||
dest: mpqDestFromSource(n),
|
||||
backup: true,
|
||||
from_release: true,
|
||||
});
|
||||
}
|
||||
if (exes.length === 1) {
|
||||
out.push({
|
||||
source: exes[0],
|
||||
dest: (cfg.launch && cfg.launch.exe) || 'Wow.exe',
|
||||
backup: true,
|
||||
from_release: true,
|
||||
});
|
||||
}
|
||||
for (const n of rest) {
|
||||
out.push({
|
||||
source: n,
|
||||
dest: path.basename(n),
|
||||
backup: true,
|
||||
from_release: true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildResolvedReleaseFiles,
|
||||
filterExplicitFiles,
|
||||
isExcludedFromGameSync,
|
||||
DEPRECATED_SOURCES,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Under Wine, the folder picker often returns a Unix absolute path (/home/...).
|
||||
* Windows Node does not resolve that to the WoW install; map to Wine's Z: drive
|
||||
* (Z: == / on typical Wine prefixes).
|
||||
*/
|
||||
function normalizeWinGameDir(gameDir) {
|
||||
if (process.platform !== 'win32') return String(gameDir || '').trim();
|
||||
let s = String(gameDir || '').trim();
|
||||
if (!s) return s;
|
||||
s = s.replace(/\//g, path.win32.sep);
|
||||
if (s.startsWith('\\\\')) return path.normalize(s);
|
||||
if (/^[A-Za-z]:/.test(s)) return path.normalize(s);
|
||||
if (s.startsWith(path.win32.sep)) return path.win32.normalize(`Z:${s}`);
|
||||
return path.normalize(s);
|
||||
}
|
||||
|
||||
module.exports = { normalizeWinGameDir };
|
||||
@@ -4,6 +4,7 @@ const { app, BrowserWindow, ipcMain, dialog, Menu } = require('electron');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { loadConfig, saveGameDir, resolveGameDir } = require('./lib/config-store');
|
||||
const { normalizeWinGameDir } = require('./lib/win-game-dir');
|
||||
const { applyPatches, wowExePath, wowInstallValid, doAuth } = require('./lib/patch');
|
||||
const { readPatchState } = require('./lib/patch-manifest');
|
||||
const { setupAutoUpdater } = require('./lib/auto-update');
|
||||
@@ -95,7 +96,8 @@ ipcMain.handle('launcher:saveGameDir', async (_e, dir) => {
|
||||
const trimmed = String(dir || '').trim();
|
||||
if (!trimmed) throw new Error('folder path is empty');
|
||||
const { configPath } = await loadConfig(app);
|
||||
const norm = path.normalize(trimmed);
|
||||
const norm =
|
||||
process.platform === 'win32' ? normalizeWinGameDir(path.normalize(trimmed)) : path.normalize(trimmed);
|
||||
const probe = { ...(await readMergedConfig()).config, game_dir: norm };
|
||||
if (!wowInstallValid(probe)) {
|
||||
throw new Error(`That folder does not contain ${(probe.launch && probe.launch.exe) || 'Wow.exe'}`);
|
||||
@@ -143,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.2",
|
||||
"version": "1.0.12",
|
||||
"description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update",
|
||||
"main": "main.js",
|
||||
"repository": {
|
||||
@@ -36,6 +36,7 @@
|
||||
"renderer.js",
|
||||
"styles.css",
|
||||
"default-launcher.json",
|
||||
"lib/win-game-dir.js",
|
||||
"lib/baked-gitea-channel.js",
|
||||
"lib/gitea-release.js",
|
||||
"lib/patch-manifest.js",
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
/**
|
||||
* Build patch-manifest.json for a release (same names as files[].source in launcher.json).
|
||||
*
|
||||
* Usage (from a folder containing the patch binaries):
|
||||
* node generate-patch-manifest.js v0.9.0-client patch-Z.MPQ Wow-patched.exe
|
||||
* Usage (from a folder containing the patch binaries — list every files[].source name):
|
||||
* node generate-patch-manifest.js v0.9.0-client Wow-patched.exe
|
||||
*
|
||||
* Prints JSON to stdout — redirect to file:
|
||||
* node generate-patch-manifest.js v0.9.0-client patch-Z.MPQ Wow-patched.exe > patch-manifest.json
|
||||
* node generate-patch-manifest.js v0.9.0-client Wow-patched.exe > patch-manifest.json
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
@@ -18,7 +18,7 @@ const version = process.argv[2];
|
||||
const names = process.argv.slice(3);
|
||||
if (!version || names.length === 0) {
|
||||
console.error('Usage: generate-patch-manifest.js <version-label> <file1> [file2 ...]');
|
||||
console.error(' Example: generate-patch-manifest.js v0.9.0-client patch-Z.MPQ Wow-patched.exe');
|
||||
console.error(' Example: generate-patch-manifest.js v0.9.0-client Wow-patched.exe');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#
|
||||
# Usage (from repo root or this directory):
|
||||
# export GH_TOKEN=ghp_... # PAT with repo/releases on the distro repo
|
||||
# ./tools/fractured-launcher-electron/scripts/publish-to-distro.sh v1.0.0 patch-Z.MPQ Wow-patched.exe
|
||||
# ./tools/fractured-launcher-electron/scripts/publish-to-distro.sh v1.0.0 Wow-patched.exe
|
||||
#
|
||||
# Optional:
|
||||
# DISTRO_REPO=YourOrg/Fratured-Distro # if your GitHub slug differs
|
||||
|
||||
@@ -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