Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1c9172beb | |||
| b408c8a95d | |||
| f88a303327 | |||
| 8ad6a2aca3 | |||
| 36ac3dbd1d | |||
| 24d1ae71d9 | |||
| 9cef99f0ff | |||
| f409ffad12 | |||
| c1f7eaa153 | |||
| b455db0db8 | |||
| 1fb284cb5c | |||
| ebd8d81924 | |||
| 362084b829 | |||
| 656cf2d07d | |||
| bfe51f6ad4 | |||
| 2a3107a78d | |||
| 48826e21d6 | |||
| 15c476c12d | |||
| 6c4d7244c3 | |||
| 9fb80102c8 | |||
| 7028258084 | |||
| 5966eb0ffc | |||
| 90c8db0b04 | |||
| 9240bf1243 | |||
| 88f8dcb0e7 | |||
| 9cb3c79dbe | |||
| 75e3b59442 | |||
| 030c2307c2 | |||
| 27d54f15a2 | |||
| 5e18c2b766 | |||
| 1c85341b1f |
@@ -74,8 +74,6 @@ jobs:
|
|||||||
if (Test-Path tools/fractured-launcher-electron/dist/latest.yml) {
|
if (Test-Path tools/fractured-launcher-electron/dist/latest.yml) {
|
||||||
Copy-Item tools/fractured-launcher-electron/dist/latest.yml launcher-publish/
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -87,6 +85,13 @@ jobs:
|
|||||||
if: github.repository == 'Dawnforger/Fractured'
|
if: github.repository == 'Dawnforger/Fractured'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: electron-dist
|
name: electron-dist
|
||||||
@@ -97,6 +102,7 @@ jobs:
|
|||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
. tools/fractured-launcher-electron/scripts/release-sync-filters.sh
|
||||||
TAG="${{ needs.meta.outputs.tag }}"
|
TAG="${{ needs.meta.outputs.tag }}"
|
||||||
mkdir -p combined
|
mkdir -p combined
|
||||||
mkdir -p /tmp/from-main
|
mkdir -p /tmp/from-main
|
||||||
@@ -104,6 +110,11 @@ jobs:
|
|||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
for f in /tmp/from-main/*; do
|
for f in /tmp/from-main/*; do
|
||||||
if [ -f "$f" ]; then
|
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/
|
cp -f "$f" combined/
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
electron-launcher:
|
electron-launcher-windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
defaults:
|
defaults:
|
||||||
@@ -49,4 +49,32 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
tools/fractured-launcher-electron/dist/*.exe
|
tools/fractured-launcher-electron/dist/*.exe
|
||||||
tools/fractured-launcher-electron/dist/latest.yml
|
tools/fractured-launcher-electron/dist/latest.yml
|
||||||
tools/fractured-launcher-electron/dist/*.blockmap
|
|
||||||
|
electron-launcher-linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: tools/fractured-launcher-electron
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: tools/fractured-launcher-electron/package-lock.json
|
||||||
|
|
||||||
|
- name: Install and pack (AppImage)
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run pack:linux
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: fractured-launcher-electron-linux-${{ github.run_id }}
|
||||||
|
if-no-files-found: warn
|
||||||
|
path: |
|
||||||
|
tools/fractured-launcher-electron/dist/*.AppImage
|
||||||
|
tools/fractured-launcher-electron/dist/latest.yml
|
||||||
|
tools/fractured-launcher-electron/dist/latest-linux.yml
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
# Primary path for player-facing binaries: every *published* GitHub Release on this repo
|
||||||
|
# is mirrored to your self-hosted Gitea (same tag). No public GitHub distro repo.
|
||||||
|
#
|
||||||
|
# Triggers:
|
||||||
|
# - release: published / released → GitHub “Release” (not a raw git tag alone).
|
||||||
|
# - workflow_dispatch → Actions → this workflow → “Run workflow” (enter tag).
|
||||||
|
#
|
||||||
|
# Troubleshooting: “Re-run failed jobs” on an OLD run replays the *original* workflow
|
||||||
|
# YAML (e.g. still runs `npm run pack:win` without --publish never). After changing this
|
||||||
|
# file on default branch, start a *new* run via “Run workflow”, not Re-run on a pre-fix run.
|
||||||
|
#
|
||||||
|
# Important: pushing only a git tag does NOT run this — you must create/publish a
|
||||||
|
# Release on github.com (Releases → Draft/new release → Publish). The workflow
|
||||||
|
# definition must exist on the repo DEFAULT branch (GitHub runs it from there).
|
||||||
|
#
|
||||||
|
# Steps: Windows (NSIS+portable) + Linux (AppImage) in parallel, launcher from DEFAULT BRANCH
|
||||||
|
# overlay on tag checkout → merge with GitHub release assets → upload all to Gitea.
|
||||||
|
#
|
||||||
|
# Secrets: GITEA_BASE_URL, GITEA_TOKEN, GITEA_OWNER, GITEA_REPO
|
||||||
|
# Optional variable: GITEA_TARGET_REF (see tools/fractured-launcher-electron/README.md)
|
||||||
|
#
|
||||||
|
# Job guard: edit `if:` if github.repository is not Dawnforger/Fractured.
|
||||||
|
|
||||||
|
name: Sync release to Gitea
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published, released]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
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
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: gitea-release-sync-${{ github.repository }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.event.release.tag_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
meta:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.repository == 'Dawnforger/Fractured'
|
||||||
|
outputs:
|
||||||
|
tag: ${{ steps.t.outputs.tag }}
|
||||||
|
steps:
|
||||||
|
- name: Resolve tag
|
||||||
|
id: t
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
RAW="${{ github.event.inputs.tag }}"
|
||||||
|
else
|
||||||
|
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
|
||||||
|
if: github.repository == 'Dawnforger/Fractured'
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.meta.outputs.tag }}
|
||||||
|
|
||||||
|
# Release tags often point at server/game commits that predate launcher lib fixes.
|
||||||
|
# Always pack the launcher from default branch so app.asar includes the full tree.
|
||||||
|
- name: Overlay launcher from default branch
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
DB="${{ github.event.repository.default_branch }}"
|
||||||
|
git fetch --no-tags --depth=1 origin "+refs/heads/${DB}:refs/remotes/origin/${DB}"
|
||||||
|
git checkout "origin/${DB}" -- tools/fractured-launcher-electron
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: tools/fractured-launcher-electron/package-lock.json
|
||||||
|
|
||||||
|
- name: Install and pack (NSIS + portable)
|
||||||
|
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
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
New-Item -ItemType Directory -Force -Path launcher-publish | Out-Null
|
||||||
|
Copy-Item tools/fractured-launcher-electron/dist/*.exe launcher-publish/
|
||||||
|
if (Test-Path tools/fractured-launcher-electron/dist/latest.yml) {
|
||||||
|
Copy-Item tools/fractured-launcher-electron/dist/latest.yml launcher-publish/
|
||||||
|
}
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: electron-dist-windows
|
||||||
|
path: launcher-publish/
|
||||||
|
|
||||||
|
build-electron-linux:
|
||||||
|
needs: meta
|
||||||
|
if: github.repository == 'Dawnforger/Fractured'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.meta.outputs.tag }}
|
||||||
|
|
||||||
|
- name: Overlay launcher from default branch
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
DB="${{ github.event.repository.default_branch }}"
|
||||||
|
git fetch --no-tags --depth=1 origin "+refs/heads/${DB}:refs/remotes/origin/${DB}"
|
||||||
|
git checkout "origin/${DB}" -- tools/fractured-launcher-electron
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: tools/fractured-launcher-electron/package-lock.json
|
||||||
|
|
||||||
|
- name: Install and pack (AppImage)
|
||||||
|
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
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p launcher-linux-publish
|
||||||
|
shopt -s nullglob
|
||||||
|
cp -f tools/fractured-launcher-electron/dist/*.AppImage 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
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: electron-dist-linux
|
||||||
|
path: launcher-linux-publish/
|
||||||
|
|
||||||
|
sync-gitea:
|
||||||
|
needs: [meta, build-electron, build-electron-linux]
|
||||||
|
if: github.repository == 'Dawnforger/Fractured'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_OWNER: ${{ secrets.GITEA_OWNER }}
|
||||||
|
GITEA_REPO: ${{ secrets.GITEA_REPO }}
|
||||||
|
GITEA_TARGET_REF: ${{ vars.GITEA_TARGET_REF }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Script may not exist on older release tags; always use default branch.
|
||||||
|
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-windows
|
||||||
|
path: /tmp/electron-win
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: electron-dist-linux
|
||||||
|
path: /tmp/electron-linux
|
||||||
|
|
||||||
|
- name: Merge GitHub release assets + Electron build
|
||||||
|
env:
|
||||||
|
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
|
||||||
|
if gh release download "$TAG" -R "${{ github.repository }}" -D /tmp/from-main 2>/tmp/dl.err; then
|
||||||
|
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
|
||||||
|
echo "Merged assets from ${{ github.repository }} release $TAG"
|
||||||
|
else
|
||||||
|
echo "GitHub release download note (continuing with launcher only):"
|
||||||
|
cat /tmp/dl.err || true
|
||||||
|
fi
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in /tmp/electron-win/* /tmp/electron-linux/*; do
|
||||||
|
if [ -f "$f" ]; then
|
||||||
|
cp -f "$f" combined/
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
ls -la combined/
|
||||||
|
|
||||||
|
- name: Upload to Gitea
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for v in GITEA_BASE_URL GITEA_TOKEN GITEA_OWNER GITEA_REPO; do
|
||||||
|
if [ -z "${!v:-}" ]; then
|
||||||
|
echo "Missing secret $v — add it under repo Settings → Secrets and variables → Actions." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
bash tools/fractured-launcher-electron/scripts/upload-release-to-gitea.sh combined "${{ needs.meta.outputs.tag }}"
|
||||||
@@ -21,6 +21,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(10, 1),
|
(10, 1),
|
||||||
(17, 1),
|
(17, 1),
|
||||||
(53, 1),
|
(53, 1),
|
||||||
|
(66, 1),
|
||||||
(72, 1),
|
(72, 1),
|
||||||
(75, 1),
|
(75, 1),
|
||||||
(78, 1),
|
(78, 1),
|
||||||
@@ -30,6 +31,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(118, 1),
|
(118, 1),
|
||||||
(120, 1),
|
(120, 1),
|
||||||
(122, 1),
|
(122, 1),
|
||||||
|
(126, 1),
|
||||||
(130, 1),
|
(130, 1),
|
||||||
(131, 1),
|
(131, 1),
|
||||||
(132, 1),
|
(132, 1),
|
||||||
@@ -52,6 +54,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(469, 1),
|
(469, 1),
|
||||||
(475, 1),
|
(475, 1),
|
||||||
(498, 1),
|
(498, 1),
|
||||||
|
(526, 1),
|
||||||
(527, 1),
|
(527, 1),
|
||||||
(528, 1),
|
(528, 1),
|
||||||
(543, 1),
|
(543, 1),
|
||||||
@@ -73,22 +76,28 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(676, 1),
|
(676, 1),
|
||||||
(686, 1),
|
(686, 1),
|
||||||
(687, 1),
|
(687, 1),
|
||||||
|
(688, 1),
|
||||||
(689, 1),
|
(689, 1),
|
||||||
|
(691, 1),
|
||||||
(693, 1),
|
(693, 1),
|
||||||
(694, 1),
|
(694, 1),
|
||||||
|
(697, 1),
|
||||||
(698, 1),
|
(698, 1),
|
||||||
(702, 1),
|
(702, 1),
|
||||||
(703, 1),
|
(703, 1),
|
||||||
(706, 1),
|
(706, 1),
|
||||||
(710, 1),
|
(710, 1),
|
||||||
|
(712, 1),
|
||||||
(740, 1),
|
(740, 1),
|
||||||
(755, 1),
|
(755, 1),
|
||||||
(759, 1),
|
(759, 1),
|
||||||
|
(768, 1),
|
||||||
(770, 1),
|
(770, 1),
|
||||||
(772, 1),
|
(772, 1),
|
||||||
(774, 1),
|
(774, 1),
|
||||||
(779, 1),
|
(779, 1),
|
||||||
(781, 1),
|
(781, 1),
|
||||||
|
(783, 1),
|
||||||
(845, 1),
|
(845, 1),
|
||||||
(853, 1),
|
(853, 1),
|
||||||
(871, 1),
|
(871, 1),
|
||||||
@@ -104,6 +113,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(1038, 1),
|
(1038, 1),
|
||||||
(1044, 1),
|
(1044, 1),
|
||||||
(1064, 1),
|
(1064, 1),
|
||||||
|
(1066, 1),
|
||||||
(1079, 1),
|
(1079, 1),
|
||||||
(1082, 1),
|
(1082, 1),
|
||||||
(1098, 1),
|
(1098, 1),
|
||||||
@@ -176,6 +186,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(2812, 1),
|
(2812, 1),
|
||||||
(2825, 1),
|
(2825, 1),
|
||||||
(2893, 1),
|
(2893, 1),
|
||||||
|
(2894, 1),
|
||||||
(2908, 1),
|
(2908, 1),
|
||||||
(2912, 1),
|
(2912, 1),
|
||||||
(2944, 1),
|
(2944, 1),
|
||||||
@@ -194,6 +205,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(3565, 1),
|
(3565, 1),
|
||||||
(3566, 1),
|
(3566, 1),
|
||||||
(3567, 1),
|
(3567, 1),
|
||||||
|
(3714, 1),
|
||||||
(3738, 1),
|
(3738, 1),
|
||||||
(4987, 1),
|
(4987, 1),
|
||||||
(5116, 1),
|
(5116, 1),
|
||||||
@@ -205,7 +217,9 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(5185, 1),
|
(5185, 1),
|
||||||
(5209, 1),
|
(5209, 1),
|
||||||
(5211, 1),
|
(5211, 1),
|
||||||
(5215, 1),
|
(5215, 1);
|
||||||
|
|
||||||
|
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||||
(5217, 1),
|
(5217, 1),
|
||||||
(5221, 1),
|
(5221, 1),
|
||||||
(5225, 1),
|
(5225, 1),
|
||||||
@@ -215,11 +229,10 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(5308, 1),
|
(5308, 1),
|
||||||
(5384, 1),
|
(5384, 1),
|
||||||
(5484, 1),
|
(5484, 1),
|
||||||
|
(5487, 1),
|
||||||
(5500, 1),
|
(5500, 1),
|
||||||
(5502, 1),
|
(5502, 1),
|
||||||
(5504, 1);
|
(5504, 1),
|
||||||
|
|
||||||
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|
||||||
(5675, 1),
|
(5675, 1),
|
||||||
(5676, 1),
|
(5676, 1),
|
||||||
(5697, 1),
|
(5697, 1),
|
||||||
@@ -244,6 +257,8 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(6770, 1),
|
(6770, 1),
|
||||||
(6785, 1),
|
(6785, 1),
|
||||||
(6789, 1),
|
(6789, 1),
|
||||||
|
(6795, 1),
|
||||||
|
(6807, 1),
|
||||||
(6940, 1),
|
(6940, 1),
|
||||||
(7294, 1),
|
(7294, 1),
|
||||||
(7302, 1),
|
(7302, 1),
|
||||||
@@ -283,6 +298,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(11418, 1),
|
(11418, 1),
|
||||||
(11419, 1),
|
(11419, 1),
|
||||||
(11420, 1),
|
(11420, 1),
|
||||||
|
(12051, 1),
|
||||||
(13159, 1),
|
(13159, 1),
|
||||||
(13161, 1),
|
(13161, 1),
|
||||||
(13163, 1),
|
(13163, 1),
|
||||||
@@ -297,6 +313,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(16857, 1),
|
(16857, 1),
|
||||||
(16914, 1),
|
(16914, 1),
|
||||||
(18499, 1),
|
(18499, 1),
|
||||||
|
(19263, 1),
|
||||||
(19740, 1),
|
(19740, 1),
|
||||||
(19742, 1),
|
(19742, 1),
|
||||||
(19746, 1),
|
(19746, 1),
|
||||||
@@ -323,7 +340,6 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(20252, 1),
|
(20252, 1),
|
||||||
(20484, 1),
|
(20484, 1),
|
||||||
(20736, 1),
|
(20736, 1),
|
||||||
(21084, 1),
|
|
||||||
(21562, 1),
|
(21562, 1),
|
||||||
(21849, 1),
|
(21849, 1),
|
||||||
(22568, 1),
|
(22568, 1),
|
||||||
@@ -331,6 +347,8 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(22812, 1),
|
(22812, 1),
|
||||||
(22842, 1),
|
(22842, 1),
|
||||||
(23028, 1),
|
(23028, 1),
|
||||||
|
(23161, 1),
|
||||||
|
(23214, 1),
|
||||||
(23920, 1),
|
(23920, 1),
|
||||||
(23922, 1),
|
(23922, 1),
|
||||||
(24275, 1),
|
(24275, 1),
|
||||||
@@ -349,6 +367,7 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(29722, 1),
|
(29722, 1),
|
||||||
(29858, 1),
|
(29858, 1),
|
||||||
(29893, 1),
|
(29893, 1),
|
||||||
|
(30449, 1),
|
||||||
(30451, 1),
|
(30451, 1),
|
||||||
(30455, 1),
|
(30455, 1),
|
||||||
(30482, 1),
|
(30482, 1),
|
||||||
@@ -372,12 +391,14 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(33745, 1),
|
(33745, 1),
|
||||||
(33763, 1),
|
(33763, 1),
|
||||||
(33786, 1),
|
(33786, 1),
|
||||||
|
(33943, 1),
|
||||||
(34026, 1),
|
(34026, 1),
|
||||||
(34074, 1),
|
(34074, 1),
|
||||||
(34428, 1),
|
(34428, 1),
|
||||||
(34433, 1),
|
(34433, 1),
|
||||||
(34477, 1),
|
(34477, 1),
|
||||||
(34600, 1),
|
(34600, 1),
|
||||||
|
(34767, 1),
|
||||||
(35715, 1),
|
(35715, 1),
|
||||||
(35717, 1),
|
(35717, 1),
|
||||||
(36936, 1),
|
(36936, 1),
|
||||||
@@ -398,7 +419,9 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(47541, 1),
|
(47541, 1),
|
||||||
(47568, 1),
|
(47568, 1),
|
||||||
(47897, 1),
|
(47897, 1),
|
||||||
(48018, 1),
|
(48018, 1);
|
||||||
|
|
||||||
|
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
||||||
(48020, 1),
|
(48020, 1),
|
||||||
(48045, 1),
|
(48045, 1),
|
||||||
(48263, 1),
|
(48263, 1),
|
||||||
@@ -416,14 +439,19 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(49576, 1),
|
(49576, 1),
|
||||||
(49998, 1),
|
(49998, 1),
|
||||||
(50464, 1),
|
(50464, 1),
|
||||||
|
(50769, 1),
|
||||||
(50842, 1),
|
(50842, 1),
|
||||||
|
(51505, 1),
|
||||||
|
(51514, 1),
|
||||||
(51722, 1),
|
(51722, 1),
|
||||||
(51723, 1),
|
(51723, 1),
|
||||||
(52610, 1);
|
(51730, 1),
|
||||||
|
(52127, 1),
|
||||||
INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
(52610, 1),
|
||||||
(53140, 1),
|
(53140, 1),
|
||||||
(53142, 1),
|
(53142, 1),
|
||||||
|
(53271, 1),
|
||||||
|
(53351, 1),
|
||||||
(53407, 1),
|
(53407, 1),
|
||||||
(53408, 1),
|
(53408, 1),
|
||||||
(53600, 1),
|
(53600, 1),
|
||||||
@@ -432,15 +460,23 @@ INSERT INTO `paragon_spell_ae_cost` (`spell_id`, `ae_cost`) VALUES
|
|||||||
(54428, 1),
|
(54428, 1),
|
||||||
(55342, 1),
|
(55342, 1),
|
||||||
(55694, 1),
|
(55694, 1),
|
||||||
|
(56222, 1),
|
||||||
(56641, 1),
|
(56641, 1),
|
||||||
|
(56815, 1),
|
||||||
|
(57330, 1),
|
||||||
(57755, 1),
|
(57755, 1),
|
||||||
(57934, 1),
|
(57934, 1),
|
||||||
(57994, 1),
|
(57994, 1),
|
||||||
(60192, 1),
|
(60192, 1),
|
||||||
(61846, 1),
|
(61846, 1),
|
||||||
|
(61999, 1),
|
||||||
(62078, 1),
|
(62078, 1),
|
||||||
(62124, 1),
|
(62124, 1),
|
||||||
(62757, 1),
|
(62757, 1),
|
||||||
(64382, 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);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
void AddSC_paragon()
|
||||||
{
|
{
|
||||||
new Paragon_PlayerScript();
|
new Paragon_PlayerScript();
|
||||||
RegisterSpellScript(spell_paragon_arcane_torrent);
|
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"
|
||||||
Executable
+336
@@ -0,0 +1,336 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Collect VPS evidence for Paragon / DBUpdater / binary staleness triage.
|
||||||
|
# Run ON the VPS (Linux). Safe: read-only; does not restart services.
|
||||||
|
#
|
||||||
|
# Usage (from clone):
|
||||||
|
# bash scripts/vps-paragon-diagnostics.sh
|
||||||
|
#
|
||||||
|
# Optional environment:
|
||||||
|
# FRACTURED_REPO — absolute path to Fractured git root (default: parent of scripts/)
|
||||||
|
# FRACTURED_WS_BIN — path to worldserver binary (default: auto-detect)
|
||||||
|
# FRACTURED_WORLDSERVER_CONF — path to worldserver.conf (default: guess from BIN + common layouts)
|
||||||
|
# FRACTURED_SYSTEMD_UNITS — space-separated units to try (default: "fractured-world worldserver ac-worldserver")
|
||||||
|
# FRACTURED_MYSQL — prefix to invoke mysql, e.g. 'mysql -uacore -pacore -h127.0.0.1'
|
||||||
|
# (default Fractured local DB user/password are often both "acore"; use ~/.my.cnf if you prefer not to pass -p on the command line)
|
||||||
|
# If unset, SQL blocks are printed for manual copy-paste only.
|
||||||
|
# FRACTURED_SPELL_IDS — space-separated spell IDs for spell_dbc spot-check (defaults to common DK rune spenders)
|
||||||
|
# FRACTURED_DIAG_OUTPUT — full log file path (default: <repo>/var/vps-paragon-diagnostics-last.txt)
|
||||||
|
#
|
||||||
|
# All output is mirrored to the log file (tee) while still printing to the terminal.
|
||||||
|
# Default path lives under var/ (gitignored in this repo). Open that file in Cursor,
|
||||||
|
# scp it down, or: git add -f var/vps-paragon-diagnostics-last.txt if you intend to commit it.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO="${FRACTURED_REPO:-$(cd "$SCRIPT_DIR/.." && pwd)}"
|
||||||
|
|
||||||
|
DIAG_OUT="${FRACTURED_DIAG_OUTPUT:-$REPO/var/vps-paragon-diagnostics-last.txt}"
|
||||||
|
mkdir -p "$(dirname "$DIAG_OUT")"
|
||||||
|
exec > >(tee "$DIAG_OUT") 2>&1
|
||||||
|
echo "Logging to: $DIAG_OUT"
|
||||||
|
|
||||||
|
hr() { printf '\n%s\n' "================================================================================"; }
|
||||||
|
sub() { printf '\n-- %s\n' "$1"; }
|
||||||
|
|
||||||
|
detect_worldserver_bin() {
|
||||||
|
local bin="" es path u units
|
||||||
|
if [[ -n "${FRACTURED_WS_BIN:-}" ]]; then
|
||||||
|
readlink -f "$FRACTURED_WS_BIN" 2>/dev/null && return
|
||||||
|
echo "$FRACTURED_WS_BIN"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
units="${FRACTURED_SYSTEMD_UNITS:-fractured-world worldserver ac-worldserver}"
|
||||||
|
for u in $units; do
|
||||||
|
if systemctl is-active --quiet "$u" 2>/dev/null || systemctl is-enabled --quiet "$u" 2>/dev/null; then
|
||||||
|
es=$(systemctl show "$u" -p ExecStart --value 2>/dev/null || true)
|
||||||
|
if [[ -n "$es" ]]; then
|
||||||
|
if [[ "$es" == \{*path=* ]]; then
|
||||||
|
path=$(printf '%s' "$es" | sed -n 's/.*path=\([^;]*\).*/\1/p')
|
||||||
|
else
|
||||||
|
path=$(printf '%s' "$es" | awk '{print $1}' | sed 's/^path=//')
|
||||||
|
fi
|
||||||
|
if [[ -n "$path" && -x "$path" ]]; then
|
||||||
|
readlink -f "$path" 2>/dev/null && return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
local pid
|
||||||
|
pid=$(pgrep -xo worldserver 2>/dev/null || true)
|
||||||
|
if [[ -n "$pid" ]]; then
|
||||||
|
readlink -f "/proc/$pid/exe" 2>/dev/null && return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v worldserver >/dev/null 2>&1; then
|
||||||
|
readlink -f "$(command -v worldserver)" 2>/dev/null && return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
guess_worldserver_conf() {
|
||||||
|
local bin="$1"
|
||||||
|
local d cands=()
|
||||||
|
[[ -z "$bin" ]] && return
|
||||||
|
d=$(dirname "$bin")
|
||||||
|
cands+=("$d/../etc/worldserver.conf")
|
||||||
|
cands+=("$d/../../etc/worldserver.conf")
|
||||||
|
cands+=("$HOME/azeroth-server/etc/worldserver.conf")
|
||||||
|
cands+=("$HOME/env/dist/etc/worldserver.conf")
|
||||||
|
for f in "${cands[@]}"; do
|
||||||
|
f=$(readlink -f "$f" 2>/dev/null || true)
|
||||||
|
if [[ -n "$f" && -f "$f" ]]; then
|
||||||
|
echo "$f"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
binary_strings_paths() {
|
||||||
|
local ws="$1"
|
||||||
|
[[ -z "$ws" || ! -f "$ws" ]] && return
|
||||||
|
strings "$ws" 2>/dev/null | grep -iE '/(home|root|opt|srv|var)[^[:space:]]*/(Fractured|fractured|azeroth|AzerothCore|acore)' | sort -u | head -40
|
||||||
|
}
|
||||||
|
|
||||||
|
hr
|
||||||
|
echo "Fractured Paragon / native VPS diagnostics"
|
||||||
|
echo "Date (UTC): $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||||
|
echo "Repo (expected): $REPO"
|
||||||
|
|
||||||
|
sub "1A — worldserver binary"
|
||||||
|
WS=$(detect_worldserver_bin || true)
|
||||||
|
if [[ -z "$WS" ]]; then
|
||||||
|
echo "ERROR: Could not find worldserver. Set FRACTURED_WS_BIN=/full/path/to/worldserver and re-run."
|
||||||
|
else
|
||||||
|
echo "Binary: $WS"
|
||||||
|
if stat -c 'binary mtime: %y' "$WS" 2>/dev/null; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
stat -f 'binary mtime: %Sm' -t '%Y-%m-%d %H:%M:%S %z' "$WS" 2>/dev/null || stat "$WS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
sub "1B — repo HEAD + Paragon_Essence.cpp mtime"
|
||||||
|
if [[ -d "$REPO/.git" ]]; then
|
||||||
|
(cd "$REPO" && git log -1 --format='HEAD commit: %h %ci %s')
|
||||||
|
else
|
||||||
|
echo "WARN: not a git repo: $REPO (set FRACTURED_REPO)"
|
||||||
|
fi
|
||||||
|
PE="$REPO/modules/mod-paragon/src/Paragon_Essence.cpp"
|
||||||
|
if [[ -f "$PE" ]]; then
|
||||||
|
if stat -c 'Paragon_Essence.cpp mtime: %y' "$PE" 2>/dev/null; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
stat -f 'Paragon_Essence.cpp mtime: %Sm' -t '%Y-%m-%d %H:%M:%S %z' "$PE" 2>/dev/null || stat "$PE"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "WARN: missing $PE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sub "1C — strings heuristics (0 can mean stripped binary — use 1A+1B)"
|
||||||
|
if [[ -n "$WS" && -f "$WS" ]]; then
|
||||||
|
c1=$(strings "$WS" 2>/dev/null | grep -c 'CLASS_PARAGON' || true)
|
||||||
|
c2=$(strings "$WS" 2>/dev/null | grep -c 'C BUILD SAVE_CURRENT' || true)
|
||||||
|
c3=$(strings "$WS" 2>/dev/null | grep -c 'character_paragon_build_share_archive' || true)
|
||||||
|
echo "CLASS_PARAGON count: $c1"
|
||||||
|
echo "C BUILD SAVE_CURRENT count: $c2"
|
||||||
|
echo "character_paragon_build_share_archive count: $c3"
|
||||||
|
else
|
||||||
|
echo "(skipped — no binary)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sub "1D — binary fingerprint (compare sha256 across dev vs VPS)"
|
||||||
|
if [[ -n "$WS" && -f "$WS" ]]; then
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum "$WS"
|
||||||
|
elif command -v shasum >/dev/null 2>&1; then
|
||||||
|
shasum -a 256 "$WS"
|
||||||
|
else
|
||||||
|
echo "(no sha256sum — install coreutils)"
|
||||||
|
fi
|
||||||
|
echo "Embedded revision / version strings (first matches):"
|
||||||
|
strings "$WS" 2>/dev/null | grep -iE 'azerothcore|revision|git|commit|build.*20[0-9]{2}' | head -25 || echo "(none matched)"
|
||||||
|
else
|
||||||
|
echo "(skipped — no binary)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CONF="${FRACTURED_WORLDSERVER_CONF:-}"
|
||||||
|
if [[ -z "$CONF" && -n "$WS" ]]; then
|
||||||
|
CONF=$(guess_worldserver_conf "$WS")
|
||||||
|
fi
|
||||||
|
|
||||||
|
sub "2B — worldserver.conf (updater / source / rates / paragon)"
|
||||||
|
if [[ -n "$CONF" && -f "$CONF" ]]; then
|
||||||
|
echo "Using conf: $CONF"
|
||||||
|
grep -E '^SourceDirectory|^Updates\.EnableDatabases|^Updates\.AutoSetup|^[[:space:]]*SourceDirectory|^[[:space:]]*Updates\.EnableDatabases|^[[:space:]]*Updates\.AutoSetup' "$CONF" 2>/dev/null || echo "(no matching lines or unreadable)"
|
||||||
|
echo "--- Rate.RunicPower (if set) ---"
|
||||||
|
grep -iE '^Rate\.RunicPower|^[[:space:]]*Rate\.RunicPower' "$CONF" 2>/dev/null || echo "(not set — server uses default)"
|
||||||
|
echo "--- Paragon.* module options (if any) ---"
|
||||||
|
grep -iE '^Paragon\.|^[[:space:]]*Paragon\.' "$CONF" 2>/dev/null || echo "(no Paragon.* keys in worldserver.conf — check etc/modules/mod_paragon.conf)"
|
||||||
|
else
|
||||||
|
echo "WARN: worldserver.conf not found. Set FRACTURED_WORLDSERVER_CONF=/path/to/worldserver.conf"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$WS" && -f "$WS" ]]; then
|
||||||
|
ETCGuess=$(readlink -f "$(dirname "$WS")/../etc" 2>/dev/null || true)
|
||||||
|
MPC="$ETCGuess/modules/mod_paragon.conf"
|
||||||
|
if [[ -f "$MPC" ]]; then
|
||||||
|
sub "2B2 — mod_paragon.conf Paragon.* toggles (non-comment)"
|
||||||
|
grep -E '^Paragon\.' "$MPC" 2>/dev/null | head -40 || echo "(no uncommented Paragon.* lines)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
sub "2A — path-like strings from binary (candidate source roots)"
|
||||||
|
if [[ -n "$WS" && -f "$WS" ]]; then
|
||||||
|
binary_strings_paths "$WS" || true
|
||||||
|
else
|
||||||
|
echo "(skipped)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sub "Resolved source root for 2D"
|
||||||
|
RESOLVED=""
|
||||||
|
if [[ -n "$CONF" && -f "$CONF" ]]; then
|
||||||
|
sd=$(awk -F= '/^[[:space:]]*SourceDirectory[[:space:]]*=/ {
|
||||||
|
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2);
|
||||||
|
gsub(/^["'\'']|["'\'']$/, "", $2);
|
||||||
|
print $2; exit }' "$CONF" 2>/dev/null || true)
|
||||||
|
if [[ -n "${sd:-}" ]]; then
|
||||||
|
RESOLVED="$sd"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -z "$RESOLVED" ]]; then
|
||||||
|
RESOLVED="$REPO"
|
||||||
|
fi
|
||||||
|
echo "Using RESOLVED=$RESOLVED (from SourceDirectory if set in conf, else FRACTURED_REPO)"
|
||||||
|
|
||||||
|
sub "2D — Paragon SQL dirs under RESOLVED"
|
||||||
|
for subdir in \
|
||||||
|
"$RESOLVED/modules/mod-paragon/data/sql/db-world/updates/" \
|
||||||
|
"$RESOLVED/modules/mod-paragon/data/sql/db-characters/updates/"; do
|
||||||
|
if [[ -d "$subdir" ]]; then
|
||||||
|
echo "Listing: $subdir"
|
||||||
|
ls -la "$subdir" 2>/dev/null | tail -15
|
||||||
|
else
|
||||||
|
echo "MISSING: $subdir"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
sub "CMake build dir hints (common Fractured layouts)"
|
||||||
|
for cand in "$REPO/var/build/obj" "$REPO/build" "$REPO/../build"; do
|
||||||
|
if [[ -f "$cand/CMakeCache.txt" ]]; then
|
||||||
|
echo "Found CMakeCache: $cand/CMakeCache.txt"
|
||||||
|
grep -E '^CMAKE_HOME_DIRECTORY:|^MODULES:|^CMAKE_INSTALL_PREFIX:' "$cand/CMakeCache.txt" 2>/dev/null | head -5
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
sub "DATABASE — updates rows (2026_05_10 / paragon)"
|
||||||
|
SQL_WORLD=$(cat <<'EOS'
|
||||||
|
SELECT name, hash, speed FROM updates
|
||||||
|
WHERE name LIKE '2026_05_10%' OR name LIKE '%paragon%'
|
||||||
|
ORDER BY name DESC LIMIT 30;
|
||||||
|
EOS
|
||||||
|
)
|
||||||
|
SQL_CHAR="$SQL_WORLD"
|
||||||
|
|
||||||
|
if [[ -n "${FRACTURED_MYSQL:-}" ]]; then
|
||||||
|
echo "--- acore_world ---"
|
||||||
|
$FRACTURED_MYSQL acore_world -e "$SQL_WORLD" || echo "(mysql failed for acore_world)"
|
||||||
|
echo "--- acore_characters ---"
|
||||||
|
$FRACTURED_MYSQL acore_characters -e "$SQL_CHAR" || echo "(mysql failed for acore_characters)"
|
||||||
|
|
||||||
|
sub "DATABASE — DBC parity for runes / Paragon (acore_world)"
|
||||||
|
# Common DK rune spenders (WotLK). Override: export FRACTURED_SPELL_IDS='45477 45462'
|
||||||
|
SPELL_IDS="${FRACTURED_SPELL_IDS:-45477 45462 49923 55050 56815}"
|
||||||
|
IDS_CSV=$(echo "$SPELL_IDS" | tr ' ' ',')
|
||||||
|
echo "--- spell_dbc table size (world DB overrides; 0 rows = all spells from disk DBC only) ---"
|
||||||
|
$FRACTURED_MYSQL acore_world -e "SELECT COUNT(*) AS spell_dbc_rows FROM spell_dbc;" 2>/dev/null || echo "(spell_dbc missing or no access)"
|
||||||
|
echo "--- acore_world.version (last core revision written by worldserver) ---"
|
||||||
|
$FRACTURED_MYSQL acore_world -e "SELECT * FROM version LIMIT 5;" 2>/dev/null || echo "(version table missing?)"
|
||||||
|
|
||||||
|
echo "--- chrclasses_dbc class 6 + 12 (DisplayPower: 0=mana, 5=POWER_RUNE in AC) ---"
|
||||||
|
$FRACTURED_MYSQL acore_world -e "
|
||||||
|
SELECT ID, DisplayPower, Name_Lang_enUS FROM chrclasses_dbc WHERE ID IN (6,12);
|
||||||
|
" 2>/dev/null || echo "(query failed — chrclasses_dbc missing?)"
|
||||||
|
echo "Note: If only ID=12 appears, class 6 (DK) is not overridden in DB — loaded from disk DBC (normal)."
|
||||||
|
|
||||||
|
echo "--- spell_dbc: are sample DK spells overridden in DB? ---"
|
||||||
|
spell_sample_n=$($FRACTURED_MYSQL acore_world -N -B -e \
|
||||||
|
"SELECT COUNT(*) FROM spell_dbc WHERE ID IN ($IDS_CSV);" 2>/dev/null || echo 0)
|
||||||
|
echo "Row count in spell_dbc for sample IDs ($SPELL_IDS): ${spell_sample_n:-0}"
|
||||||
|
if [[ "${spell_sample_n:-0}" == "0" ]]; then
|
||||||
|
echo "=> 0 means those spells use on-disk Spell.dbc only; the sample block below will be empty (not an error)."
|
||||||
|
fi
|
||||||
|
echo "--- spell_dbc sample (PowerType 5 = POWER_RUNE in AC) ---"
|
||||||
|
$FRACTURED_MYSQL acore_world -e "
|
||||||
|
SELECT ID, PowerType, ManaCost, RuneCostID FROM spell_dbc WHERE ID IN ($IDS_CSV);
|
||||||
|
" 2>/dev/null || echo "(query failed — spell_dbc missing or wrong schema)"
|
||||||
|
echo "--- spellrunecost join for sample IDs (empty if no spell_dbc rows above) ---"
|
||||||
|
$FRACTURED_MYSQL acore_world -e "
|
||||||
|
SELECT s.ID AS spell_id, s.PowerType, s.RuneCostID, r.Blood, r.Unholy, r.Frost, r.RunicPower
|
||||||
|
FROM spell_dbc s
|
||||||
|
LEFT JOIN spellrunecost_dbc r ON r.ID = s.RuneCostID
|
||||||
|
WHERE s.ID IN ($IDS_CSV);
|
||||||
|
" 2>/dev/null || echo "(join failed — check spellrunecost_dbc)"
|
||||||
|
|
||||||
|
echo "--- spell_dbc suspicious overrides: RuneCostID>0 but PowerType!=5 (can break rune checks) ---"
|
||||||
|
$FRACTURED_MYSQL acore_world -e "
|
||||||
|
SELECT ID, PowerType, ManaCost, RuneCostID FROM spell_dbc
|
||||||
|
WHERE RuneCostID > 0 AND PowerType <> 5
|
||||||
|
ORDER BY ID LIMIT 40;
|
||||||
|
" 2>/dev/null || echo "(query failed)"
|
||||||
|
echo "Compare counts/IDs to dev: unexpected rows here warrant a DB diff."
|
||||||
|
|
||||||
|
echo "--- spell_dbc POWER_RUNE (5) spells with RuneCostID (sample) ---"
|
||||||
|
$FRACTURED_MYSQL acore_world -e "
|
||||||
|
SELECT ID, PowerType, RuneCostID FROM spell_dbc
|
||||||
|
WHERE PowerType = 5 AND RuneCostID > 0
|
||||||
|
ORDER BY ID LIMIT 15;
|
||||||
|
" 2>/dev/null || echo "(query failed)"
|
||||||
|
else
|
||||||
|
echo "FRACTURED_MYSQL not set — run manually (example: export FRACTURED_MYSQL='mysql -uUSER -hHOST')"
|
||||||
|
echo "acore_world:"
|
||||||
|
echo "$SQL_WORLD"
|
||||||
|
echo "acore_characters:"
|
||||||
|
echo "$SQL_CHAR"
|
||||||
|
echo ""
|
||||||
|
echo "Optional DBC parity (acore_world) — run after connecting:"
|
||||||
|
echo " SELECT ID, DisplayPower, Name_Lang_enUS FROM chrclasses_dbc WHERE ID IN (6,12);"
|
||||||
|
echo " SELECT ID, PowerType, ManaCost, RuneCostID FROM spell_dbc WHERE ID IN (45477,45462,49923,55050,56815);"
|
||||||
|
echo " SELECT s.ID, s.RuneCostID, r.Blood, r.Unholy, r.Frost, r.RunicPower FROM spell_dbc s"
|
||||||
|
echo " LEFT JOIN spellrunecost_dbc r ON r.ID = s.RuneCostID WHERE s.ID IN (45477,45462,49923,55050,56815);"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sub "mod_paragon.conf vs .dist (install etc)"
|
||||||
|
ETC=""
|
||||||
|
if [[ -n "$WS" ]]; then
|
||||||
|
ETC=$(readlink -f "$(dirname "$WS")/../etc" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [[ -z "$ETC" || ! -d "$ETC" ]]; then
|
||||||
|
ETC=$(readlink -f "$HOME/azeroth-server/etc" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [[ -n "$ETC" && -d "$ETC/modules" ]]; then
|
||||||
|
MP="$ETC/modules/mod_paragon.conf"
|
||||||
|
MPD="$ETC/modules/mod_paragon.conf.dist"
|
||||||
|
if [[ -f "$MP" && -f "$MPD" ]]; then
|
||||||
|
diff -u "$MP" "$MPD" 2>/dev/null | head -80 || true
|
||||||
|
else
|
||||||
|
echo "ETC=$ETC — mod_paragon.conf or .dist missing (MP=$MP MPD=$MPD)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Could not find install etc/modules (set paths manually for diff)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
hr
|
||||||
|
echo "DELIVERABLE for maintainer:"
|
||||||
|
echo "1) Paste 1A–1D (binary mtime, git HEAD, strings, sha256 + revision strings)."
|
||||||
|
echo "2) Paste DATABASE blocks: updates + DBC parity (chrclasses 12, spell_dbc, spellrunecost join)."
|
||||||
|
echo "3) Paste 2A path strings + 2D listings (or MISSING lines)."
|
||||||
|
echo "4) From dev: same 1D sha256 of worldserver OR same SQL block — proves binary/data parity."
|
||||||
|
echo "5) ONE sentence: exact in-game symptom."
|
||||||
|
echo "Done."
|
||||||
|
echo ""
|
||||||
|
echo "Full transcript: $DIAG_OUT"
|
||||||
@@ -4767,6 +4767,36 @@ Respawn.DynamicEscortNPC = 0
|
|||||||
|
|
||||||
Respawn.ForceCompatibilityMode = 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)
|
if (mod->op == SPELLMOD_DURATION && spellInfo->GetDuration() == -1)
|
||||||
return false;
|
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>
|
template <class T>
|
||||||
@@ -12017,6 +12021,28 @@ void Player::learnSkillRewardedSpells(uint32 skill_id, uint32 skill_value)
|
|||||||
uint32 raceMask = getRaceMask();
|
uint32 raceMask = getRaceMask();
|
||||||
uint32 classMask = getClassMask();
|
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)
|
// Get all abilities for this skill and sort by MinSkillLineRank (lowest to highest)
|
||||||
auto abilities = GetSkillLineAbilitiesBySkillLine(skill_id);
|
auto abilities = GetSkillLineAbilitiesBySkillLine(skill_id);
|
||||||
std::vector<SkillLineAbilityEntry const*> sortedAbilities(abilities.begin(), abilities.end());
|
std::vector<SkillLineAbilityEntry const*> sortedAbilities(abilities.begin(), abilities.end());
|
||||||
|
|||||||
@@ -72,11 +72,25 @@
|
|||||||
#include "Util.h"
|
#include "Util.h"
|
||||||
#include "Vehicle.h"
|
#include "Vehicle.h"
|
||||||
#include "World.h"
|
#include "World.h"
|
||||||
|
#include "WorldConfig.h"
|
||||||
#include "WorldPacket.h"
|
#include "WorldPacket.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <limits>
|
#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] =
|
float baseMoveSpeed[MAX_MOVE_TYPE] =
|
||||||
{
|
{
|
||||||
2.5f, // MOVE_WALK
|
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
|
// 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
|
// 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);
|
AuraEffectList const& auras = GetAuraEffectsByType(SPELL_AURA_PERIODIC_HEAL);
|
||||||
for (AuraEffectList::const_iterator i = auras.begin(); i != auras.end(); ++i)
|
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();
|
uint64 mechanicMask = spellProto->GetAllEffectsMechanicMask();
|
||||||
|
|
||||||
// Shred, Maul - "Effects which increase Bleed damage also increase Shred damage"
|
// 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);
|
mechanicMask |= (1ULL << MECHANIC_BLEED);
|
||||||
|
|
||||||
if (mechanicMask)
|
if (mechanicMask)
|
||||||
|
|||||||
@@ -2268,6 +2268,15 @@ private:
|
|||||||
ValuesUpdateCache _valuesUpdateCache;
|
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
|
namespace Acore
|
||||||
{
|
{
|
||||||
// Binary predicate for sorting Units based on percent value of a power
|
// Binary predicate for sorting Units based on percent value of a power
|
||||||
|
|||||||
@@ -2175,7 +2175,9 @@ uint8 Aura::GetProcEffectMask(AuraApplication* aurApp, ProcEventInfo& eventInfo,
|
|||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
// do checks against db data
|
// 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;
|
return 0;
|
||||||
|
|
||||||
// check if spell was affected by this aura's spellmod (used by Arcane Potency and similar effects)
|
// 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))
|
if (m_caster->ToPlayer()->GetCommandStatus(CHEAT_CASTTIME))
|
||||||
m_casttime = 0;
|
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
|
// don't allow channeled spells / spells with cast time to be casted while moving
|
||||||
// (even if they are interrupted on moving, spells with almost immediate effect get to have their effect processed before movement interrupter kicks in)
|
// (even if they are interrupted on moving, spells with almost immediate effect get to have their effect processed before movement interrupter kicks in)
|
||||||
if ((m_spellInfo->IsChanneled() || m_casttime) && m_caster->IsPlayer() && m_caster->isMoving() && m_spellInfo->InterruptFlags & SPELL_INTERRUPT_FLAG_MOVEMENT && !IsTriggered())
|
if ((m_spellInfo->IsChanneled() || m_casttime) && m_caster->IsPlayer() && m_caster->isMoving() && m_spellInfo->InterruptFlags & SPELL_INTERRUPT_FLAG_MOVEMENT && !IsTriggered())
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
#include "SpellAuraDefines.h"
|
#include "SpellAuraDefines.h"
|
||||||
#include "SpellAuraEffects.h"
|
#include "SpellAuraEffects.h"
|
||||||
#include "SpellMgr.h"
|
#include "SpellMgr.h"
|
||||||
|
#include "World.h"
|
||||||
|
#include "WorldConfig.h"
|
||||||
|
|
||||||
uint32 GetTargetFlagMask(SpellTargetObjectTypes objType)
|
uint32 GetTargetFlagMask(SpellTargetObjectTypes objType)
|
||||||
{
|
{
|
||||||
@@ -1323,11 +1325,26 @@ bool SpellInfo::HasInitialAggro() const
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool SpellInfo::IsAffected(uint32 familyName, flag96 const& familyFlags) 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)
|
if (!familyName)
|
||||||
return true;
|
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;
|
return false;
|
||||||
|
|
||||||
if (familyFlags && !(familyFlags & SpellFamilyFlags))
|
if (familyFlags && !(familyFlags & SpellFamilyFlags))
|
||||||
@@ -1342,6 +1359,11 @@ bool SpellInfo::IsAffectedBySpellMods() const
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool SpellInfo::IsAffectedBySpellMod(SpellModifier const* mod) 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
|
// xinef: dont check duration mod
|
||||||
if (mod->op != SPELLMOD_DURATION)
|
if (mod->op != SPELLMOD_DURATION)
|
||||||
@@ -1356,7 +1378,7 @@ bool SpellInfo::IsAffectedBySpellMod(SpellModifier const* mod) const
|
|||||||
if (!sScriptMgr->OnIsAffectedBySpellModCheck(affectSpell, this, mod))
|
if (!sScriptMgr->OnIsAffectedBySpellModCheck(affectSpell, this, mod))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
return IsAffected(affectSpell->SpellFamilyName, mod->mask);
|
return IsAffected(affectSpell->SpellFamilyName, mod->mask, listenerOwner);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool SpellInfo::CanPierceImmuneAura(SpellInfo const* auraSpellInfo) const
|
bool SpellInfo::CanPierceImmuneAura(SpellInfo const* auraSpellInfo) const
|
||||||
|
|||||||
@@ -494,9 +494,21 @@ public:
|
|||||||
bool HasInitialAggro() const;
|
bool HasInitialAggro() const;
|
||||||
|
|
||||||
[[nodiscard]] bool IsAffected(uint32 familyName, flag96 const& familyFlags) 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 IsAffectedBySpellMods() const;
|
||||||
bool IsAffectedBySpellMod(SpellModifier const* mod) 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 CanPierceImmuneAura(SpellInfo const* auraSpellInfo) const;
|
||||||
bool CanDispelAura(SpellInfo const* auraSpellInfo) const;
|
bool CanDispelAura(SpellInfo const* auraSpellInfo) const;
|
||||||
|
|||||||
@@ -5368,6 +5368,18 @@ void SpellMgr::LoadSpellInfoCorrections()
|
|||||||
LockEntry* key = const_cast<LockEntry*>(sLockStore.LookupEntry(36)); // 3366 Opening, allows to open without proper key
|
LockEntry* key = const_cast<LockEntry*>(sLockStore.LookupEntry(36)); // 3366 Opening, allows to open without proper key
|
||||||
key->Type[2] = LOCK_KEY_NONE;
|
key->Type[2] = LOCK_KEY_NONE;
|
||||||
|
|
||||||
|
// Fractured / Paragon: DK weapon-line "passives" Forceful Deflection and
|
||||||
|
// Runic Focus ship in 3.3.5a Spell.dbc without SPELL_ATTR0_PASSIVE set.
|
||||||
|
// SpellInfo::IsPassive() is therefore false, and mod-paragon's panel-learn
|
||||||
|
// diff treats them as castable actives and revokes them — while true
|
||||||
|
// actives (Blood Presence, Death Coil, Death Grip, ...) must stay
|
||||||
|
// stripped. Mark these two passive in-memory so the panel policy matches
|
||||||
|
// the spellbook UX for every class (stock DK benefits too).
|
||||||
|
ApplySpellFix({ 49410, 61455 }, [](SpellInfo* spellInfo)
|
||||||
|
{
|
||||||
|
spellInfo->Attributes |= SPELL_ATTR0_PASSIVE;
|
||||||
|
});
|
||||||
|
|
||||||
// Fractured: strip reagent requirements from every player-class spell at
|
// Fractured: strip reagent requirements from every player-class spell at
|
||||||
// load time. Filtered by SpellFamilyName != 0 so that profession spells
|
// load time. Filtered by SpellFamilyName != 0 so that profession spells
|
||||||
// (cooking, alchemy, enchanting, blacksmithing, jewelcrafting, leatherworking,
|
// (cooking, alchemy, enchanting, blacksmithing, jewelcrafting, leatherworking,
|
||||||
|
|||||||
@@ -842,7 +842,8 @@ SpellProcEntry const* SpellMgr::GetSpellProcEntry(uint32 spellId) const
|
|||||||
return nullptr;
|
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
|
// proc type doesn't match
|
||||||
if (!(eventInfo.GetTypeMask() & procEntry.ProcFlags))
|
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
|
// check spell family name/flags (if set) for spells
|
||||||
if (eventInfo.GetTypeMask() & SPELL_PROC_FLAG_MASK)
|
if (eventInfo.GetTypeMask() & SPELL_PROC_FLAG_MASK)
|
||||||
if (SpellInfo const* eventSpellInfo = eventInfo.GetSpellInfo())
|
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;
|
return false;
|
||||||
|
|
||||||
// check spell type mask (if set)
|
// check spell type mask (if set)
|
||||||
|
|||||||
@@ -699,7 +699,12 @@ public:
|
|||||||
|
|
||||||
// Spell proc table
|
// Spell proc table
|
||||||
[[nodiscard]] SpellProcEntry const* GetSpellProcEntry(uint32 spellId) const;
|
[[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
|
// Spell bonus data table
|
||||||
[[nodiscard]] SpellBonusEntry const* GetSpellBonusData(uint32 spellId) const;
|
[[nodiscard]] SpellBonusEntry const* GetSpellBonusData(uint32 spellId) const;
|
||||||
|
|||||||
@@ -684,4 +684,10 @@ void WorldConfig::BuildConfigCache()
|
|||||||
|
|
||||||
// Achievement
|
// Achievement
|
||||||
SetConfigValue<uint32>(CONFIG_ACHIEVEMENT_REALM_FIRST_KILL_WINDOW, "Achievement.RealmFirstKillWindow", 60);
|
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_NEW_CHAR_STRING,
|
||||||
CONFIG_VALIDATE_SKILL_LEARNED_BY_SPELLS,
|
CONFIG_VALIDATE_SKILL_LEARNED_BY_SPELLS,
|
||||||
CONFIG_ACHIEVEMENT_REALM_FIRST_KILL_WINDOW,
|
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
|
MAX_NUM_SERVER_CONFIGS
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Fractured Launcher (Electron)
|
# Fractured Launcher (Electron)
|
||||||
|
|
||||||
Windows launcher with **no extra console window**, **native Browse folder** dialog, GitHub **release assets** + repo file sync, **realmlist**, optional **auth**, **Play**, and **auto-update** (via `electron-updater`). This is the **only** supported client launcher in this repo.
|
**Windows** and **Linux (AppImage)** launcher with **no extra console window**, **native Browse folder** dialog, **Gitea or GitHub** release assets + GitHub repo file sync, **realmlist**, optional **auth**, **Play**, and **auto-update** (via `electron-updater`). This is the **only** supported client launcher in this repo.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -14,7 +14,12 @@ npm install
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
On first run, `launcher.json` is created next to the app (dev: in this folder). By default **`github.repo`** is **`Fractured-Distro`** (public release assets); no token is required for patches. Set **GITHUB_TOKEN** only if you override `github` to a **private** repo.
|
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
|
||||||
|
|
||||||
|
- **Recommended (self-hosted Gitea):** set **`gitea.base_url`**, **`gitea.owner`**, **`gitea.repo`** in `launcher.json` (see **`default-launcher.json`**). Players need **`GITEA_TOKEN`** (or the env name in **`gitea.token_env`**) if the Gitea repo is **private** — same trade-off as any private host (per-player token, SSO proxy, or a read-only deploy token you accept distributing).
|
||||||
|
- **Fallback:** if **`gitea.base_url`** is empty, **`from_release`** uses the **GitHub** Releases API against **`github.owner` / `github.repo`** (defaults to this **`Fractured`** repo for non-release paths), with optional **`GITHUB_TOKEN`** for private assets.
|
||||||
|
|
||||||
## Build Windows installers
|
## Build Windows installers
|
||||||
|
|
||||||
@@ -30,63 +35,142 @@ Produces under **`dist/`**:
|
|||||||
| `Fractured-Launcher-${version}-Setup.exe` (NSIS) | **Recommended for players** — supports seamless **auto-update** and restart. |
|
| `Fractured-Launcher-${version}-Setup.exe` (NSIS) | **Recommended for players** — supports seamless **auto-update** and restart. |
|
||||||
| `Fractured-Launcher-${version}-Windows-Portable.exe` | No installer; players replace the file manually. Auto-update is **less reliable** than NSIS. |
|
| `Fractured-Launcher-${version}-Windows-Portable.exe` | No installer; players replace the file manually. Auto-update is **less reliable** than NSIS. |
|
||||||
|
|
||||||
|
## Build Linux AppImage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/fractured-launcher-electron
|
||||||
|
npm install
|
||||||
|
npm run pack:linux
|
||||||
|
```
|
||||||
|
|
||||||
|
Produces **`dist/Fractured-Launcher-${version}-Linux-x86_64.AppImage`**. Same **`lib/baked-gitea-channel.js`** and **`default-launcher.json`** as Windows; run on **Linux** (or use **Fractured launcher CI** / **Sync release to Gitea**, which upload this file to Gitea with the Windows installers).
|
||||||
|
|
||||||
|
**Quick local test (avoids tag snapshot / CI):**
|
||||||
|
- **Linux:** from repo root, **`bash tools/fractured-launcher-electron/scripts/manual-pack-linux.sh`** → **`dist/*.AppImage`**.
|
||||||
|
- **Windows:** on a Windows machine, **`cd tools/fractured-launcher-electron`**, **`npm ci`**, **`npm run pack:win`** → **`dist/*.exe`**.
|
||||||
|
|
||||||
|
### Hardcoded Gitea channel (non-token)
|
||||||
|
|
||||||
|
**`lib/baked-gitea-channel.js`** exports **`base_url`**, **`owner`**, **`repo`**, **`release_tag`**. Set those strings once in the repo (same values you use for CI upload — not secret). At runtime **`config-store`** merges them into **`gitea.*`** so **`launcher.json`** does not need those fields; **`GITEA_TOKEN`** (or **`gitea.token_env`**) is still only for **private** Gitea. Leave a field **`''`** in the baked file to fall back to **`default-launcher.json`** / user **`launcher.json`** for that key.
|
||||||
|
|
||||||
|
**`npm run pack:win`** is plain **electron-builder** — no inject step, no extra JSON beside the app.
|
||||||
|
|
||||||
## Auto-update behaviour
|
## Auto-update behaviour
|
||||||
|
|
||||||
- **Packaged** builds only (`npm run pack:win` output). In `npm start` dev mode, update checks are skipped (button still explains that).
|
- **Packaged** builds only (`npm run pack:win` output). In `npm start` dev mode, update checks are skipped (button still explains that).
|
||||||
- **~5 seconds** after launch, then **every 6 hours**, the app checks for a newer version.
|
- **No implicit GitHub feed:** the app does **not** guess `package.json` → `repository` anymore. Without configuration you get a clear “skipped” message instead of a **404** on a private repo.
|
||||||
|
- **Configured feeds** (first match wins): **`update_feed_url` / `LAUNCHER_UPDATE_URL`** (generic `latest.yml`); or **`gitea`** block filled in + **`GITEA_TOKEN`** when the instance is private (resolves `…/releases/download/{tag}/`); or **`GITHUB_TOKEN`** + **`github.owner` / `github.repo`** for **private** GitHub releases only.
|
||||||
|
- **~5 seconds** after launch, then **every 6 hours**, the app checks when a feed is configured.
|
||||||
- When a download finishes, a dialog offers **Restart now** (calls `quitAndInstall`) or **Later**.
|
- When a download finishes, a dialog offers **Restart now** (calls `quitAndInstall`) or **Later**.
|
||||||
- **Manual check:** button **Check launcher updates** in the UI.
|
- **Manual check:** button **Check launcher updates** in the UI.
|
||||||
|
|
||||||
### Where updates are hosted
|
### Where launcher updates are hosted
|
||||||
|
|
||||||
**`package.json`** → `build.publish` targets **`Dawnforger/Fractured-Distro`** (public). `electron-updater` reads **`latest.yml`** from the **latest** release there; players do not need a GitHub token for launcher updates.
|
**`npm run publish:win`** runs **`electron-builder` with `--publish never`** — artifacts stay in **`dist/`**; CI uploads them to Gitea when you **publish a GitHub release**. For ad-hoc uploads, use **`scripts/upload-release-to-gitea.sh`**. For launcher auto-update, prefer:
|
||||||
|
|
||||||
**Private GitHub** (if you change `build.publish` or `github` back to a private repo): set **`GH_TOKEN`** / **`GITHUB_TOKEN`** / **`github.token_env`** before starting the launcher so the updater can authenticate.
|
- Set **`update_feed_url`** (or **`LAUNCHER_UPDATE_URL`**) to a **generic** HTTPS base URL where **`latest.yml`** and the installer files are hosted (often the same Gitea release attachment URLs pattern your reverse proxy exposes), **or**
|
||||||
|
- Keep publishing to a GitHub release only for **`latest.yml`** + installers if you accept that small metadata/binary channel there.
|
||||||
|
|
||||||
**Public generic feed** (optional CDN): set **`update_feed_url`** or **`LAUNCHER_UPDATE_URL`** to a folder that hosts `latest.yml` + installers; optional Bearer token if the host requires it.
|
**Private GitHub** updater: set **`GH_TOKEN`** / **`GITHUB_TOKEN`** / **`github.token_env`** as documented in `lib/auto-update.js` behaviour.
|
||||||
|
|
||||||
|
**Generic feed:** optional Bearer token via the same token envs if your static host checks `Authorization`.
|
||||||
|
|
||||||
### Publishing a new launcher version
|
### Publishing a new launcher version
|
||||||
|
|
||||||
1. Bump **`version`** in `package.json` (semver, e.g. `1.0.1`).
|
1. Bump **`version`** in `package.json` on `main` (or your release branch) and merge.
|
||||||
2. Create a **GitHub personal access token** with `repo` (or `public_repo` for public repos).
|
2. Create a **GitHub release** (tag + attach patches / `Wow.exe` if needed) and click **Publish** — **Sync release to Gitea** builds **Windows + Linux** launcher artifacts and mirrors everything to Gitea.
|
||||||
3. From this directory:
|
3. Local check: **`npm run pack:win`** (on Windows) or **`npm run pack:linux`** / **`scripts/manual-pack-linux.sh`**, then **`scripts/upload-release-to-gitea.sh`** with the same **`GITEA_*`** env vars as CI if you need a manual upload.
|
||||||
|
|
||||||
```bash
|
## Sync to Gitea (patches + launcher binaries)
|
||||||
set GH_TOKEN=ghp_your_token_here
|
|
||||||
npm run publish:win
|
CI workflow **Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml`) runs on **every published GitHub release** on this repo:
|
||||||
|
|
||||||
|
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 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):
|
||||||
|
|
||||||
|
| Secret | Example |
|
||||||
|
|--------|---------|
|
||||||
|
| **`GITEA_BASE_URL`** | `https://git.yourdomain.com` (no trailing slash) |
|
||||||
|
| **`GITEA_TOKEN`** | Gitea personal access token with permission to manage releases and attachments on the target repo |
|
||||||
|
| **`GITEA_OWNER`** | Organization or username on Gitea |
|
||||||
|
| **`GITEA_REPO`** | Repository name — must already have **at least one commit** (Gitea returns HTTP 422 “repo is empty” for zero-commit repos; push e.g. a README on **`main`** or set **`GITEA_TARGET_REF`** to your default branch) |
|
||||||
|
|
||||||
|
**Optional variable** (Settings → Variables): **`GITEA_TARGET_REF`** — default branch/commitish used **only when the workflow must create a new Gitea release** and Gitea needs `target_commitish` (defaults to **`main`** in the upload script if unset).
|
||||||
|
|
||||||
|
**Player `launcher.json`:** packaged builds should already include **`gitea.base_url` / `owner` / `repo`** from the bake step above. Players only need to set **`GITEA_TOKEN`** (or your **`token_env`**) if the Gitea repo is **private**. To point at another instance, edit **`gitea`** in **`launcher.json`**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"gitea": {
|
||||||
|
"base_url": "https://git.yourdomain.com",
|
||||||
|
"owner": "myorg",
|
||||||
|
"repo": "fractured-patches",
|
||||||
|
"release_tag": "latest",
|
||||||
|
"token_env": "GITEA_TOKEN"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
That builds NSIS + portable and **uploads** update metadata and installers to the configured GitHub repo’s **releases** (see [electron-builder publish](https://www.electron.build/configuration/publish)).
|
**Manual upload:** `bash scripts/upload-release-to-gitea.sh /path/to/files v1.0.0` with the same env vars as CI.
|
||||||
|
|
||||||
Players on an older NSIS install will pick up the next version automatically on the next check.
|
### Sync did not run / Gitea unchanged — checklist
|
||||||
|
|
||||||
## Public distro repo (patches + launcher binaries)
|
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. **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.
|
||||||
|
|
||||||
Default **`default-launcher.json`** uses **`github.repo`: `Fractured-Distro`** so **`from_release`** assets (`patch-Z.MPQ`, `Wow-patched.exe`, …) download from **[Dawnforger/Fractured-Distro releases](https://github.com/Dawnforger/Fractured-Distro/releases)** — **no player token**.
|
### Private Gitea token for players
|
||||||
|
|
||||||
Publish assets there by:
|
Do **not** embed a shared admin PAT in a shipped `launcher.json`. Prefer read-only tokens scoped to one repo, short-lived tokens, or a small auth service that redirects to signed URLs.
|
||||||
|
|
||||||
- **GitHub Actions** — workflow **Sync release to Fractured-Distro** (`.github/workflows/distro-release-sync.yml`): on **release published** on `Dawnforger/Fractured`, it **builds the Electron launcher** from that tag on Windows (`npm run pack:win`), **downloads every asset** attached to that release (patches, `Wow-patched.exe`, …), merges them (launcher files overwrite on duplicate names), and creates or updates the **same tag** on **`Fractured-Distro`**. Requires repository secret **`DISTRO_SYNC_TOKEN`**. **Manual:** Actions → run workflow with an existing tag.
|
**Release asset names** must match **`files[].source`** when **`from_release`**: true. Use **`release_tag`**: `"latest"` or a pinned tag matching both GitHub and Gitea.
|
||||||
- **Locally:** `scripts/publish-to-distro.sh` (see script header) if you need to upload without a full release cycle.
|
|
||||||
|
|
||||||
If your public repo slug is not `Fractured-Distro`, edit **`DISTRO_REPO`** in the workflow / script and **`github.repo`** in `launcher.json`.
|
## Patch versions (same filenames, different bytes)
|
||||||
|
|
||||||
### Private `github.repo` (optional)
|
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):
|
||||||
|
|
||||||
For a **private** release source, set `GITHUB_TOKEN` (or `github.token_env`) with read access — **do not** embed a shared PAT in shipped `launcher.json` for all players.
|
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.
|
||||||
|
3. After a real download, the launcher **re-hashes** installed files and compares to the manifest; mismatch → clear error. It also writes **`.fractured/patch-state.json`** under the WoW folder so the UI can show **“Installed client files: …”**.
|
||||||
|
|
||||||
**Release asset names** must match **`files[].source`** exactly when **`from_release`**: true. Use **`release_tag`: `"latest"`** for the newest release, or pin a tag.
|
If **`patch-manifest.json`** is missing on a release, the launcher falls back to **always downloading** all configured files (same as before).
|
||||||
|
|
||||||
|
**Generate the manifest** when you cut a release (paths are your local patch binaries):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/staging
|
||||||
|
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).
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
Workflow **Fractured launcher CI** (`.github/workflows/fractured-launcher-ci.yml`) runs on pushes/PRs under `tools/fractured-launcher-electron/`: Windows **`npm run pack:win`** and **artifacts** (`*.exe`, `latest.yml`, blockmaps). **Actions → Fractured launcher CI → Run workflow** runs it manually.
|
Workflow **Fractured launcher CI** (`.github/workflows/fractured-launcher-ci.yml`) runs on pushes/PRs under `tools/fractured-launcher-electron/`: **Windows** (`npm run pack:win`) and **Linux** (`npm run pack:linux`) jobs, each **`electron-builder … --publish never`**. **Actions → Fractured launcher CI → Run workflow** runs it manually.
|
||||||
|
|
||||||
|
**Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml`) uses the same pack commands. If you see `GH_TOKEN` / `GitHubPublisher` errors in logs, the job is almost certainly an old **Re-run failed jobs** — open **Actions → Sync release to Gitea → Run workflow**, enter the tag, and start a **new** run instead.
|
||||||
|
|
||||||
## Config
|
## 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`).
|
- **`game_dir`**: WoW 3.3.5a root (contains `Wow.exe`).
|
||||||
- **`update_feed_url`**: optional generic HTTPS base for launcher auto-update (overrides default GitHub feed when set).
|
- **`update_feed_url`**: optional generic HTTPS base for launcher auto-update.
|
||||||
- **`github`**: `owner`, `repo`, `ref` (repo file paths), **`release_tag`** (`latest` or e.g. `v1.0.0`), **`token_env`** (env var name for a PAT when using private sources).
|
- **`launcher_updates_from_github`**: default **`false`**. Only when **`true`** will a **`GITHUB_TOKEN`** (or **`github.token_env`**) enable **electron-updater**’s GitHub provider against **`github.owner` / `github.repo`**. Leave **`false`** when launcher binaries and **`latest.yml`** live on **Gitea** (use **`gitea`** + token instead) so a stray GitHub token does not produce “No published versions on GitHub”.
|
||||||
- **`files`**: `source`, `dest`, `backup`, **`from_release`** (asset name on GitHub release vs repo path).
|
- **`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.
|
||||||
- **`realmlist`**, **`auth`**, **`launch`**.
|
- **`github`**: used for **non-release** repo paths (`from_release`: false) and for **GitHub** **`from_release`** when **`gitea.base_url`** is empty.
|
||||||
|
- **`patch_manifest`**: **`enabled`**, **`source`** (default `patch-manifest.json`), **`from_release`** — checksum-based skip + verify (see above).
|
||||||
|
- **`files`**: default **`[]`**. **Download updates** resolves what to pull in order: (**1**) non-empty **`files`** if you set explicit **`source`** → **`dest`** pairs; (**2**) else each key in **`patch-manifest.json`** on the release (recommended); (**3**) else release attachments except launcher artifacts (`Fractured-Launcher*`, `*.blockmap`, `latest*.yml`, `.AppImage`, `patch-manifest.json`): **`.MPQ`** → **`Data/enUS/<name>.MPQ`** (extension forced to **`.MPQ`** caps for client compatibility), one **`.exe`** → **`launch.exe`**. Multiple `.exe` attachments require a manifest. Legacy **`Wow-patched.exe`** entries are removed when merging **`launcher.json`**.
|
||||||
|
- **`realmlist`**, **`auth`**, **`launch`** (`**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.
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
{
|
{
|
||||||
"game_dir": "",
|
"game_dir": "",
|
||||||
"update_feed_url": "",
|
"update_feed_url": "",
|
||||||
|
"launcher_updates_from_github": false,
|
||||||
|
"gitea": {
|
||||||
|
"base_url": "",
|
||||||
|
"owner": "",
|
||||||
|
"repo": "",
|
||||||
|
"release_tag": "latest",
|
||||||
|
"token_env": "GITEA_TOKEN"
|
||||||
|
},
|
||||||
"github": {
|
"github": {
|
||||||
"owner": "Dawnforger",
|
"owner": "Dawnforger",
|
||||||
"repo": "Fractured-Distro",
|
"repo": "Fractured",
|
||||||
"ref": "main",
|
"ref": "main",
|
||||||
"release_tag": "latest",
|
"release_tag": "latest",
|
||||||
"token_env": "GITHUB_TOKEN"
|
"token_env": "GITHUB_TOKEN"
|
||||||
},
|
},
|
||||||
"files": [
|
"patch_manifest": {
|
||||||
{
|
"enabled": true,
|
||||||
"source": "patch-Z.MPQ",
|
"source": "patch-manifest.json",
|
||||||
"dest": "Data/patch-Z.MPQ",
|
"from_release": true
|
||||||
"backup": true,
|
},
|
||||||
"from_release": true
|
"files": [],
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "Wow-patched.exe",
|
|
||||||
"dest": "Wow.exe",
|
|
||||||
"backup": true,
|
|
||||||
"from_release": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"realmlist": {
|
"realmlist": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"line": "set realmlist fracturedwow.ddns.net:47497",
|
"line": "set realmlist fracturedwow.ddns.net:47497",
|
||||||
"paths": ["Data/enUS/realmlist.wtf", "Data/enGB/realmlist.wtf"]
|
"paths": ["Data/enUS/realmlist.wtf"]
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -37,6 +37,9 @@
|
|||||||
"launch": {
|
"launch": {
|
||||||
"exe": "Wow.exe",
|
"exe": "Wow.exe",
|
||||||
"args": [],
|
"args": [],
|
||||||
"linux_wrapper": ["wine"]
|
"linux_wrapper": ["wine"],
|
||||||
|
"linux_steam_uri": "",
|
||||||
|
"linux_steam_binary": "",
|
||||||
|
"linux_steam_spawn": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,28 +2,51 @@
|
|||||||
|
|
||||||
const { dialog } = require('electron');
|
const { dialog } = require('electron');
|
||||||
const { autoUpdater } = require('electron-updater');
|
const { autoUpdater } = require('electron-updater');
|
||||||
|
const { useGiteaReleases, getGiteaUpdaterFeedBase } = require('./gitea-release');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('electron').App} app
|
* @param {import('electron').App} app
|
||||||
* @param {() => import('electron').BrowserWindow | null} getMainWindow
|
* @param {() => import('electron').BrowserWindow | null} getMainWindow
|
||||||
* @param {{ updateFeedUrl?: string, githubOwner?: string, githubRepo?: string, token?: string }} opts
|
* @param {{
|
||||||
|
* updateFeedUrl?: string,
|
||||||
|
* githubOwner?: string,
|
||||||
|
* githubRepo?: string,
|
||||||
|
* githubToken?: string,
|
||||||
|
* giteaToken?: string,
|
||||||
|
* allowGithubLauncherUpdates?: boolean,
|
||||||
|
* config?: object,
|
||||||
|
* }} opts
|
||||||
*/
|
*/
|
||||||
function setupAutoUpdater(app, getMainWindow, opts = {}) {
|
async function setupAutoUpdater(app, getMainWindow, opts = {}) {
|
||||||
if (!app.isPackaged) {
|
if (!app.isPackaged) {
|
||||||
return {
|
return {
|
||||||
checkNow: async () => ({ skipped: true, reason: 'development build' }),
|
checkNow: async () => ({ skipped: true, reason: 'development build' }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
autoUpdater.autoDownload = true;
|
const ghToken = String(opts.githubToken || '').trim();
|
||||||
autoUpdater.autoInstallOnAppQuit = true;
|
const giteaTok = String(opts.giteaToken || '').trim();
|
||||||
|
|
||||||
const token = String(opts.token || '').trim();
|
|
||||||
const envGeneric = String(process.env.LAUNCHER_UPDATE_URL || '').trim();
|
const envGeneric = String(process.env.LAUNCHER_UPDATE_URL || '').trim();
|
||||||
const configGeneric = String(opts.updateFeedUrl || '').trim();
|
const configGeneric = String(opts.updateFeedUrl || '').trim();
|
||||||
const genericUrl = envGeneric || configGeneric;
|
let genericUrl = envGeneric || configGeneric;
|
||||||
const owner = String(opts.githubOwner || 'Dawnforger').trim();
|
let genericAuthHeader = '';
|
||||||
const repo = String(opts.githubRepo || 'Fractured').trim();
|
|
||||||
|
if (!genericUrl && opts.config && useGiteaReleases(opts.config)) {
|
||||||
|
const gfb = await getGiteaUpdaterFeedBase(opts.config);
|
||||||
|
if (gfb && gfb.url) {
|
||||||
|
genericUrl = gfb.url;
|
||||||
|
const t = String(gfb.token || giteaTok || '').trim();
|
||||||
|
if (t) genericAuthHeader = `token ${t}`;
|
||||||
|
}
|
||||||
|
} else if (genericUrl) {
|
||||||
|
if (giteaTok) genericAuthHeader = `token ${giteaTok}`;
|
||||||
|
else if (ghToken) genericAuthHeader = `Bearer ${ghToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = String(opts.githubOwner || '').trim();
|
||||||
|
const repo = String(opts.githubRepo || '').trim();
|
||||||
|
|
||||||
|
let feedConfigured = false;
|
||||||
|
|
||||||
if (genericUrl) {
|
if (genericUrl) {
|
||||||
const base = genericUrl.replace(/\/?$/, '/');
|
const base = genericUrl.replace(/\/?$/, '/');
|
||||||
@@ -31,22 +54,37 @@ function setupAutoUpdater(app, getMainWindow, opts = {}) {
|
|||||||
provider: 'generic',
|
provider: 'generic',
|
||||||
url: base,
|
url: base,
|
||||||
});
|
});
|
||||||
if (token) {
|
if (genericAuthHeader) {
|
||||||
autoUpdater.requestHeaders = {
|
autoUpdater.requestHeaders = {
|
||||||
...autoUpdater.requestHeaders,
|
...autoUpdater.requestHeaders,
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: genericAuthHeader,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (token && owner && repo) {
|
feedConfigured = true;
|
||||||
|
} else if (opts.allowGithubLauncherUpdates && ghToken && owner && repo) {
|
||||||
autoUpdater.setFeedURL({
|
autoUpdater.setFeedURL({
|
||||||
provider: 'github',
|
provider: 'github',
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
private: true,
|
private: true,
|
||||||
token,
|
token: ghToken,
|
||||||
});
|
});
|
||||||
|
feedConfigured = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!feedConfigured) {
|
||||||
|
const reason =
|
||||||
|
'No update channel configured. Set launcher.json → update_feed_url (HTTPS folder with latest.yml), ' +
|
||||||
|
'or fill gitea.base_url/owner/repo (+ GITEA_TOKEN for private), ' +
|
||||||
|
'or set launcher_updates_from_github to true with GITHUB_TOKEN for private GitHub release feeds.';
|
||||||
|
return {
|
||||||
|
checkNow: async () => ({ skipped: true, reason }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
autoUpdater.autoDownload = true;
|
||||||
|
autoUpdater.autoInstallOnAppQuit = true;
|
||||||
|
|
||||||
const send = (msg) => {
|
const send = (msg) => {
|
||||||
const w = getMainWindow();
|
const w = getMainWindow();
|
||||||
if (w && !w.isDestroyed()) {
|
if (w && !w.isDestroyed()) {
|
||||||
@@ -63,9 +101,8 @@ function setupAutoUpdater(app, getMainWindow, opts = {}) {
|
|||||||
const m = (err && (err.message || String(err))) || '';
|
const m = (err && (err.message || String(err))) || '';
|
||||||
if (/404|releases\.atom|HttpError:\s*404/i.test(m)) {
|
if (/404|releases\.atom|HttpError:\s*404/i.test(m)) {
|
||||||
send(
|
send(
|
||||||
'Launcher update: could not read GitHub releases (404). ' +
|
'Launcher update: 404 (no latest.yml or wrong URL). For Gitea use gitea.* + token, or set update_feed_url. ' +
|
||||||
'If the repo is private, set GITHUB_TOKEN (or your token_env) so the launcher can authenticate, ' +
|
'For private GitHub set GITHUB_TOKEN.'
|
||||||
'or set update_feed_url in launcher.json to a public HTTPS folder that contains latest.yml.'
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production Gitea mirror (non-secret). Edit here and ship — no inject script,
|
||||||
|
* no fractured-release-channel.json, no CI env needed for these fields.
|
||||||
|
* Token stays in env: GITEA_TOKEN or launcher.json → gitea.token_env.
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
// 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 path = require('path');
|
||||||
const fs = require('fs').promises;
|
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) {
|
function mergeConfig(defaults, user) {
|
||||||
return {
|
return {
|
||||||
@@ -11,17 +31,44 @@ function mergeConfig(defaults, user) {
|
|||||||
user.update_feed_url != null && user.update_feed_url !== ''
|
user.update_feed_url != null && user.update_feed_url !== ''
|
||||||
? user.update_feed_url
|
? user.update_feed_url
|
||||||
: defaults.update_feed_url,
|
: defaults.update_feed_url,
|
||||||
|
launcher_updates_from_github:
|
||||||
|
user.launcher_updates_from_github != null
|
||||||
|
? user.launcher_updates_from_github
|
||||||
|
: defaults.launcher_updates_from_github,
|
||||||
github: { ...defaults.github, ...(user.github || {}) },
|
github: { ...defaults.github, ...(user.github || {}) },
|
||||||
|
gitea: { ...defaults.gitea, ...(user.gitea || {}) },
|
||||||
|
patch_manifest: { ...defaults.patch_manifest, ...(user.patch_manifest || {}) },
|
||||||
launch: { ...defaults.launch, ...(user.launch || {}) },
|
launch: { ...defaults.launch, ...(user.launch || {}) },
|
||||||
auth: user.auth != null ? { ...defaults.auth, ...user.auth } : defaults.auth,
|
auth: user.auth != null ? { ...defaults.auth, ...user.auth } : defaults.auth,
|
||||||
realmlist: user.realmlist != null ? { ...defaults.realmlist, ...user.realmlist } : defaults.realmlist,
|
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hardcoded Gitea host/repo (see lib/baked-gitea-channel.js). Non-empty baked values win. */
|
||||||
|
function applyBakedGitea(cfg) {
|
||||||
|
let baked;
|
||||||
|
try {
|
||||||
|
baked = require('./baked-gitea-channel');
|
||||||
|
} catch {
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
if (!baked || typeof baked !== 'object') return cfg;
|
||||||
|
cfg.gitea = { ...(cfg.gitea || {}) };
|
||||||
|
for (const k of ['base_url', 'owner', 'repo', 'release_tag']) {
|
||||||
|
const v = baked[k];
|
||||||
|
if (v != null && String(v).trim() !== '') cfg.gitea[k] = String(v).trim();
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
function getConfigPath(app) {
|
function getConfigPath(app) {
|
||||||
if (process.env.FRACTURED_LAUNCHER_CONFIG) return process.env.FRACTURED_LAUNCHER_CONFIG;
|
if (process.env.FRACTURED_LAUNCHER_CONFIG) return process.env.FRACTURED_LAUNCHER_CONFIG;
|
||||||
if (app && app.isPackaged) {
|
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(path.dirname(process.execPath), 'launcher.json');
|
||||||
}
|
}
|
||||||
return path.join(__dirname, '..', 'launcher.json');
|
return path.join(__dirname, '..', 'launcher.json');
|
||||||
@@ -33,11 +80,16 @@ async function loadConfig(app) {
|
|||||||
const defaults = JSON.parse(await fs.readFile(defPath, 'utf8'));
|
const defaults = JSON.parse(await fs.readFile(defPath, 'utf8'));
|
||||||
try {
|
try {
|
||||||
const user = JSON.parse(await fs.readFile(p, 'utf8'));
|
const user = JSON.parse(await fs.readFile(p, 'utf8'));
|
||||||
return { configPath: p, config: 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) {
|
} catch (e) {
|
||||||
if (e.code === 'ENOENT') {
|
if (e.code === 'ENOENT') {
|
||||||
await fs.writeFile(p, JSON.stringify(defaults, null, 2), 'utf8');
|
const initial = applyBakedGitea(mergeConfig(defaults, {}));
|
||||||
return { configPath: p, config: JSON.parse(JSON.stringify(defaults)) };
|
await fs.writeFile(p, JSON.stringify(initial, null, 2), 'utf8');
|
||||||
|
return { configPath: p, config: JSON.parse(JSON.stringify(initial)) };
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@@ -48,7 +100,7 @@ async function saveGameDir(configPath, gameDir) {
|
|||||||
const defaults = JSON.parse(await fs.readFile(defPath, 'utf8'));
|
const defaults = JSON.parse(await fs.readFile(defPath, 'utf8'));
|
||||||
const user = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
const user = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
||||||
user.game_dir = gameDir;
|
user.game_dir = gameDir;
|
||||||
const merged = mergeConfig(defaults, user);
|
const merged = applyBakedGitea(mergeConfig(defaults, user));
|
||||||
await fs.writeFile(configPath, JSON.stringify(merged, null, 2), 'utf8');
|
await fs.writeFile(configPath, JSON.stringify(merged, null, 2), 'utf8');
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
@@ -56,8 +108,9 @@ async function saveGameDir(configPath, gameDir) {
|
|||||||
function resolveGameDir(cfg, configPath) {
|
function resolveGameDir(cfg, configPath) {
|
||||||
const gd = cfg.game_dir;
|
const gd = cfg.game_dir;
|
||||||
if (!gd) return '';
|
if (!gd) return '';
|
||||||
if (path.isAbsolute(gd)) return path.normalize(gd);
|
const abs = path.isAbsolute(gd) ? path.normalize(gd) : path.normalize(path.join(path.dirname(configPath), gd));
|
||||||
return path.normalize(path.join(path.dirname(configPath), gd));
|
if (process.platform === 'win32') return normalizeWinGameDir(abs);
|
||||||
|
return abs;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getConfigPath, loadConfig, saveGameDir, resolveGameDir, mergeConfig };
|
module.exports = { getConfigPath, loadConfig, saveGameDir, resolveGameDir, mergeConfig, applyBakedGitea };
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { downloadBodyToFile, fetchOrThrow } = require('./http-download');
|
||||||
|
|
||||||
|
function normalizeGiteaBaseUrl(raw) {
|
||||||
|
let b = String(raw || '').trim().replace(/\/+$/, '');
|
||||||
|
if (!b) return '';
|
||||||
|
if (!/^https?:\/\//i.test(b)) b = `https://${b}`;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function giteaApiBase(cfg) {
|
||||||
|
const base = normalizeGiteaBaseUrl(cfg.gitea.base_url);
|
||||||
|
return `${base}/api/v1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function giteaToken(cfg) {
|
||||||
|
const name = cfg.gitea && cfg.gitea.token_env;
|
||||||
|
if (name && process.env[name]) return String(process.env[name]).trim();
|
||||||
|
return String(process.env.GITEA_TOKEN || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function giteaHeaders(token, json = false) {
|
||||||
|
const h = { 'User-Agent': 'Fractured-Launcher-Electron' };
|
||||||
|
if (json) h.Accept = 'application/json';
|
||||||
|
if (token) h.Authorization = `token ${token}`;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGiteaReleases(cfg) {
|
||||||
|
const g = cfg.gitea;
|
||||||
|
if (!g) return false;
|
||||||
|
return !!(String(g.base_url || '').trim() && String(g.owner || '').trim() && String(g.repo || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGiteaReleaseRecord(cfg) {
|
||||||
|
const api = giteaApiBase(cfg);
|
||||||
|
const { owner, repo } = cfg.gitea;
|
||||||
|
const tag = (cfg.gitea.release_tag || 'latest').trim() || 'latest';
|
||||||
|
const token = giteaToken(cfg);
|
||||||
|
|
||||||
|
let listUrl;
|
||||||
|
if (tag.toLowerCase() === 'latest') {
|
||||||
|
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`;
|
||||||
|
} else {
|
||||||
|
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetchOrThrow(listUrl, { headers: giteaHeaders(token, true) });
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
let hint = '';
|
||||||
|
if (res.status === 404) hint = ' (wrong tag / no release / check base_url owner repo)';
|
||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
if (a.name !== assetName) continue;
|
||||||
|
downloadUrl = a.browser_download_url || a.download_url || '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!downloadUrl) {
|
||||||
|
const names = list.map((x) => x.name).filter(Boolean);
|
||||||
|
throw new Error(`Gitea release asset "${assetName}" not found; attachments: ${names.join(', ') || '(none)'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const h = { Accept: 'application/octet-stream' };
|
||||||
|
if (token) h.Authorization = `token ${token}`;
|
||||||
|
const dl = await fetchOrThrow(downloadUrl, { headers: h, redirect: 'follow' });
|
||||||
|
await downloadBodyToFile(dl, destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base URL for electron-updater generic provider (expects latest.yml under this path).
|
||||||
|
* Matches Gitea’s pattern: …/owner/repo/releases/download/{tag}/latest.yml
|
||||||
|
*/
|
||||||
|
async function getGiteaUpdaterFeedBase(cfg) {
|
||||||
|
if (!useGiteaReleases(cfg)) return null;
|
||||||
|
const api = giteaApiBase(cfg);
|
||||||
|
const { owner, repo } = cfg.gitea;
|
||||||
|
const tag = (cfg.gitea.release_tag || 'latest').trim() || 'latest';
|
||||||
|
const token = giteaToken(cfg);
|
||||||
|
let listUrl;
|
||||||
|
if (tag.toLowerCase() === 'latest') {
|
||||||
|
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/latest`;
|
||||||
|
} else {
|
||||||
|
listUrl = `${api}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`;
|
||||||
|
}
|
||||||
|
const res = await fetchOrThrow(listUrl, { headers: giteaHeaders(token, true) });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const rel = await res.json();
|
||||||
|
const tagName = rel.tag_name;
|
||||||
|
if (!tagName || typeof tagName !== 'string') return null;
|
||||||
|
const root = normalizeGiteaBaseUrl(cfg.gitea.base_url);
|
||||||
|
const url = `${root}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/download/${encodeURIComponent(tagName)}/`;
|
||||||
|
return { url, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
downloadGiteaReleaseAsset,
|
||||||
|
fetchGiteaReleaseRecord,
|
||||||
|
listGiteaReleaseAttachmentNames,
|
||||||
|
giteaToken,
|
||||||
|
useGiteaReleases,
|
||||||
|
getGiteaUpdaterFeedBase,
|
||||||
|
};
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { githubToken } = require('./github-token');
|
const { githubToken } = require('./github-token');
|
||||||
const { fetchToFile, downloadBodyToFile } = require('./http-download');
|
const { downloadGiteaReleaseAsset, useGiteaReleases, listGiteaReleaseAttachmentNames } = require('./gitea-release');
|
||||||
|
const { fetchToFile, downloadBodyToFile, fetchOrThrow } = require('./http-download');
|
||||||
|
|
||||||
function encodeRepoPath(repoPath) {
|
function encodeRepoPath(repoPath) {
|
||||||
let p = String(repoPath || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
let p = String(repoPath || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||||
@@ -34,7 +35,7 @@ async function downloadGitHubRepoFile(cfg, repoPath, destPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${enc}?ref=${encodeURIComponent(ref)}`;
|
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();
|
const body = await res.text();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`GitHub contents API ${res.status}: ${body.slice(0, 800)}`);
|
throw new Error(`GitHub contents API ${res.status}: ${body.slice(0, 800)}`);
|
||||||
@@ -64,7 +65,7 @@ async function downloadGitHubRepoFile(cfg, repoPath, destPath) {
|
|||||||
throw new Error(`unexpected GitHub response for ${repoPath}`);
|
throw new Error(`unexpected GitHub response for ${repoPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadReleaseAsset(cfg, assetName, destPath) {
|
async function fetchGitHubReleaseJson(cfg) {
|
||||||
const token = githubToken(cfg);
|
const token = githubToken(cfg);
|
||||||
const tag = (cfg.github.release_tag || 'latest').trim() || 'latest';
|
const tag = (cfg.github.release_tag || 'latest').trim() || 'latest';
|
||||||
const { owner, repo } = cfg.github;
|
const { owner, repo } = cfg.github;
|
||||||
@@ -74,15 +75,35 @@ async function downloadReleaseAsset(cfg, assetName, destPath) {
|
|||||||
} else {
|
} else {
|
||||||
listUrl = `https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`;
|
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();
|
const text = await res.text();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let hint = '';
|
let hint = '';
|
||||||
if (res.status === 404) hint = ' (wrong tag or private repo without token?)';
|
if (res.status === 404) {
|
||||||
|
hint =
|
||||||
|
' (wrong tag, private repo without token, or releases live on Gitea — set gitea.base_url, gitea.owner, gitea.repo in launcher.json)';
|
||||||
|
}
|
||||||
if (res.status === 401 || res.status === 403) hint = ' (set GITHUB_TOKEN or token_env PAT)';
|
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)}`);
|
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 || [];
|
const assets = rel.assets || [];
|
||||||
let assetURL = '';
|
let assetURL = '';
|
||||||
for (const a of assets) {
|
for (const a of assets) {
|
||||||
@@ -107,8 +128,14 @@ async function downloadReleaseAsset(cfg, assetName, destPath) {
|
|||||||
h.Authorization = `Bearer ${token}`;
|
h.Authorization = `Bearer ${token}`;
|
||||||
h['X-GitHub-Api-Version'] = '2022-11-28';
|
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);
|
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 { pipeline } = require('stream/promises');
|
||||||
const { Readable } = require('stream');
|
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) {
|
async function downloadBodyToFile(res, destPath) {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errText = await res.text().catch(() => '');
|
const errText = await res.text().catch(() => '');
|
||||||
@@ -30,11 +66,11 @@ async function downloadBodyToFile(res, destPath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchToFile(url, headers, destPath) {
|
async function fetchToFile(url, headers, destPath) {
|
||||||
const res = await fetch(url, {
|
const res = await fetchOrThrow(url, {
|
||||||
headers,
|
headers,
|
||||||
redirect: 'follow',
|
redirect: 'follow',
|
||||||
});
|
});
|
||||||
await downloadBodyToFile(res, destPath);
|
await downloadBodyToFile(res, destPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { fetchToFile, downloadBodyToFile };
|
module.exports = { fetchToFile, downloadBodyToFile, fetchOrThrow, safeUrlForLog };
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const { createHash } = require('node:crypto');
|
||||||
|
const { downloadReleaseAsset, downloadGitHubRepoFile } = require('./github');
|
||||||
|
|
||||||
|
async function sha256File(absPath) {
|
||||||
|
const buf = await fs.readFile(absPath);
|
||||||
|
return createHash('sha256').update(buf).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateDir(gameDir) {
|
||||||
|
return path.join(gameDir, '.fractured');
|
||||||
|
}
|
||||||
|
|
||||||
|
function statePath(gameDir) {
|
||||||
|
return path.join(stateDir(gameDir), 'patch-state.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPatchState(gameDir) {
|
||||||
|
if (!gameDir) return null;
|
||||||
|
try {
|
||||||
|
const t = await fs.readFile(statePath(gameDir), 'utf8');
|
||||||
|
return JSON.parse(t);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writePatchState(gameDir, manifestVersion, fileShas) {
|
||||||
|
const p = statePath(gameDir);
|
||||||
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
||||||
|
const body = {
|
||||||
|
client_build: manifestVersion,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
files: fileShas,
|
||||||
|
};
|
||||||
|
const tmp = p + '.tmp';
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(body, null, 2), 'utf8');
|
||||||
|
await fs.rename(tmp, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateManifest(m) {
|
||||||
|
if (!m || m.version == null || String(m.version).trim() === '') return false;
|
||||||
|
if (!m.files || typeof m.files !== 'object') return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download and parse patch-manifest.json (or custom name). Returns null on any failure.
|
||||||
|
*/
|
||||||
|
async function loadManifest(cfg) {
|
||||||
|
const pm = cfg.patch_manifest;
|
||||||
|
if (!pm || !pm.enabled || !String(pm.source || '').trim()) return null;
|
||||||
|
const tmp = path.join(os.tmpdir(), `fr-patch-manifest-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
||||||
|
try {
|
||||||
|
if (pm.from_release) {
|
||||||
|
await downloadReleaseAsset(cfg, String(pm.source).trim(), tmp);
|
||||||
|
} else {
|
||||||
|
await downloadGitHubRepoFile(cfg, String(pm.source).trim(), tmp);
|
||||||
|
}
|
||||||
|
const raw = await fs.readFile(tmp, 'utf8');
|
||||||
|
await fs.unlink(tmp).catch(() => {});
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
await fs.unlink(tmp).catch(() => {});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if every from_release file on disk matches manifest sha256.
|
||||||
|
*/
|
||||||
|
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 entries) {
|
||||||
|
if (!entry.from_release) continue;
|
||||||
|
const spec = manifest.files[entry.source];
|
||||||
|
if (!spec || !spec.sha256) return false;
|
||||||
|
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
||||||
|
const destAbs = path.join(gameDir, ...parts);
|
||||||
|
let disk;
|
||||||
|
try {
|
||||||
|
disk = await sha256File(destAbs);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (disk.toLowerCase() !== String(spec.sha256).trim().toLowerCase()) return false;
|
||||||
|
}
|
||||||
|
if (onStatus) {
|
||||||
|
onStatus(`Client files already match build ${manifest.version} (nothing to download).`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyInstalledAgainstManifest(cfg, manifest) {
|
||||||
|
if (!validateManifest(manifest)) return;
|
||||||
|
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) {
|
||||||
|
throw new Error(
|
||||||
|
`patch-manifest.json is missing a sha256 for "${entry.source}" — regenerate the manifest for this release.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
||||||
|
const destAbs = path.join(cfg.game_dir, ...parts);
|
||||||
|
const disk = await sha256File(destAbs);
|
||||||
|
if (disk.toLowerCase() !== String(spec.sha256).trim().toLowerCase()) {
|
||||||
|
throw new Error(
|
||||||
|
`${entry.source}: checksum mismatch after install (expected ${spec.sha256.slice(0, 12)}…, got ${disk.slice(0, 12)}…). Try syncing again.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 entries) {
|
||||||
|
if (!entry.from_release) continue;
|
||||||
|
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
||||||
|
const destAbs = path.join(cfg.game_dir, ...parts);
|
||||||
|
try {
|
||||||
|
shas[entry.source] = await sha256File(destAbs);
|
||||||
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writePatchState(cfg.game_dir, String(manifest.version), shas);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
loadManifest,
|
||||||
|
validateManifest,
|
||||||
|
patchesMatchManifest,
|
||||||
|
verifyInstalledAgainstManifest,
|
||||||
|
recordPatchState,
|
||||||
|
readPatchState,
|
||||||
|
statePath,
|
||||||
|
};
|
||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
|
const fsSync = require('fs');
|
||||||
const { downloadGitHubRepoFile, downloadReleaseAsset } = require('./github');
|
const { downloadGitHubRepoFile, downloadReleaseAsset } = require('./github');
|
||||||
|
const { normalizeWinGameDir } = require('./win-game-dir');
|
||||||
|
const { loadManifest } = require('./patch-manifest');
|
||||||
|
const { buildResolvedReleaseFiles } = require('./release-sync');
|
||||||
|
|
||||||
function pad2(n) {
|
function pad2(n) {
|
||||||
return String(n).padStart(2, '0');
|
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())}`;
|
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) {
|
function wowExePath(cfg) {
|
||||||
|
const gd = normalizeWinGameDir(cfg.game_dir || '');
|
||||||
const exe = (cfg.launch && cfg.launch.exe) || 'Wow.exe';
|
const exe = (cfg.launch && cfg.launch.exe) || 'Wow.exe';
|
||||||
const parts = exe.replace(/\\/g, '/').split('/').filter(Boolean);
|
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) {
|
function wowInstallValid(cfg) {
|
||||||
if (!cfg.game_dir) return false;
|
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) {
|
async function installFile(cfg, entry) {
|
||||||
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
|
||||||
const destAbs = path.join(cfg.game_dir, ...parts);
|
const root = normalizeWinGameDir(cfg.game_dir || '');
|
||||||
if (entry.backup) {
|
const destAbs = normalizeMpqDestinationPath(path.join(root, ...parts));
|
||||||
try {
|
|
||||||
const st = await fs.stat(destAbs);
|
|
||||||
if (st.isFile()) {
|
|
||||||
const bak = `${destAbs}.bak-${backupSuffix()}`;
|
|
||||||
await fs.rename(destAbs, bak);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e.code !== 'ENOENT') throw e;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await fs.unlink(destAbs);
|
|
||||||
} catch (e) {
|
|
||||||
if (e.code !== 'ENOENT') throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const tmp = destAbs + '.new';
|
const tmp = destAbs + '.new';
|
||||||
|
|
||||||
if (entry.from_release) {
|
if (entry.from_release) {
|
||||||
await downloadReleaseAsset(cfg, entry.source, tmp);
|
await downloadReleaseAsset(cfg, entry.source, tmp);
|
||||||
} else {
|
} else {
|
||||||
await downloadGitHubRepoFile(cfg, entry.source, tmp);
|
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) {
|
async function applyRealmlist(cfg) {
|
||||||
@@ -62,18 +181,25 @@ async function applyRealmlist(cfg) {
|
|||||||
const content = line + '\n';
|
const content = line + '\n';
|
||||||
let paths = cfg.realmlist.paths;
|
let paths = cfg.realmlist.paths;
|
||||||
if (!paths || !paths.length) paths = ['Data/enUS/realmlist.wtf'];
|
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) {
|
for (const rel of paths) {
|
||||||
const r = String(rel).trim().replace(/\\/g, '/');
|
const r = String(rel).trim().replace(/\\/g, '/');
|
||||||
if (!r) continue;
|
if (!r) continue;
|
||||||
const segs = r.split('/').filter(Boolean);
|
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.mkdir(path.dirname(abs), { recursive: true });
|
||||||
await fs.writeFile(abs, content, 'utf8');
|
await fs.writeFile(abs, content, 'utf8');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyPatches(cfg, onStatus) {
|
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} …`);
|
if (onStatus) onStatus(`Updating ${f.dest} …`);
|
||||||
try {
|
try {
|
||||||
await installFile(cfg, f);
|
await installFile(cfg, f);
|
||||||
@@ -85,6 +211,12 @@ async function applyPatches(cfg, onStatus) {
|
|||||||
if (onStatus) onStatus('Applying realmlist …');
|
if (onStatus) onStatus('Applying realmlist …');
|
||||||
await applyRealmlist(cfg);
|
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.');
|
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,7 +4,9 @@ const { app, BrowserWindow, ipcMain, dialog, Menu } = require('electron');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { loadConfig, saveGameDir, resolveGameDir } = require('./lib/config-store');
|
const { loadConfig, saveGameDir, resolveGameDir } = require('./lib/config-store');
|
||||||
|
const { normalizeWinGameDir } = require('./lib/win-game-dir');
|
||||||
const { applyPatches, wowExePath, wowInstallValid, doAuth } = require('./lib/patch');
|
const { applyPatches, wowExePath, wowInstallValid, doAuth } = require('./lib/patch');
|
||||||
|
const { readPatchState } = require('./lib/patch-manifest');
|
||||||
const { setupAutoUpdater } = require('./lib/auto-update');
|
const { setupAutoUpdater } = require('./lib/auto-update');
|
||||||
|
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
@@ -46,16 +48,23 @@ async function readMergedConfig() {
|
|||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
createWindow();
|
createWindow();
|
||||||
const { config } = await loadConfig(app);
|
const { config } = await loadConfig(app);
|
||||||
const tokenEnv = config.github && config.github.token_env;
|
const ghEnv = config.github && config.github.token_env;
|
||||||
const token =
|
const githubToken =
|
||||||
(tokenEnv && String(process.env[tokenEnv] || '').trim()) ||
|
(ghEnv && String(process.env[ghEnv] || '').trim()) ||
|
||||||
String(process.env.GH_TOKEN || process.env.GITHUB_TOKEN || '').trim();
|
String(process.env.GH_TOKEN || process.env.GITHUB_TOKEN || '').trim();
|
||||||
|
const giteaEnv = config.gitea && config.gitea.token_env;
|
||||||
|
const giteaToken =
|
||||||
|
(giteaEnv && String(process.env[giteaEnv] || '').trim()) ||
|
||||||
|
String(process.env.GITEA_TOKEN || '').trim();
|
||||||
const updateFeedUrl = String(process.env.LAUNCHER_UPDATE_URL || config.update_feed_url || '').trim();
|
const updateFeedUrl = String(process.env.LAUNCHER_UPDATE_URL || config.update_feed_url || '').trim();
|
||||||
autoUpdateApi = setupAutoUpdater(app, () => mainWindow, {
|
autoUpdateApi = await setupAutoUpdater(app, () => mainWindow, {
|
||||||
updateFeedUrl,
|
updateFeedUrl,
|
||||||
|
config,
|
||||||
githubOwner: config.github && config.github.owner,
|
githubOwner: config.github && config.github.owner,
|
||||||
githubRepo: config.github && config.github.repo,
|
githubRepo: config.github && config.github.repo,
|
||||||
token,
|
githubToken,
|
||||||
|
giteaToken,
|
||||||
|
allowGithubLauncherUpdates: config.launcher_updates_from_github === true,
|
||||||
});
|
});
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
@@ -68,12 +77,18 @@ app.on('window-all-closed', () => {
|
|||||||
|
|
||||||
ipcMain.handle('launcher:load', async () => {
|
ipcMain.handle('launcher:load', async () => {
|
||||||
const { configPath, config } = await readMergedConfig();
|
const { configPath, config } = await readMergedConfig();
|
||||||
|
let clientBuild = '';
|
||||||
|
if (wowInstallValid(config)) {
|
||||||
|
const st = await readPatchState(config.game_dir);
|
||||||
|
if (st && st.client_build) clientBuild = String(st.client_build);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
configPath,
|
configPath,
|
||||||
gameDir: config.game_dir || '',
|
gameDir: config.game_dir || '',
|
||||||
authEnabled: !!(config.auth && config.auth.enabled),
|
authEnabled: !!(config.auth && config.auth.enabled),
|
||||||
wowExe: (config.launch && config.launch.exe) || 'Wow.exe',
|
wowExe: (config.launch && config.launch.exe) || 'Wow.exe',
|
||||||
wowOk: wowInstallValid(config),
|
wowOk: wowInstallValid(config),
|
||||||
|
clientBuild,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +96,8 @@ ipcMain.handle('launcher:saveGameDir', async (_e, dir) => {
|
|||||||
const trimmed = String(dir || '').trim();
|
const trimmed = String(dir || '').trim();
|
||||||
if (!trimmed) throw new Error('folder path is empty');
|
if (!trimmed) throw new Error('folder path is empty');
|
||||||
const { configPath } = await loadConfig(app);
|
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 };
|
const probe = { ...(await readMergedConfig()).config, game_dir: norm };
|
||||||
if (!wowInstallValid(probe)) {
|
if (!wowInstallValid(probe)) {
|
||||||
throw new Error(`That folder does not contain ${(probe.launch && probe.launch.exe) || 'Wow.exe'}`);
|
throw new Error(`That folder does not contain ${(probe.launch && probe.launch.exe) || 'Wow.exe'}`);
|
||||||
@@ -129,9 +145,44 @@ ipcMain.handle('launcher:checkUpdates', async () => {
|
|||||||
ipcMain.handle('launcher:play', async () => {
|
ipcMain.handle('launcher:play', async () => {
|
||||||
const { config } = await readMergedConfig();
|
const { config } = await readMergedConfig();
|
||||||
const exe = wowExePath(config);
|
const exe = wowExePath(config);
|
||||||
const args = (config.launch && config.launch.args) || [];
|
const gameArgs = (config.launch && config.launch.args) || [];
|
||||||
const child = spawn(exe, args, {
|
const lc = config.launch || {};
|
||||||
cwd: config.game_dir,
|
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,
|
detached: true,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fractured-launcher-electron",
|
"name": "fractured-launcher-electron",
|
||||||
"version": "1.0.1",
|
"version": "1.0.12",
|
||||||
"description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update",
|
"description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -9,8 +9,9 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"pack:win": "electron-builder --win nsis portable --x64",
|
"pack:win": "electron-builder --win nsis portable --x64 --publish never",
|
||||||
"publish:win": "electron-builder --win nsis portable --x64 --publish always"
|
"pack:linux": "electron-builder --linux AppImage --x64 --publish never",
|
||||||
|
"publish:win": "electron-builder --win nsis portable --x64 --publish never"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
@@ -27,13 +28,7 @@
|
|||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist"
|
"output": "dist"
|
||||||
},
|
},
|
||||||
"publish": [
|
"publish": null,
|
||||||
{
|
|
||||||
"provider": "github",
|
|
||||||
"owner": "Dawnforger",
|
|
||||||
"repo": "Fractured-Distro"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"files": [
|
"files": [
|
||||||
"main.js",
|
"main.js",
|
||||||
"preload.cjs",
|
"preload.cjs",
|
||||||
@@ -41,6 +36,10 @@
|
|||||||
"renderer.js",
|
"renderer.js",
|
||||||
"styles.css",
|
"styles.css",
|
||||||
"default-launcher.json",
|
"default-launcher.json",
|
||||||
|
"lib/win-game-dir.js",
|
||||||
|
"lib/baked-gitea-channel.js",
|
||||||
|
"lib/gitea-release.js",
|
||||||
|
"lib/patch-manifest.js",
|
||||||
"lib/**/*"
|
"lib/**/*"
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
@@ -62,6 +61,18 @@
|
|||||||
},
|
},
|
||||||
"portable": {
|
"portable": {
|
||||||
"artifactName": "Fractured-Launcher-${version}-Windows-Portable.${ext}"
|
"artifactName": "Fractured-Launcher-${version}-Windows-Portable.${ext}"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "AppImage",
|
||||||
|
"arch": ["x64"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"category": "Game"
|
||||||
|
},
|
||||||
|
"appImage": {
|
||||||
|
"artifactName": "Fractured-Launcher-${version}-Linux-x86_64.${ext}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Push a one-file README so the Gitea repo is non-empty (fixes HTTP 422 "repo is empty"
|
||||||
|
# when CI creates a release). Safe to re-run only if the repo still has no commits;
|
||||||
|
# if it already has history, skip or use the Gitea web UI instead.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# export GITEA_BASE_URL=https://git.example.com
|
||||||
|
# export GITEA_OWNER=myorg
|
||||||
|
# export GITEA_REPO=fractured-patches
|
||||||
|
# ./bootstrap-gitea-repo.sh
|
||||||
|
#
|
||||||
|
# Or pass an explicit clone URL (HTTPS or SSH):
|
||||||
|
# ./bootstrap-gitea-repo.sh https://git.example.com/myorg/fractured-patches.git
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BRANCH="${GITEA_TARGET_REF:-main}"
|
||||||
|
|
||||||
|
if [ "${1:-}" != "" ]; then
|
||||||
|
URL="$1"
|
||||||
|
else
|
||||||
|
: "${GITEA_BASE_URL:?Set GITEA_BASE_URL or pass clone URL as first argument}"
|
||||||
|
: "${GITEA_OWNER:?Set GITEA_OWNER or pass clone URL as first argument}"
|
||||||
|
: "${GITEA_REPO:?Set GITEA_REPO or pass clone URL as first argument}"
|
||||||
|
BASE="${GITEA_BASE_URL%/}"
|
||||||
|
URL="${BASE}/${GITEA_OWNER}/${GITEA_REPO}.git"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
cd "$TMP"
|
||||||
|
|
||||||
|
git init -q
|
||||||
|
git checkout -q -b "$BRANCH"
|
||||||
|
|
||||||
|
cat >README.md <<'EOF'
|
||||||
|
# Fractured release mirror
|
||||||
|
|
||||||
|
Release assets (launcher builds, patches, `patch-manifest.json`, etc.) are uploaded here by **GitHub Actions** (“Sync release to Gitea”) from the main Fractured repository.
|
||||||
|
|
||||||
|
This initial commit exists because **Gitea requires at least one commit** in the repository before releases can be created.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
git add README.md
|
||||||
|
git commit -q -m "chore: initial commit (required for Gitea releases)"
|
||||||
|
|
||||||
|
git remote add origin "$URL"
|
||||||
|
git push -u origin "$BRANCH"
|
||||||
|
|
||||||
|
echo "Pushed initial README to $URL (branch $BRANCH)."
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Build patch-manifest.json for a release (same names as files[].source in launcher.json).
|
||||||
|
*
|
||||||
|
* 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 Wow-patched.exe > patch-manifest.json
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
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 Wow-patched.exe');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = { version, files: {} };
|
||||||
|
for (const f of names) {
|
||||||
|
const base = path.basename(f);
|
||||||
|
const buf = fs.readFileSync(f);
|
||||||
|
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||||
|
out.files[base] = { sha256 };
|
||||||
|
}
|
||||||
|
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Local Linux AppImage build (uses current tree — no tag snapshot). Run from repo root or this dir.
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
echo "==> npm ci"
|
||||||
|
npm ci
|
||||||
|
echo "==> npm run pack:linux (AppImage x64)"
|
||||||
|
npm run pack:linux
|
||||||
|
echo "==> dist/:"
|
||||||
|
ls -la dist/
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
#
|
#
|
||||||
# Usage (from repo root or this directory):
|
# Usage (from repo root or this directory):
|
||||||
# export GH_TOKEN=ghp_... # PAT with repo/releases on the distro repo
|
# 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:
|
# Optional:
|
||||||
# DISTRO_REPO=YourOrg/Fratured-Distro # if your GitHub slug differs
|
# 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Upload all files in a directory as attachments on a Gitea release (create release if missing).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# export GITEA_BASE_URL=https://git.example.com
|
||||||
|
# export GITEA_TOKEN=gta_...
|
||||||
|
# export GITEA_OWNER=myorg
|
||||||
|
# export GITEA_REPO=fractured-patches
|
||||||
|
# export GITEA_TARGET_REF=main # optional, used when creating a new release (tag must not exist yet)
|
||||||
|
# ./upload-release-to-gitea.sh /path/to/combined v1.0.0
|
||||||
|
#
|
||||||
|
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)}"
|
||||||
|
|
||||||
|
: "${GITEA_BASE_URL:?Set GITEA_BASE_URL (no trailing slash required)}"
|
||||||
|
: "${GITEA_TOKEN:?Set GITEA_TOKEN}"
|
||||||
|
: "${GITEA_OWNER:?Set GITEA_OWNER}"
|
||||||
|
: "${GITEA_REPO:?Set GITEA_REPO}"
|
||||||
|
|
||||||
|
BASE="${GITEA_BASE_URL%/}"
|
||||||
|
API="$BASE/api/v1"
|
||||||
|
TARGET="${GITEA_TARGET_REF:-main}"
|
||||||
|
AUTH_H=(-H "Authorization: token ${GITEA_TOKEN}" -H "Accept: application/json")
|
||||||
|
|
||||||
|
TAG_ENC=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$TAG")
|
||||||
|
REL_JSON=$(mktemp)
|
||||||
|
trap 'rm -f "$REL_JSON"' EXIT
|
||||||
|
|
||||||
|
code=$(curl -sS -o "$REL_JSON" -w "%{http_code}" "${AUTH_H[@]}" \
|
||||||
|
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/tags/${TAG_ENC}")
|
||||||
|
|
||||||
|
if [ "$code" = "200" ]; then
|
||||||
|
rel_id=$(jq -r '.id' "$REL_JSON")
|
||||||
|
elif [ "$code" = "404" ]; then
|
||||||
|
body=$(jq -n \
|
||||||
|
--arg tag "$TAG" \
|
||||||
|
--arg name "Fractured $TAG" \
|
||||||
|
--arg body "Synced from GitHub Actions (Fractured)." \
|
||||||
|
--arg target "$TARGET" \
|
||||||
|
'{tag_name:$tag,name:$name,body:$body,draft:false,prerelease:false,target_commitish:$target}')
|
||||||
|
code=$(curl -sS -o "$REL_JSON" -w "%{http_code}" -X POST "${AUTH_H[@]}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$body" \
|
||||||
|
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases")
|
||||||
|
if [ "$code" != "201" ] && [ "$code" != "200" ]; then
|
||||||
|
echo "Gitea create release failed HTTP $code:" >&2
|
||||||
|
cat "$REL_JSON" >&2
|
||||||
|
if [ "$code" = "422" ] && jq -e '.message == "repo is empty"' "$REL_JSON" >/dev/null 2>&1; then
|
||||||
|
echo >&2
|
||||||
|
echo "Gitea does not allow releases on a repo with zero commits. Fix: push at least one commit" >&2
|
||||||
|
echo "to ${GITEA_OWNER}/${GITEA_REPO} (e.g. add README.md on branch ${TARGET} via web UI or git push)," >&2
|
||||||
|
echo "or set Actions variable GITEA_TARGET_REF to an existing default branch name." >&2
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rel_id=$(jq -r '.id' "$REL_JSON")
|
||||||
|
else
|
||||||
|
echo "Gitea GET release by tag failed HTTP $code:" >&2
|
||||||
|
cat "$REL_JSON" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$rel_id" ] || [ "$rel_id" = "null" ]; then
|
||||||
|
echo "Could not resolve Gitea release id" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
files=("$COMBINED_DIR"/*)
|
||||||
|
if [ "${#files[@]}" -eq 0 ]; then
|
||||||
|
echo "No files in $COMBINED_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
uploadable=0
|
||||||
|
for f in "${files[@]}"; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
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 $uploaded file(s)."
|
||||||
Reference in New Issue
Block a user