Compare commits

...

14 Commits

Author SHA1 Message Date
Docker Build 9fb80102c8 Paragon: spell unlearn queue + AE/TE reconciliation
Two related additions to mod-paragon:

  * HandleCommit gains a third payload section, " u:<id>,...", carrying
    spell IDs the player wants to refund + unlearn in the same commit
    that learns / talents through. The protocol stays backward-compat
    (older clients omit the section). PanelUnlearnSpellPurchase mirrors
    the per-spell branch of HandleParagonResetAbilities: tracked passive
    children are removed first, then the chain head, then panel_spells /
    panel_spell_children / panel_spell_revoked rows for that purchase
    are dropped, then LookupSpellAECost(head) is refunded into the
    cache. Unlearns are applied before learns inside the commit so the
    refund covers the same-commit spends. Allow-list for the silence
    window now includes chain ranks + panel_spell_children for the
    intentional unlearns so "You have unlearned X" toasts stay visible
    for the targeted spell while cascade dependents stay silenced.

  * ReconcileEssenceForPlayer reads panel_spells + panel_talents and
    sets the cache to ComputeStartingAE/TE(level) - sum-of-spends.
    Self-heals drift in either direction: clamps the cache down when
    the player has more essence than their level + spends allow
    (cheese clamp), and tops up when they have less (admin-tweak /
    crash recovery). Wired into OnPlayerLogin (after LoadCurrencyFromDb,
    before PushCurrency so the first balance the client sees is the
    reconciled one) and OnPlayerLevelChanged (replaces the old
    GrantLevelUpEssence delta -- Reconcile sets the absolute correct
    balance from level + spend, so it subsumes the per-level grant and
    the cheese clamp in one call). Costs come from the same
    paragon_spell_ae_cost / config keys HandleCommit uses so the math
    stays in lockstep across any future cost rebalance.

Both features ship in patch-enUS-6.MPQ v0.9.16: right-click a learned
spell row to queue an unlearn (header shows +N AE refund preview) and
hit Learn All to apply. The icon picker also got two fixes -- the
leading INV_Misc_QuestionMark is no longer duplicated, and the
selection ring is now a tooltip-border Frame anchored to the cell
bounds (the prior UI-ActionButton-Border texture rendered nearly
invisible at non-native sizes).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 19:57:47 -04:00
Docker Build 7028258084 feat(launcher): bake Gitea base_url/owner/repo into pack from env or channel file
- inject-release-channel.js merges GITEA_* (or fractured-release-channel.json) into
  default-launcher.json before electron-builder.
- CI passes existing GITEA_BASE_URL/OWNER/REPO secrets into the Windows pack job.
- npm run pack:win/publish:win run the injector; workflows use npm run pack:win.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 17:33:28 -05:00
Docker Build 5966eb0ffc scripts: document default mysql acore/acore for FRACTURED_MYSQL example
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 16:24:37 -05:00
Docker Build 90c8db0b04 scripts: tee vps-paragon-diagnostics output to var/vps-paragon-diagnostics-last.txt
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 15:57:54 -05:00
Docker Build 9240bf1243 scripts: clarify empty spell_dbc samples; add version + rune override probes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 15:53:57 -05:00
Docker Build 88f8dcb0e7 scripts: extend vps-paragon-diagnostics for rune/RP DBC and binary parity
- Binary sha256 + revision-like strings for dev vs VPS compare
- worldserver.conf Rate.RunicPower and mod_paragon.conf Paragon.* keys
- MySQL: chrclasses_dbc 6/12, spell_dbc sample, spellrunecost join
- FRACTURED_SPELL_IDS override for custom spell spot-checks

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 15:46:35 -05:00
Docker Build 9cb3c79dbe fix(launcher): opt-in GitHub auto-update; clarify Gitea for from_release
- Gate electron-updater GitHub provider on launcher_updates_from_github (default false)
  so GITHUB_TOKEN no longer targets the source repo without latest.yml.
- Improve GitHub releases 404 hint when assets are on Gitea.
- Document in README and default-launcher.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 15:38:07 -05:00
Docker Build 75e3b59442 chore(gitea): add bootstrap-gitea-repo.sh for initial README commit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 15:29:07 -05:00
Docker Build 030c2307c2 scripts: add vps-paragon-diagnostics.sh for native VPS triage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 15:19:59 -05:00
Docker Build 27d54f15a2 fix(gitea): document and explain HTTP 422 repo is empty on release create
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 14:37:02 -05:00
Docker Build 5e18c2b766 docs(ci): explain Re-run vs Run workflow for Gitea sync (GH_TOKEN error)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 14:29:21 -05:00
Docker Build 1c85341b1f ci: disable electron-builder GitHub publish; add Gitea sync workflow
- Use --publish never in pack/CI so tagged builds do not require GH_TOKEN.
- Set build.publish to null and align publish:win with local-only packaging.
- Add Gitea release sync workflow and upload script; fetch script from default
  branch so reruns work for tags that predate the script.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 14:24:42 -05:00
Docker Build ef02839ea0 Paragon: Save-Current build, archive retired share codes, reset clears active
Server side of the v0.7.10 Builds drop. Squashes a few footguns from
the original Builds catalog and adds a one-click "save what I have
right now" path the Overview pane can hook directly into.

- HandleBuildSaveCurrent: new C BUILD SAVE_CURRENT verb. Inserts a
  fresh build row, snapshots the live panel into its recipe, sets it
  active. No AE/TE motion, no relearning -- just a named slot for
  whatever the player already has.
- Reset abilities / Reset talents now SetActiveBuildId(0) and re-push
  the catalog. Without this, the next swap silently overwrote the
  active build's saved recipe with the (now empty/partial) post-reset
  state -- effectively erasing the build.
- Delete of the *active* build is now a hard reset (HandleParagonResetAll):
  unlearn everything the panel bought, refund all AE/TE. Deleting a
  non-active slot still just removes the saved recipe row + parked pet.
- Load of the currently-active build is now a "revert to last snapshot"
  instead of a no-op refresh: keeps the saved recipe authoritative,
  parks the pet, resets, re-applies. Useful for discarding pending
  edits.
- After a successful Learn All while a build is active: archive the
  build's previous share_code + recipe into
  character_paragon_build_share_archive* (so codes already posted to
  Discord keep importing the frozen loadout), snapshot the new panel
  into the live build, assign a fresh share_code, push catalog.
- HandleBuildImport now falls back to the archive tables when a code
  isn't in the live catalog -- old shared codes resurrect the recipe
  they pointed at when they were retired.
- Imports never copy pet_number (the parked pet belongs to the source
  player); if the imported recipe contains Tame Beast we hint that the
  importer needs to tame their own pet.
- BuildPanelOwnedSpellsAllowlist now walks SPELL_EFFECT_LEARN_SPELL
  effects on talent rank spells (Mangle, Feral Charge, Mutilate, ...)
  so the login cascade sweep stops revoking talent-granted active
  abilities.

Schema: new mod-paragon migration 2026_05_10_05.sql adds
character_paragon_build_share_archive (+ _spells / _talents).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 15:12:12 -04:00
Docker Build 377927b878 chore(launcher): Electron-only distro, CI sync with Windows pack 2026-05-10 12:34:43 -05:00
28 changed files with 8260 additions and 66 deletions
+150
View File
@@ -0,0 +1,150 @@
# When a release is published on this repo (or manual dispatch):
# 1. Builds the Electron launcher from that tag (npm run pack:win).
# 2. Downloads any assets attached to the same release on this repo (patches, Wow exe, …).
# 3. Merges them (launcher files win on name collision) and creates/updates the matching
# release on Fractured-Distro.
#
# Setup (GitHub → Settings → Secrets and variables → Actions):
# DISTRO_SYNC_TOKEN — PAT with releases write on Fractured-Distro (see repo README).
#
# Change DISTRO_REPO or the job `if:` if your GitHub slugs differ.
name: Sync release to Fractured-Distro
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag on this repo (must exist; e.g. v1.0.0)'
required: true
type: string
permissions:
contents: read
env:
DISTRO_REPO: Dawnforger/Fractured-Distro
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: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
fi
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 }}
- 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
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/
}
Get-ChildItem tools/fractured-launcher-electron/dist/*.blockmap -ErrorAction SilentlyContinue |
Copy-Item -Destination launcher-publish/
- uses: actions/upload-artifact@v4
with:
name: electron-dist
path: launcher-publish/
sync-distro:
needs: [meta, build-electron]
if: github.repository == 'Dawnforger/Fractured'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: electron-dist
path: /tmp/electron
- name: Merge main release assets + Electron build
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
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
cp -f "$f" combined/
fi
done
echo "Merged assets from ${{ github.repository }} release $TAG"
else
echo "Main release download note (continuing with launcher only):"
cat /tmp/dl.err || true
fi
shopt -s nullglob
for f in /tmp/electron/*; do
if [ -f "$f" ]; then
cp -f "$f" combined/
fi
done
echo "Combined directory:"
ls -la combined/
- name: Upload to Fractured-Distro
env:
GH_TOKEN: ${{ secrets.DISTRO_SYNC_TOKEN }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "Missing secret DISTRO_SYNC_TOKEN (PAT with access to $DISTRO_REPO)."
exit 1
fi
TAG="${{ needs.meta.outputs.tag }}"
shopt -s nullglob
files=(combined/*)
if [ "${#files[@]}" -eq 0 ]; then
echo "Nothing to upload (Electron pack produced no files?)."
exit 1
fi
SRC_URL="https://github.com/${{ github.repository }}/releases/tag/${TAG}"
if gh release view "$TAG" -R "$DISTRO_REPO" &>/dev/null; then
gh release upload "$TAG" -R "$DISTRO_REPO" "${files[@]}" --clobber
echo "Uploaded (clobber) to $DISTRO_REPO release $TAG"
else
gh release create "$TAG" -R "$DISTRO_REPO" \
--title "Fractured $TAG" \
--notes "Synced from [$TAG]($SRC_URL) on ${{ github.repository }}. Includes CI-built Electron launcher + release assets." \
"${files[@]}"
echo "Created $DISTRO_REPO release $TAG with ${#files[@]} asset(s)."
fi
@@ -0,0 +1,56 @@
# Verifies Electron launcher Windows pack and uploads installers for testing.
name: Fractured launcher CI
on:
workflow_dispatch:
push:
branches: [master, main]
paths:
- 'tools/fractured-launcher-electron/**'
- '.github/workflows/fractured-launcher-ci.yml'
pull_request:
paths:
- 'tools/fractured-launcher-electron/**'
- '.github/workflows/fractured-launcher-ci.yml'
permissions:
contents: read
concurrency:
group: fractured-launcher-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
electron-launcher:
runs-on: windows-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 (NSIS + portable)
env:
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
GITEA_OWNER: ${{ secrets.GITEA_OWNER }}
GITEA_REPO: ${{ secrets.GITEA_REPO }}
run: |
npm ci
npm run pack:win
- uses: actions/upload-artifact@v4
with:
name: fractured-launcher-electron-windows-${{ github.run_id }}
if-no-files-found: warn
path: |
tools/fractured-launcher-electron/dist/*.exe
tools/fractured-launcher-electron/dist/latest.yml
tools/fractured-launcher-electron/dist/*.blockmap
+164
View File
@@ -0,0 +1,164 @@
# 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: build Electron from tag → download this repos release attachments → 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: 'Release tag on this GitHub repo (must exist; e.g. v1.0.0)'
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: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
fi
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 }}
- 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
env:
# Same values as upload step — baked into default-launcher.json (no token).
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
GITEA_OWNER: ${{ secrets.GITEA_OWNER }}
GITEA_REPO: ${{ secrets.GITEA_REPO }}
run: |
npm ci
# pack:win runs inject-release-channel.js then electron-builder --publish never
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/
}
Get-ChildItem tools/fractured-launcher-electron/dist/*.blockmap -ErrorAction SilentlyContinue |
Copy-Item -Destination launcher-publish/
- uses: actions/upload-artifact@v4
with:
name: electron-dist
path: launcher-publish/
sync-gitea:
needs: [meta, build-electron]
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
path: /tmp/electron
- name: Merge GitHub release assets + Electron build
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
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
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/*; 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 }}"
@@ -7,6 +7,13 @@ re-downloaded without bloating `git clone`.
This file is the table of contents and install guide.
**Launcher (Windows):** The maintained client launcher lives in
[`tools/fractured-launcher-electron/`](../../tools/fractured-launcher-electron/)
(see its README for build and config). **Public downloads** for the launcher
and mirrored patch assets are pushed to
[Fractured-Distro releases](https://github.com/Dawnforger/Fractured-Distro/releases)
when a release is published here (workflow **Sync release to Fractured-Distro**).
---
## What ships in a release
@@ -0,0 +1,34 @@
-- mod-paragon: preserve superseded share codes as importable snapshots.
-- ----------------------------------------------------------------------------
-- When an active build is updated (Learn All), the live row gets a new
-- share_code and a fresh recipe. Older codes the player posted to Discord
-- must keep working: each retired code is frozen here with its spell/talent
-- recipe so `C BUILD IMPORT <code>` still materializes that exact loadout.
-- ----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `character_paragon_build_share_archive` (
`share_code` CHAR(6) NOT NULL COMMENT 'retired code (same charset as live builds)',
`name` VARCHAR(32) NOT NULL,
`icon` VARCHAR(64) NOT NULL DEFAULT 'INV_Misc_QuestionMark',
`archived_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`share_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='mod-paragon: frozen build metadata for retired share codes';
CREATE TABLE IF NOT EXISTS `character_paragon_build_share_archive_spells` (
`share_code` CHAR(6) NOT NULL,
`spell_id` INT UNSIGNED NOT NULL,
PRIMARY KEY (`share_code`, `spell_id`),
KEY `idx_share` (`share_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='mod-paragon: spell recipe rows for an archived share code';
CREATE TABLE IF NOT EXISTS `character_paragon_build_share_archive_talents` (
`share_code` CHAR(6) NOT NULL,
`spec` TINYINT UNSIGNED NOT NULL,
`talent_id` SMALLINT UNSIGNED NOT NULL,
`rank` TINYINT UNSIGNED NOT NULL,
PRIMARY KEY (`share_code`, `spec`, `talent_id`),
KEY `idx_share` (`share_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='mod-paragon: talent recipe rows for an archived share code';
+608 -66
View File
@@ -257,10 +257,99 @@ uint32 GetParagonPanelSpellRequiredLevel(SpellInfo const* info)
return std::max(1u, lv);
}
// Reconciles the player's AE/TE cache against what they SHOULD have
// based on level (ComputeStartingAE/TE) minus what they've spent through
// Character Advancement (sum over character_paragon_panel_spells +
// character_paragon_panel_talents). Updates the cache + DB if either
// direction drifts:
// * actual < expected: top up (handles per-level grants automatically;
// also self-heals from admin commands / crashes that lost essence).
// * actual > expected: clamp down (prevents .modify-style cheese, ghost
// panel rows that were rolled back, or any path that left more
// essence than the level allowed).
// Logs at INFO when drift is corrected so we can spot abuse patterns.
//
// Cheap (two SELECTs of small per-character tables) and safe to call from
// OnPlayerLogin and OnPlayerLevelChanged. SAFE TO CALL ANY TIME the panel
// DB is in a steady state (i.e. NOT mid-HandleCommit).
void ReconcileEssenceForPlayer(Player* pl)
{
if (!pl || pl->getClass() != CLASS_PARAGON)
return;
if (!sConfigMgr->GetOption<bool>("Paragon.Currency.Enabled", true))
return;
uint32 const lowGuid = pl->GetGUID().GetCounter();
uint8 const level = pl->GetLevel();
// Sum AE / TE spent through panel purchases. Mirrors the cost lookups
// used by HandleCommit so reconciliation matches the spend math byte-
// for-byte (no off-by-one if config keys are tweaked at runtime).
uint32 spentAE = 0;
uint32 spentTE = 0;
if (QueryResult r = CharacterDatabase.Query(
"SELECT spell_id FROM character_paragon_panel_spells WHERE guid = {}", lowGuid))
{
do
{
spentAE += LookupSpellAECost(r->Fetch()[0].Get<uint32>());
} while (r->NextRow());
}
uint32 const tePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
uint32 const aePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.TalentLearnCost", 1);
if (QueryResult r = CharacterDatabase.Query(
"SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}", lowGuid))
{
do
{
Field const* f = r->Fetch();
uint32 const tid = f[0].Get<uint32>();
uint32 const rank = f[1].Get<uint32>();
TalentEntry const* te = sTalentStore.LookupEntry(tid);
if (!te || !rank)
continue;
spentTE += rank * tePerRank;
if (te->addToSpellBook)
spentAE += rank * aePerRank;
} while (r->NextRow());
}
uint32 const expectedTotalAE = ComputeStartingAE(level);
uint32 const expectedTotalTE = ComputeStartingTE(level);
uint32 const expectedBalAE = expectedTotalAE > spentAE ? expectedTotalAE - spentAE : 0;
uint32 const expectedBalTE = expectedTotalTE > spentTE ? expectedTotalTE - spentTE : 0;
ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid);
if (d.abilityEssence == expectedBalAE && d.talentEssence == expectedBalTE)
return;
LOG_INFO("module",
"Paragon essence reconciled for {} (lvl {}): AE {}->{} TE {}->{} (spent AE={} TE={}, expected total AE={} TE={})",
pl->GetName(), uint32(level),
d.abilityEssence, expectedBalAE,
d.talentEssence, expectedBalTE,
spentAE, spentTE,
expectedTotalAE, expectedTotalTE);
d.abilityEssence = expectedBalAE;
d.talentEssence = expectedBalTE;
SaveCurrencyToDb(pl);
}
// Forward declaration: reset handlers below need PushSnapshot, which itself
// is defined later (after PushSpellSnapshot / PushTalentSnapshot).
void PushSnapshot(Player* pl);
// Forward declarations: reset handlers below clear the active build pointer
// and re-push the build catalog so the cell border drops client-side. Both
// helpers live with the rest of the build catalog code further down.
void SetActiveBuildId(uint32 lowGuid, uint32 buildId);
void PushBuildCatalog(Player* pl);
bool BuildBelongsToPlayer(uint32 lowGuid, uint32 buildId);
void SnapshotBuildFromCurrent(Player* pl, uint32 buildId);
// Forward declaration: the login-time scoped sweep (defined a few helpers
// down) calls into the chain-walker (defined further down).
void CollectSpellChainIds(uint32 baseSpellId, std::unordered_set<uint32>& out);
@@ -374,8 +463,40 @@ void BuildPanelOwnedSpellsAllowlist(uint32 lowGuid, std::unordered_set<uint32>&
// protects all lower ranks the player rolled through.
uint32 const cap = std::min<uint32>(rank, MAX_TALENT_RANK);
for (uint32 i = 0; i < cap; ++i)
if (te->RankID[i])
allowed.insert(te->RankID[i]);
{
uint32 const rankSpell = te->RankID[i];
if (!rankSpell)
continue;
allowed.insert(rankSpell);
// Some talents (Mangle, Feral Charge, Mutilate, ...) own a
// *passive* RankID spell whose effects then LEARN_SPELL the
// actual active abilities the player gets to use. RankID
// alone isn't enough -- the granted spells live on the
// class skill line, so the login cascade sweep would see
// them as "not allowlisted" and revoke them, and from the
// user's POV the ability vanishes on relog.
//
// Walk every effect of this rank's spell, expand each
// SPELL_EFFECT_LEARN_SPELL trigger through CollectSpellChainIds
// so the whole rank chain of the granted spell stays
// protected too (e.g. Mangle Bear rank 1 33878 ->
// 33986 -> ... -> 48566; we want all of them on the
// allowlist so a high-level Paragon druid keeps the rank
// appropriate to their level).
SpellInfo const* rankInfo = sSpellMgr->GetSpellInfo(rankSpell);
if (!rankInfo)
continue;
for (uint8 e = 0; e < MAX_SPELL_EFFECTS; ++e)
{
if (rankInfo->Effects[e].Effect != SPELL_EFFECT_LEARN_SPELL)
continue;
uint32 const grantId = rankInfo->Effects[e].TriggerSpell;
if (!grantId)
continue;
CollectSpellChainIds(grantId, allowed);
}
}
} while (r->NextRow());
}
@@ -949,10 +1070,12 @@ uint32 ComputeTalentRankAnySpec(Player* pl, uint32 talentId)
// ---- Commit handler --------------------------------------------------------
//
// Wire format from Net.lua Net:Commit:
// "C COMMIT s:<id1>,<id2>,...<empty allowed> t:<talentId>:<delta>,..."
// Both sub-lists are optional but the leading tags are not. Examples:
// "C COMMIT s:<id1>,<id2>,...<empty allowed> t:<talentId>:<delta>,... u:<id>,..."
// The " u:" spell-unlearn section is optional (omitted by older clients).
// Both s: and t: leading tags are required. Examples:
// "C COMMIT s:5176,8921 t:"
// "C COMMIT s: t:1234:1,5678:2"
// "C COMMIT s: t: u:45477"
// We parse leniently and abort on any structural error.
//
// On success we push:
@@ -995,12 +1118,15 @@ std::vector<uint32> ParseCsvUInt(std::string_view csv)
// * full chain ids for every spell the player explicitly purchased (so
// the player still sees "Plague Strike (Rank 1..N) learned" toasts);
// * every talent rank id the player explicitly purchased (so addToSpellBook
// talents like Bladestorm/Starfall still toast normally).
// talents like Bladestorm/Starfall still toast normally);
// * chain ids + tracked passive children for spells intentionally unlearned
// in this commit (so "You have unlearned …" for those stays visible).
// Anything else learned/unlearned during the window -- the SkillLineAbility
// cascade and our diff-revoke cleanup -- is silenced.
void SendSilenceOpenForCommit(Player* pl,
std::vector<std::pair<uint32, uint32>> const& spellsAndCosts,
std::vector<std::pair<uint32, uint32>> const& talentDeltas)
std::vector<std::pair<uint32, uint32>> const& talentDeltas,
std::vector<uint32> const& unlearnTrackIds = {})
{
if (!pl)
return;
@@ -1009,6 +1135,26 @@ void SendSilenceOpenForCommit(Player* pl,
for (auto const& kv : spellsAndCosts)
CollectSpellChainIds(kv.first, allow);
uint32 const lowGuid = pl->GetGUID().GetCounter();
for (uint32 trackId : unlearnTrackIds)
{
if (!trackId)
continue;
CollectSpellChainIds(trackId, allow);
if (QueryResult cr = CharacterDatabase.Query(
"SELECT child_spell_id FROM character_paragon_panel_spell_children "
"WHERE guid = {} AND parent_spell_id = {}",
lowGuid, trackId))
{
do
{
uint32 const cid = cr->Fetch()[0].Get<uint32>();
if (cid)
allow.insert(cid);
} while (cr->NextRow());
}
}
for (auto const& [tid, delta] : talentDeltas)
{
(void)delta;
@@ -1054,6 +1200,71 @@ void SendSilenceClose(Player* pl)
SendAddonMessage(pl, "R SILENCE CLOSE");
}
// Removes one Character Advancement spell purchase (chain head in
// character_paragon_panel_spells). Refunds that row's AE cost, unlearns
// tracked passive children then the parent chain, and clears matching
// panel_* DB rows (mirrors the per-spell portion of HandleParagonResetAbilities).
// `spellId` may be any rank id from the bake; normalized to GetFirstSpellInChain.
bool PanelUnlearnSpellPurchase(Player* pl, uint32 spellId, std::string* err)
{
if (!pl || !spellId)
{
if (err)
*err = "bad player or spell";
return false;
}
uint32 const lowGuid = pl->GetGUID().GetCounter();
uint32 const head = sSpellMgr->GetFirstSpellInChain(spellId);
uint32 const sid = head ? head : spellId;
if (!CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {} LIMIT 1",
lowGuid, sid))
{
if (err)
*err = fmt::format("spell {} is not a panel purchase", sid);
return false;
}
uint32 const refund = LookupSpellAECost(sid);
if (QueryResult cr = CharacterDatabase.Query(
"SELECT child_spell_id FROM character_paragon_panel_spell_children "
"WHERE guid = {} AND parent_spell_id = {}",
lowGuid, sid))
{
do
{
uint32 const cid = cr->Fetch()[0].Get<uint32>();
if (pl->HasSpell(cid))
pl->removeSpell(cid, SPEC_MASK_ALL, false);
} while (cr->NextRow());
}
if (pl->HasSpell(sid))
pl->removeSpell(sid, SPEC_MASK_ALL, false);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_panel_spell_children WHERE guid = {} AND parent_spell_id = {}",
lowGuid, sid);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_panel_spell_revoked WHERE guid = {} AND parent_spell_id = {}",
lowGuid, sid);
std::unordered_set<uint32> chainIds;
CollectSpellChainIds(sid, chainIds);
DbDeletePanelSpellRevokedForChain(lowGuid, chainIds);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {}",
lowGuid, sid);
ParagonCurrencyData& d = GetOrCreateCacheEntry(lowGuid);
d.abilityEssence += refund;
return true;
}
bool HandleCommit(Player* pl, std::string const& body, std::string* err)
{
// Strip leading "C COMMIT " (already stripped by caller, but be defensive)
@@ -1071,10 +1282,20 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err)
return false;
}
std::string_view spellsCsv = rest.substr(2, tPos - 2);
std::string_view talentsCsv = rest.substr(tPos + 3);
std::string_view spellsCsv = rest.substr(2, tPos - 2);
std::string_view talentsCsv;
std::string_view unlearnCsv;
size_t const uPos = rest.find(" u:", tPos);
if (uPos != std::string_view::npos)
{
talentsCsv = rest.substr(tPos + 3, uPos - (tPos + 3));
unlearnCsv = rest.substr(uPos + 3);
}
else
talentsCsv = rest.substr(tPos + 3);
std::vector<uint32> spellIds = ParseCsvUInt(spellsCsv);
std::vector<uint32> unlearnRaw = ParseCsvUInt(unlearnCsv);
// Talents are "id:delta,id:delta,...". Parse into vector of pairs.
std::vector<std::pair<uint32, uint32>> talentDeltas;
@@ -1112,12 +1333,37 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err)
}
}
if (spellIds.size() + talentDeltas.size() > kCommitMaxItems)
std::unordered_set<uint32> unlearnTrackSet;
std::vector<uint32> unlearnTracks;
for (uint32 raw : unlearnRaw)
{
if (!raw)
continue;
uint32 const head = sSpellMgr->GetFirstSpellInChain(raw);
uint32 const tid = head ? head : raw;
if (unlearnTrackSet.insert(tid).second)
unlearnTracks.push_back(tid);
}
if (spellIds.size() + talentDeltas.size() + unlearnTracks.size() > kCommitMaxItems)
{
*err = "commit exceeds size cap";
return false;
}
uint32 unlearnRefundAE = 0;
for (uint32 tid : unlearnTracks)
{
if (!CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_panel_spells WHERE guid = {} AND spell_id = {} LIMIT 1",
pl->GetGUID().GetCounter(), tid))
{
*err = fmt::format("cannot unlearn {} (not a panel purchase)", tid);
return false;
}
unlearnRefundAE += LookupSpellAECost(tid);
}
// Pre-validate spells: must be valid SpellInfo, not already learned,
// and afford their combined AE cost.
uint32 totalAE = 0;
@@ -1141,6 +1387,13 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err)
*err = fmt::format("requires level {} for spell {}", reqLv, id);
return false;
}
uint32 const learnHead = sSpellMgr->GetFirstSpellInChain(id);
uint32 const learnTrack = learnHead ? learnHead : id;
if (unlearnTrackSet.count(learnTrack))
{
*err = "cannot learn and unlearn the same spell in one commit";
return false;
}
uint32 cost = LookupSpellAECost(id);
spellsAndCosts.emplace_back(id, cost);
totalAE += cost;
@@ -1182,9 +1435,10 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err)
*err = fmt::format("not enough TE (need {} have {})", talentsTE, GetTE(pl));
return false;
}
if (GetAE(pl) < (totalAE + talentsAE))
if (GetAE(pl) + unlearnRefundAE < (totalAE + talentsAE))
{
*err = fmt::format("not enough AE (need {} have {})", totalAE + talentsAE, GetAE(pl));
*err = fmt::format("not enough AE (need {} total; you have {} plus {} from unlearns in this commit)",
totalAE + talentsAE, GetAE(pl), unlearnRefundAE);
return false;
}
@@ -1194,8 +1448,18 @@ bool HandleCommit(Player* pl, std::string const& body, std::string* err)
// learnSpell drags along (Death Coil/Death Grip/Blood Plague/Blood
// Presence/Forceful Deflection/Runic Focus/...) don't spam learn/
// unlearn toasts. Allow list = chain ranks of explicitly purchased
// spells + talent rank ids. Closed below at the end of the commit.
SendSilenceOpenForCommit(pl, spellsAndCosts, talentDeltas);
// spells + talent rank ids + chains/children for intentional unlearns.
SendSilenceOpenForCommit(pl, spellsAndCosts, talentDeltas, unlearnTracks);
// Apply spell unlearns first so refunded AE is available for spends.
for (uint32 tid : unlearnTracks)
{
if (!PanelUnlearnSpellPurchase(pl, tid, err))
{
SendSilenceClose(pl);
return false;
}
}
// Apply spells: each consumes its individual AE cost. PanelLearnSpellChain
// also grants every higher rank up to the player's current level so the
@@ -1447,9 +1711,21 @@ bool HandleParagonResetAbilities(Player* pl, std::string* err)
d.abilityEssence += refundAE;
}
// Reset detaches the player from any active build. The build's
// saved recipe is preserved in DB so the player can re-load it,
// but until they do, the next swap MUST NOT auto-snapshot the
// (now empty/partial) panel state into that build -- which is
// exactly what would happen if we left the pointer set. We push
// the catalog so the cell that previously had the "active"
// border drops it client-side; if this reset is being invoked
// mid-swap (HandleBuildLoad), the swap's final PushBuildCatalog
// restores the correct activeId at the tail.
SetActiveBuildId(lowGuid, 0);
SaveCurrencyToDb(pl);
PushCurrency(pl);
PushSnapshot(pl);
PushBuildCatalog(pl);
LOG_INFO("module", "Paragon panel: {} reset abilities (+{} AE refund)", pl->GetName(), refundAE);
return true;
}
@@ -1514,9 +1790,15 @@ bool HandleParagonResetTalents(Player* pl, std::string* err)
d.abilityEssence += refundAE;
d.talentEssence += refundTE;
// See HandleParagonResetAbilities: detach from the active build
// so the next swap doesn't overwrite its saved recipe with the
// (post-reset) state.
SetActiveBuildId(lowGuid, 0);
SaveCurrencyToDb(pl);
PushCurrency(pl);
PushSnapshot(pl);
PushBuildCatalog(pl);
LOG_INFO("module", "Paragon panel: {} reset talents (+{} AE +{} TE refund)", pl->GetName(), refundAE, refundTE);
return true;
}
@@ -1555,8 +1837,13 @@ void PushSnapshot(Player* pl)
//
// Q BUILDS -- request catalog
// C BUILD NEW <name>\t<icon> -- create empty build
// C BUILD SAVE_CURRENT <name>\t<icon> -- create build from current
// panel state and set active
// C BUILD EDIT <id>\t<name>\t<icon> -- rename / re-icon
// C BUILD DELETE <id> -- delete + drop parked pet
// C BUILD DELETE <id> -- delete + drop parked pet;
// if <id> is the active build,
// also full panel reset (unlearn
// + AE/TE refund) like RESET ALL
// C BUILD LOAD <id> -- swap to this build
// C BUILD UNLOAD -- clear active pointer
// C BUILD IMPORT <sharecode> -- copy a shared build
@@ -1644,10 +1931,13 @@ std::string GenerateUniqueShareCode()
for (int attempt = 0; attempt < 8; ++attempt)
{
std::string code = GenerateBuildShareCode();
QueryResult r = CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_builds WHERE share_code = '{}'", code);
if (!r)
return code;
if (QueryResult r = CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_builds WHERE share_code = '{}'", code))
continue;
if (QueryResult r2 = CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_build_share_archive WHERE share_code = '{}'", code))
continue;
return code;
}
// Worst-case fallback: append a numeric uniquifier from build_id
// sequence. We can't produce a guaranteed-unique 6-char code if
@@ -1657,6 +1947,64 @@ std::string GenerateUniqueShareCode()
return GenerateBuildShareCode();
}
// After a successful Learn All while a build is active: freeze the
// previous share_code + recipe into character_paragon_build_share_archive*
// (so Discord-posted codes keep importing that exact loadout), then
// snapshot the panel into the live build rows and assign a fresh code
// for the owner's current recipe.
void PersistActiveBuildSnapshotAfterLearnAllCommit(Player* pl, uint32 buildId)
{
if (!pl || !buildId)
return;
uint32 const lowGuid = pl->GetGUID().GetCounter();
if (!BuildBelongsToPlayer(lowGuid, buildId))
return;
std::string oldCode;
if (QueryResult row = CharacterDatabase.Query(
"SELECT COALESCE(NULLIF(share_code, ''), '') AS sc "
"FROM character_paragon_builds WHERE build_id = {} AND guid = {}",
buildId, lowGuid))
oldCode = row->Fetch()[0].Get<std::string>();
if (!oldCode.empty())
{
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_build_share_archive_spells WHERE share_code = '{}'", oldCode);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_build_share_archive_talents WHERE share_code = '{}'", oldCode);
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_build_share_archive WHERE share_code = '{}'", oldCode);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_share_archive (share_code, name, icon) "
"SELECT share_code, name, icon FROM character_paragon_builds "
"WHERE build_id = {} AND guid = {}", buildId, lowGuid);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_share_archive_spells (share_code, spell_id) "
"SELECT '{}', spell_id FROM character_paragon_build_spells WHERE build_id = {}",
oldCode, buildId);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_share_archive_talents (share_code, spec, talent_id, `rank`) "
"SELECT '{}', spec, talent_id, `rank` FROM character_paragon_build_talents WHERE build_id = {}",
oldCode, buildId);
}
SnapshotBuildFromCurrent(pl, buildId);
std::string const newCode = GenerateUniqueShareCode();
CharacterDatabase.DirectExecute(
"UPDATE character_paragon_builds SET share_code = '{}' WHERE build_id = {} AND guid = {}",
newCode, buildId, lowGuid);
LOG_INFO("module",
"Paragon build: {} persisted active build {} after commit (share now {})",
pl->GetName(), buildId, newCode);
}
std::string SanitizeBuildName(std::string s)
{
std::string out;
@@ -1994,6 +2342,81 @@ bool HandleBuildNew(Player* pl, std::string const& payload, std::string* err)
return true;
}
// "Save current loadout as a new build". Driven by the Overview pane's
// "Save as Build" button. Equivalent to HandleBuildNew + an immediate
// SnapshotBuildFromCurrent into the new row, plus a SetActiveBuildId
// flip. Does NOT touch panel rows / currency / learned spells -- the
// player's state is already what they want, we just file it under a
// named slot. The previously-active build (if any) keeps its last
// committed recipe; loading it later restores that snapshot exactly
// as the normal swap flow does.
bool HandleBuildSaveCurrent(Player* pl, std::string const& payload, std::string* err)
{
if (!pl || pl->getClass() != CLASS_PARAGON)
{
*err = "not a Paragon";
return false;
}
if (pl->IsInCombat())
{
*err = "cannot save builds while in combat";
return false;
}
auto tab = payload.find('\t');
if (tab == std::string::npos)
{
*err = "BUILD SAVE_CURRENT malformed";
return false;
}
std::string name = SanitizeBuildName(payload.substr(0, tab));
std::string icon = SanitizeBuildIcon(payload.substr(tab + 1));
if (name.empty())
{
*err = "build name is empty";
return false;
}
uint32 const lowGuid = pl->GetGUID().GetCounter();
if (QueryResult cnt = CharacterDatabase.Query(
"SELECT COUNT(*) FROM character_paragon_builds WHERE guid = {}", lowGuid))
{
if (cnt->Fetch()[0].Get<uint32>() >= 64)
{
*err = "build limit reached (64)";
return false;
}
}
std::string insertName = name;
std::string insertIcon = icon;
CharacterDatabase.EscapeString(insertName);
CharacterDatabase.EscapeString(insertIcon);
std::string const code = GenerateUniqueShareCode();
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_builds (guid, name, icon, share_code) "
"VALUES ({}, '{}', '{}', '{}')",
lowGuid, insertName, insertIcon, code);
QueryResult idRow = CharacterDatabase.Query(
"SELECT build_id FROM character_paragon_builds "
"WHERE share_code = '{}'", code);
if (!idRow)
{
*err = "save failed (could not allocate build_id)";
return false;
}
uint32 const newBuildId = idRow->Fetch()[0].Get<uint32>();
SnapshotBuildFromCurrent(pl, newBuildId);
SetActiveBuildId(lowGuid, newBuildId);
PushBuildCatalog(pl);
LOG_INFO("module",
"Paragon build: {} saved current loadout as build {} '{}' (share {})",
pl->GetName(), newBuildId, name, code);
return true;
}
bool HandleBuildEdit(Player* pl, std::string const& payload, std::string* err)
{
if (!pl || pl->getClass() != CLASS_PARAGON)
@@ -2092,14 +2515,21 @@ bool HandleBuildDelete(Player* pl, std::string const& payload, std::string* err)
Field const* f = r->Fetch();
uint32 petNumber = f[0].IsNull() ? 0 : f[0].Get<uint32>();
// If the build being deleted is currently active, clear the
// active pointer first so the player ends up in the "no active
// build" state. Their currently-learned spells/talents are
// preserved (the client warns them about this -- they keep the
// loadout but lose the named slot and the parked pet).
// Deleting the *active* build is a hard reset: unlearn everything the
// Character Advancement panel bought, refund all AE/TE into the
// unspent pool, and clear the active pointer (same net effect as
// C RESET ALL). Deleting a non-active slot only removes the saved
// recipe row + any parked pet bound to that slot.
uint32 const active = GetActiveBuildId(lowGuid);
if (active == buildId)
SetActiveBuildId(lowGuid, 0);
{
std::string resetErr;
if (!HandleParagonResetAll(pl, &resetErr))
{
*err = resetErr;
return false;
}
}
CharacterDatabase.DirectExecute(
"DELETE FROM character_paragon_build_spells WHERE build_id = {}", buildId);
@@ -2128,8 +2558,8 @@ bool HandleBuildDelete(Player* pl, std::string const& payload, std::string* err)
//
// Errors (sent back as "R ERR ..." for the addon channel):
// - malformed code (length / charset)
// - code not found
// - the code points to one of the requester's own builds
// - code not found (neither live nor archived)
// - the code points to one of the requester's own live-catalog builds
// - the requester is at the 64-build cap
bool HandleBuildImport(Player* pl, std::string const& payload, std::string* err)
{
@@ -2159,24 +2589,42 @@ bool HandleBuildImport(Player* pl, std::string const& payload, std::string* err)
uint32 const lowGuid = pl->GetGUID().GetCounter();
// Look up the source build by share_code. We need the source's
// owner guid so we can short-circuit "import your own build"
// before consuming a slot or copying recipe rows.
// Live catalog first, then retired codes frozen in
// character_paragon_build_share_archive* (same code keeps importing
// the recipe that was current when the owner last committed).
QueryResult srcRow = CharacterDatabase.Query(
"SELECT build_id, guid, name, icon "
"FROM character_paragon_builds WHERE share_code = '{}'", code);
if (!srcRow)
bool fromArchive = false;
uint32 srcBuildId = 0;
uint32 srcOwner = 0;
std::string srcName;
std::string srcIcon;
if (srcRow)
{
Field const* sf = srcRow->Fetch();
srcBuildId = sf[0].Get<uint32>();
srcOwner = sf[1].Get<uint32>();
srcName = sf[2].Get<std::string>();
srcIcon = sf[3].Get<std::string>();
}
else if (QueryResult arch = CharacterDatabase.Query(
"SELECT name, icon FROM character_paragon_build_share_archive WHERE share_code = '{}'", code))
{
fromArchive = true;
Field const* af = arch->Fetch();
srcName = af[0].Get<std::string>();
srcIcon = af[1].Get<std::string>();
}
else
{
*err = "no build with that code";
return false;
}
Field const* sf = srcRow->Fetch();
uint32 srcBuildId = sf[0].Get<uint32>();
uint32 srcOwner = sf[1].Get<uint32>();
std::string srcName = sf[2].Get<std::string>();
std::string srcIcon = sf[3].Get<std::string>();
if (srcOwner == lowGuid)
if (!fromArchive && srcOwner == lowGuid)
{
*err = "this build is already in your catalog";
return false;
@@ -2195,11 +2643,28 @@ bool HandleBuildImport(Player* pl, std::string const& payload, std::string* err)
// Insert the new owned build first so we have its build_id to
// attach the copied recipe rows to. Server-generated share code
// is fresh -- the imported copy can be re-shared independently.
//
// Pet handling: we deliberately do NOT copy `pet_number`. A parked
// hunter pet belongs to the source player's character and lives
// in `character_pet` under their owner guid; cloning the row
// would either steal the pet (corrupting the source player's
// stable / stable-master state) or summon a pet the importer
// can't legally own. The new row leaves `pet_number = NULL`
// (column default), so when the importer first loads this build
// and HandleBuildLoad reaches Phase 4, RestoreParkedPetForBuild
// sees NULL and no-ops -- the player must tame their own pet
// (Tame Beast comes via the recipe if the source build had it),
// and on next swap-away ParkActivePetForBuild will bind THEIR
// pet to the row exactly like a locally-created build.
std::string newCode = GenerateUniqueShareCode();
std::string insertName = srcName;
std::string insertIcon = srcIcon;
CharacterDatabase.EscapeString(insertName);
CharacterDatabase.EscapeString(insertIcon);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_builds (guid, name, icon, share_code) "
"VALUES ({}, '{}', '{}', '{}')",
lowGuid, srcName, srcIcon, newCode);
lowGuid, insertName, insertIcon, newCode);
QueryResult idRow = CharacterDatabase.Query(
"SELECT build_id FROM character_paragon_builds "
@@ -2215,22 +2680,64 @@ bool HandleBuildImport(Player* pl, std::string const& payload, std::string* err)
// need to materialize them in C++. Using a literal `newBuildId`
// for the copy so the foreign reference is correct on the new
// owner's row.
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_spells (build_id, spell_id) "
"SELECT {}, spell_id FROM character_paragon_build_spells WHERE build_id = {}",
newBuildId, srcBuildId);
if (!fromArchive)
{
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_spells (build_id, spell_id) "
"SELECT {}, spell_id FROM character_paragon_build_spells WHERE build_id = {}",
newBuildId, srcBuildId);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_talents (build_id, spec, talent_id, `rank`) "
"SELECT {}, spec, talent_id, `rank` FROM character_paragon_build_talents "
"WHERE build_id = {}",
newBuildId, srcBuildId);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_talents (build_id, spec, talent_id, `rank`) "
"SELECT {}, spec, talent_id, `rank` FROM character_paragon_build_talents "
"WHERE build_id = {}",
newBuildId, srcBuildId);
}
else
{
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_spells (build_id, spell_id) "
"SELECT {}, spell_id FROM character_paragon_build_share_archive_spells "
"WHERE share_code = '{}'",
newBuildId, code);
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_build_talents (build_id, spec, talent_id, `rank`) "
"SELECT {}, spec, talent_id, `rank` FROM character_paragon_build_share_archive_talents "
"WHERE share_code = '{}'",
newBuildId, code);
}
PushBuildCatalog(pl);
LOG_INFO("module",
"Paragon build: {} imported '{}' (src build {} owner {}) "
"as new build {} with code {}",
pl->GetName(), srcName, srcBuildId, srcOwner, newBuildId, newCode);
// Hunter-pet hint: imports never carry a parked pet (see comment
// before the INSERT above). If the recipe contains Tame Beast
// (spell 1515) we surface a one-line system message so the player
// knows they need to tame their own pet before that build feels
// "complete". Other classes' pet-summon spells (Summon Imp, Raise
// Dead, ...) re-summon a fresh entity each cast so they don't
// need any heads-up.
QueryResult petCheck = CharacterDatabase.Query(
"SELECT 1 FROM character_paragon_build_spells "
"WHERE build_id = {} AND spell_id = 1515 LIMIT 1", newBuildId);
if (petCheck && pl->GetSession())
{
std::string const msg = fmt::format(
"|cffffd200[Paragon]|r Imported \"{}\" includes Tame Beast. "
"Tame your own pet after loading this build -- the source "
"player's pet was not transferred.", srcName);
ChatHandler(pl->GetSession()).SendSysMessage(msg.c_str());
}
if (fromArchive)
LOG_INFO("module",
"Paragon build: {} imported archived code '{}' as new build {} (share {})",
pl->GetName(), code, newBuildId, newCode);
else
LOG_INFO("module",
"Paragon build: {} imported '{}' (src build {} owner {}) "
"as new build {} with code {}",
pl->GetName(), srcName, srcBuildId, srcOwner, newBuildId, newCode);
return true;
}
@@ -2459,21 +2966,24 @@ bool HandleBuildLoad(Player* pl, std::string const& payload, std::string* err)
}
uint32 const activeId = GetActiveBuildId(lowGuid);
// No-op swap: target is already active. Refresh catalog so client
// UI re-syncs and bail.
if (activeId == targetId)
{
PushBuildCatalog(pl);
return true;
}
bool const sameBuild = (activeId == targetId);
// -------------------------------------------------------------
// Phase 1: snapshot + park the current build's state, if any.
// Phase 1: snapshot + park the current build's state.
//
// Cross-build swap: capture the outgoing build's panel state into
// its recipe rows so swapping back later restores it; park any
// active hunter pet so we can re-summon the same instance.
//
// Same-build "revert": skip the snapshot (we WANT the saved
// recipe to remain authoritative -- this command's whole purpose
// is to discard pending edits), but still park the pet so the
// reset+re-spend cycle below doesn't destroy it.
// -------------------------------------------------------------
if (activeId)
{
SnapshotBuildFromCurrent(pl, activeId);
if (!sameBuild)
SnapshotBuildFromCurrent(pl, activeId);
ParkActivePetForBuild(pl, activeId);
}
@@ -2616,7 +3126,8 @@ bool HandleBuildLoad(Player* pl, std::string const& payload, std::string* err)
RestoreParkedPetForBuild(pl, targetId);
PushBuildCatalog(pl);
LOG_INFO("module", "Paragon build: {} loaded build {}", pl->GetName(), targetId);
LOG_INFO("module", "Paragon build: {} {} build {}",
pl->GetName(), sameBuild ? "reverted to snapshot of" : "loaded", targetId);
return true;
}
@@ -2637,6 +3148,13 @@ public:
void OnPlayerLogin(Player* player) override
{
LoadCurrencyFromDb(player);
// Verify AE/TE matches what the player's level + panel spend
// permit. Self-heals admin / crash drift in either direction
// and is a no-op (just two small SELECTs) when the balance is
// already correct. Has to run BEFORE PushCurrency so the
// client's first balance update of the session is the
// reconciled one.
ReconcileEssenceForPlayer(player);
PushCurrency(player);
PushSnapshot(player);
PushBuildCatalog(player);
@@ -2716,7 +3234,7 @@ public:
// Player isn't fully in-world here; OnPlayerLogin will push.
}
void OnPlayerLevelChanged(Player* player, uint8 oldLevel) override
void OnPlayerLevelChanged(Player* player, uint8 /*oldLevel*/) override
{
if (!player || player->getClass() != CLASS_PARAGON)
return;
@@ -2730,10 +3248,13 @@ public:
if (gParagonCurrencyCache.find(lowGuid) == gParagonCurrencyCache.end())
LoadCurrencyFromDb(player);
GrantLevelUpEssence(player, oldLevel, player->GetLevel());
// Persist the grant immediately so a crash before next save doesn't
// lose freshly-awarded essence. Cheap (single REPLACE).
// Single source of truth: ComputeStartingAE/TE(newLevel) - spent.
// Subsumes the old GrantLevelUpEssence per-level delta AND catches
// drift in both directions (cheese clamp + restore-from-loss).
// SaveCurrencyToDb runs inside Reconcile when drift is detected;
// call it once more here so a no-drift level-up still flushes any
// pending cache changes from this session.
ReconcileEssenceForPlayer(player);
SaveCurrencyToDb(player);
PushCurrency(player);
}
@@ -2821,6 +3342,20 @@ public:
SendAddonMessage(player, fmt::format("R OK {} {}", GetAE(player), GetTE(player)));
PushCurrency(player);
PushSnapshot(player);
// If the player has a build loaded, the commit just
// mutated their panel state -- archive the previous
// share_code + recipe (so old Discord codes still
// import that frozen loadout), snapshot the new panel
// into the live build, assign a fresh share_code for
// the new recipe, and re-push the catalog.
uint32 const lowGuid = player->GetGUID().GetCounter();
uint32 const activeId = GetActiveBuildId(lowGuid);
if (activeId)
{
PersistActiveBuildSnapshotAfterLearnAllCommit(player, activeId);
PushBuildCatalog(player);
}
}
else
{
@@ -2868,6 +3403,13 @@ public:
SendAddonMessage(player, "R ERR " + err);
return;
}
if (body.compare(0, 21, "C BUILD SAVE_CURRENT ") == 0)
{
std::string err;
if (!HandleBuildSaveCurrent(player, body.substr(21), &err))
SendAddonMessage(player, "R ERR " + err);
return;
}
if (body.compare(0, 13, "C BUILD EDIT ") == 0)
{
std::string err;
+336
View File
@@ -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 1A1D (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"
@@ -0,0 +1,5 @@
node_modules/
dist/
launcher.json
.DS_Store
Thumbs.db
+158
View File
@@ -0,0 +1,158 @@
# Fractured Launcher (Electron)
Windows 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
- [Node.js](https://nodejs.org/) 20+ (includes npm)
## Run from source
```bash
cd tools/fractured-launcher-electron
npm install
npm start
```
On first run, `launcher.json` is created next to the app (dev: in this folder).
### 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
```bash
npm install
npm run pack:win
```
Produces under **`dist/`**:
| Artifact | Purpose |
|----------|---------|
| `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. |
### Baked Gitea channel (non-token)
**`npm run pack:win`** runs **`scripts/inject-release-channel.js`** first. It merges **`gitea.base_url`**, **`owner`**, **`repo`**, and optional **`release_tag`** into **`default-launcher.json`** for that build only (then **electron-builder** packs that file).
- **GitHub Actions** — **Sync release to Gitea** and **Fractured launcher CI** export **`GITEA_BASE_URL`**, **`GITEA_OWNER`**, **`GITEA_REPO`** (same names as your upload secrets) for the pack step, so installers match the repo you sync to. Nothing embeds **`GITEA_TOKEN`**.
- **Local packs** — put the same values in **`fractured-release-channel.json`** (committed or personal copy) **or** export those env vars before **`npm run pack:win`**.
First launch still copies **`default-launcher.json`** → **`launcher.json`** beside the exe, so players get the baked **`gitea.*`** without editing unless they override.
## Auto-update behaviour
- **Packaged** builds only (`npm run pack:win` output). In `npm start` dev mode, update checks are skipped (button still explains that).
- **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**.
- **Manual check:** button **Check launcher updates** in the UI.
### Where launcher updates are hosted
**`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:
- 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.
**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
1. Bump **`version`** in `package.json` on `main` (or your release branch) and merge.
2. Create a **GitHub release** (tag + attach patches / `Wow.exe` if needed) and click **Publish****Sync release to Gitea** builds Windows installers and mirrors everything to Gitea.
3. Local check: `npm run pack:win` then **`scripts/upload-release-to-gitea.sh`** with the same **`GITEA_*`** env vars as CI if you need a manual upload.
## Sync to Gitea (patches + launcher binaries)
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 the **Electron** app from that tag on Windows.
3. Downloads **all assets** attached to that **GitHub** release (MPQs, patched `Wow.exe`, etc.).
4. Merges with the built launcher artifacts and uploads everything to a **Gitea release** with the **same tag** (existing attachments on that Gitea release are replaced).
**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"
}
```
**Manual upload:** `bash scripts/upload-release-to-gitea.sh /path/to/files v1.0.0` with the same env vars as CI.
### Sync did not run / Gitea unchanged — checklist
1. **Git tag ≠ GitHub Release** — Only **Releases** (published on the GitHub **Releases** page) trigger this workflow. If your teammate only **`git push --tags`**, create a **Release** from that tag and click **Publish** (or run **Actions → Sync release to Gitea → Run workflow** and enter the tag).
2. **Draft release** — Must click **Publish release**; drafts do not mirror.
3. **Workflow on default branch** — GitHub runs `release` workflows from the **default branch** (e.g. `main`). Ensure `.github/workflows/gitea-release-sync.yml` is merged there.
4. **Repo name guard** — Jobs use `if: github.repository == 'Dawnforger/Fractured'`. Forks or renames must change that line or runs are skipped.
5. **Secrets****`GITEA_BASE_URL`**, **`GITEA_TOKEN`**, **`GITEA_OWNER`**, **`GITEA_REPO`** must be set under **Settings → Secrets and variables → Actions**. A failed “Upload to Gitea” step usually prints which is missing.
6. **Actions tab** — Open the latest **Sync release to Gitea** run; a red **build-electron** (old tag without `package-lock.json`, etc.) or **Upload to Gitea** step shows the real error.
7. **HTTP 422 `repo is empty`** — The Gitea repo has **no commits** yet. Push any initial commit (e.g. **Add README** in the Gitea web UI, or `git push` to **`main`**). Optionally set **`GITEA_TARGET_REF`** to match your real default branch if it is not **`main`**. From this repo you can run **`scripts/bootstrap-gitea-repo.sh`** (see script header for `GITEA_*` env or pass the HTTPS/SSH clone URL as the first argument).
### Private Gitea token for players
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.
**Release asset names** must match **`files[].source`** when **`from_release`**: true. Use **`release_tag`**: `"latest"` or a pinned tag matching both GitHub and Gitea.
## Patch versions (same filenames, different bytes)
The launcher does **not** read Git commits. For **turn-key** updates when asset names stay fixed (`patch-Z.MPQ`, `Wow-patched.exe`, …):
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: …”**.
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 patch-Z.MPQ 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
Workflow **Fractured launcher CI** (`.github/workflows/fractured-launcher-ci.yml`) runs on pushes/PRs under `tools/fractured-launcher-electron/`: Windows pack uses **`electron-builder … --publish never`** (not `npm run pack:win`, so tagged checkouts never require `GH_TOKEN`). **Actions → Fractured launcher CI → Run workflow** runs it manually.
**Sync release to Gitea** (`.github/workflows/gitea-release-sync.yml`) uses the same pack command. 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
Schema is defined by **`default-launcher.json`** (shipped in the app; first run copies to `launcher.json` beside the executable):
- **`game_dir`**: WoW 3.3.5a root (contains `Wow.exe`).
- **`update_feed_url`**: optional generic HTTPS base for launcher auto-update.
- **`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”.
- **`gitea`**: **`base_url`**, **`owner`**, **`repo`**, **`release_tag`**, **`token_env`** — when **`base_url`** is set (and owner/repo set), **`from_release`** downloads and (with token if needed) the **generic** updater feed use **Gitea**. **Required** for players if your CI mirrors patches/launchers to Gitea only.
- **`github`**: used for **non-release** repo paths (`from_release`: false) and for **GitHub** **`from_release`** when **`gitea.base_url`** is empty.
- **`patch_manifest`**: **`enabled`**, **`source`** (default `patch-manifest.json`), **`from_release`** — checksum-based skip + verify (see above).
- **`files`**, **`realmlist`**, **`auth`**, **`launch`**.
@@ -0,0 +1,55 @@
{
"game_dir": "",
"update_feed_url": "",
"launcher_updates_from_github": false,
"gitea": {
"base_url": "",
"owner": "",
"repo": "",
"release_tag": "latest",
"token_env": "GITEA_TOKEN"
},
"github": {
"owner": "Dawnforger",
"repo": "Fractured",
"ref": "main",
"release_tag": "latest",
"token_env": "GITHUB_TOKEN"
},
"patch_manifest": {
"enabled": true,
"source": "patch-manifest.json",
"from_release": true
},
"files": [
{
"source": "patch-Z.MPQ",
"dest": "Data/patch-Z.MPQ",
"backup": true,
"from_release": true
},
{
"source": "Wow-patched.exe",
"dest": "Wow.exe",
"backup": true,
"from_release": true
}
],
"realmlist": {
"enabled": true,
"line": "set realmlist fracturedwow.ddns.net:47497",
"paths": ["Data/enUS/realmlist.wtf", "Data/enGB/realmlist.wtf"]
},
"auth": {
"enabled": false,
"url": "https://auth.your-realm.example/api/launcher/login",
"method": "POST",
"username_field": "username",
"password_field": "password"
},
"launch": {
"exe": "Wow.exe",
"args": [],
"linux_wrapper": ["wine"]
}
}
@@ -0,0 +1,8 @@
{
"gitea": {
"base_url": "",
"owner": "",
"repo": "",
"release_tag": "latest"
}
}
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
<title>Fractured Launcher</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header>
<h1>Fractured Launcher</h1>
<p class="sub">Point at your 3.3.5a client, download patches, then play.</p>
</header>
<section class="card">
<label class="lbl">World of Warcraft folder (contains <span id="wowExeName">Wow.exe</span>)</label>
<div class="row">
<input type="text" id="gameDir" placeholder="Browse… or paste the folder that contains Wow.exe" />
<button type="button" id="btnBrowse">Browse…</button>
<button type="button" id="btnSaveFolder" class="primary">Save folder</button>
</div>
</section>
<section class="card hidden" id="authCard">
<label class="lbl">Account</label>
<div class="row stack">
<input type="text" id="username" autocomplete="username" placeholder="Username" />
<input type="password" id="password" autocomplete="current-password" placeholder="Password" />
<button type="button" id="btnAuth" class="primary">Sign in</button>
</div>
</section>
<section class="card row-actions">
<button type="button" id="btnCheckLauncher" class="ghost">Check launcher updates</button>
</section>
<section class="card">
<button type="button" id="btnSync" class="primary wide" disabled>Download updates</button>
<button type="button" id="btnPlay" class="success wide hidden" disabled>Play</button>
</section>
<pre id="log" class="log" aria-live="polite"></pre>
<script src="renderer.js"></script>
</body>
</html>
@@ -0,0 +1,148 @@
'use strict';
const { dialog } = require('electron');
const { autoUpdater } = require('electron-updater');
const { useGiteaReleases, getGiteaUpdaterFeedBase } = require('./gitea-release');
/**
* @param {import('electron').App} app
* @param {() => import('electron').BrowserWindow | null} getMainWindow
* @param {{
* updateFeedUrl?: string,
* githubOwner?: string,
* githubRepo?: string,
* githubToken?: string,
* giteaToken?: string,
* allowGithubLauncherUpdates?: boolean,
* config?: object,
* }} opts
*/
async function setupAutoUpdater(app, getMainWindow, opts = {}) {
if (!app.isPackaged) {
return {
checkNow: async () => ({ skipped: true, reason: 'development build' }),
};
}
const ghToken = String(opts.githubToken || '').trim();
const giteaTok = String(opts.giteaToken || '').trim();
const envGeneric = String(process.env.LAUNCHER_UPDATE_URL || '').trim();
const configGeneric = String(opts.updateFeedUrl || '').trim();
let genericUrl = envGeneric || configGeneric;
let genericAuthHeader = '';
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) {
const base = genericUrl.replace(/\/?$/, '/');
autoUpdater.setFeedURL({
provider: 'generic',
url: base,
});
if (genericAuthHeader) {
autoUpdater.requestHeaders = {
...autoUpdater.requestHeaders,
Authorization: genericAuthHeader,
};
}
feedConfigured = true;
} else if (opts.allowGithubLauncherUpdates && ghToken && owner && repo) {
autoUpdater.setFeedURL({
provider: 'github',
owner,
repo,
private: true,
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 w = getMainWindow();
if (w && !w.isDestroyed()) {
w.webContents.send('launcher:progress', msg);
}
};
autoUpdater.on('checking-for-update', () => send('Checking for launcher updates…'));
autoUpdater.on('update-available', (info) => {
send(`Launcher update available: ${info.version}`);
});
autoUpdater.on('update-not-available', () => {});
autoUpdater.on('error', (err) => {
const m = (err && (err.message || String(err))) || '';
if (/404|releases\.atom|HttpError:\s*404/i.test(m)) {
send(
'Launcher update: 404 (no latest.yml or wrong URL). For Gitea use gitea.* + token, or set update_feed_url. ' +
'For private GitHub set GITHUB_TOKEN.'
);
return;
}
if (m && !/net::ERR|ENOTFOUND|ETIMEDOUT/i.test(m)) {
send(`Launcher update: ${m.slice(0, 400)}`);
}
});
autoUpdater.on('download-progress', (p) => {
const pct = Math.round(p.percent || 0);
send(`Launcher update download: ${pct}%`);
});
autoUpdater.on('update-downloaded', async (info) => {
const win = getMainWindow();
const r = await dialog.showMessageBox(win || undefined, {
type: 'info',
title: 'Launcher update',
message: `Version ${info.version} is ready to install.`,
detail: 'Restart the launcher now to finish. You can finish patching WoW after restart.',
buttons: ['Restart now', 'Later'],
defaultId: 0,
cancelId: 1,
noLink: true,
});
if (r.response === 0) {
autoUpdater.quitAndInstall(false, true);
}
});
const checkNow = async () => {
const r = await autoUpdater.checkForUpdates();
return { ok: true, updateInfo: r && r.updateInfo };
};
const tick = () => {
checkNow().catch(() => {});
};
setTimeout(tick, 5000);
setInterval(tick, 6 * 60 * 60 * 1000);
return { checkNow };
}
module.exports = { setupAutoUpdater };
@@ -0,0 +1,63 @@
'use strict';
const path = require('path');
const fs = require('fs').promises;
function mergeConfig(defaults, user) {
return {
...defaults,
...user,
update_feed_url:
user.update_feed_url != null && user.update_feed_url !== ''
? user.update_feed_url
: defaults.update_feed_url,
github: { ...defaults.github, ...(user.github || {}) },
launch: { ...defaults.launch, ...(user.launch || {}) },
auth: user.auth != null ? { ...defaults.auth, ...user.auth } : defaults.auth,
realmlist: user.realmlist != null ? { ...defaults.realmlist, ...user.realmlist } : defaults.realmlist,
files: Array.isArray(user.files) && user.files.length ? user.files : defaults.files,
};
}
function getConfigPath(app) {
if (process.env.FRACTURED_LAUNCHER_CONFIG) return process.env.FRACTURED_LAUNCHER_CONFIG;
if (app && app.isPackaged) {
return path.join(path.dirname(process.execPath), 'launcher.json');
}
return path.join(__dirname, '..', 'launcher.json');
}
async function loadConfig(app) {
const p = getConfigPath(app);
const defPath = path.join(__dirname, '..', 'default-launcher.json');
const defaults = JSON.parse(await fs.readFile(defPath, 'utf8'));
try {
const user = JSON.parse(await fs.readFile(p, 'utf8'));
return { configPath: p, config: mergeConfig(defaults, user) };
} catch (e) {
if (e.code === 'ENOENT') {
await fs.writeFile(p, JSON.stringify(defaults, null, 2), 'utf8');
return { configPath: p, config: JSON.parse(JSON.stringify(defaults)) };
}
throw e;
}
}
async function saveGameDir(configPath, gameDir) {
const defPath = path.join(__dirname, '..', 'default-launcher.json');
const defaults = JSON.parse(await fs.readFile(defPath, 'utf8'));
const user = JSON.parse(await fs.readFile(configPath, 'utf8'));
user.game_dir = gameDir;
const merged = mergeConfig(defaults, user);
await fs.writeFile(configPath, JSON.stringify(merged, null, 2), 'utf8');
return merged;
}
function resolveGameDir(cfg, configPath) {
const gd = cfg.game_dir;
if (!gd) return '';
if (path.isAbsolute(gd)) return path.normalize(gd);
return path.normalize(path.join(path.dirname(configPath), gd));
}
module.exports = { getConfigPath, loadConfig, saveGameDir, resolveGameDir, mergeConfig };
@@ -0,0 +1,9 @@
'use strict';
function githubToken(cfg) {
const name = cfg.github && cfg.github.token_env;
if (name && process.env[name]) return process.env[name];
return process.env.GITHUB_TOKEN || '';
}
module.exports = { githubToken };
@@ -0,0 +1,121 @@
'use strict';
const path = require('path');
const fs = require('fs').promises;
const { githubToken } = require('./github-token');
const { downloadGiteaReleaseAsset, useGiteaReleases } = require('./gitea-release');
const { fetchToFile, downloadBodyToFile } = require('./http-download');
function encodeRepoPath(repoPath) {
let p = String(repoPath || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (!p) return '';
return p.split('/').map((seg) => encodeURIComponent(seg)).join('/');
}
function ghHeaders(token, json = false) {
const h = {
'User-Agent': 'Fractured-Launcher-Electron',
'X-GitHub-Api-Version': '2022-11-28',
};
if (json) h.Accept = 'application/vnd.github+json';
if (token) h.Authorization = `Bearer ${token}`;
return h;
}
async function downloadGitHubRepoFile(cfg, repoPath, destPath) {
const token = githubToken(cfg);
const enc = encodeRepoPath(repoPath);
const ref = cfg.github.ref || 'main';
const { owner, repo } = cfg.github;
if (!token) {
const url = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${enc}`;
await fetchToFile(url, {}, destPath);
return;
}
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${enc}?ref=${encodeURIComponent(ref)}`;
const res = await fetch(apiUrl, { headers: ghHeaders(token, true) });
const body = await res.text();
if (!res.ok) {
throw new Error(`GitHub contents API ${res.status}: ${body.slice(0, 800)}`);
}
const meta = JSON.parse(body);
if (meta.type && meta.type !== 'file') {
throw new Error(`not a file: ${repoPath}`);
}
if (meta.download_url) {
const h = { Accept: 'application/octet-stream' };
if (token) {
h.Authorization = `Bearer ${token}`;
h['X-GitHub-Api-Version'] = '2022-11-28';
}
await fetchToFile(meta.download_url, h, destPath);
return;
}
if (meta.content && meta.encoding === 'base64') {
const buf = Buffer.from(String(meta.content).replace(/\n/g, ''), 'base64');
if (!buf.length) throw new Error('empty base64 content');
await fs.mkdir(path.dirname(destPath), { recursive: true });
const tmp = destPath + '.downloading';
await fs.writeFile(tmp, buf);
await fs.rename(tmp, destPath);
return;
}
throw new Error(`unexpected GitHub response for ${repoPath}`);
}
async function downloadReleaseAsset(cfg, assetName, destPath) {
if (useGiteaReleases(cfg)) {
return downloadGiteaReleaseAsset(cfg, assetName, destPath);
}
const token = githubToken(cfg);
const tag = (cfg.github.release_tag || 'latest').trim() || 'latest';
const { owner, repo } = cfg.github;
let listUrl;
if (tag.toLowerCase() === 'latest') {
listUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
} else {
listUrl = `https://api.github.com/repos/${owner}/${repo}/releases/tags/${encodeURIComponent(tag)}`;
}
const res = await fetch(listUrl, { headers: ghHeaders(token, true) });
const text = await res.text();
if (!res.ok) {
let hint = '';
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)';
throw new Error(`releases list ${res.status}${hint}: ${text.slice(0, 600)}`);
}
const rel = JSON.parse(text);
const assets = rel.assets || [];
let assetURL = '';
for (const a of assets) {
if (a.name !== assetName) continue;
if (token && a.url) {
assetURL = a.url;
break;
}
if (a.browser_download_url) {
assetURL = a.browser_download_url;
break;
}
assetURL = a.url;
break;
}
if (!assetURL) {
const names = assets.map((x) => x.name);
throw new Error(`release asset "${assetName}" not found; attachments: ${names.join(', ')}`);
}
const h = { Accept: 'application/octet-stream' };
if (token) {
h.Authorization = `Bearer ${token}`;
h['X-GitHub-Api-Version'] = '2022-11-28';
}
const dl = await fetch(assetURL, { headers: h, redirect: 'follow' });
await downloadBodyToFile(dl, destPath);
}
module.exports = { downloadGitHubRepoFile, downloadReleaseAsset, encodeRepoPath };
@@ -0,0 +1,40 @@
'use strict';
const fs = require('fs').promises;
const path = require('path');
const { createWriteStream } = require('fs');
const { pipeline } = require('stream/promises');
const { Readable } = require('stream');
async function downloadBodyToFile(res, destPath) {
if (!res.ok) {
const errText = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}: ${errText.slice(0, 500)}`);
}
if (!res.body) {
throw new Error('download has no body');
}
await fs.mkdir(path.dirname(destPath), { recursive: true });
const tmp = destPath + '.downloading';
let body = res.body;
if (body && typeof body.pipe !== 'function') {
body = Readable.fromWeb(body);
}
await pipeline(body, createWriteStream(tmp));
const st = await fs.stat(tmp);
if (st.size === 0) {
await fs.unlink(tmp).catch(() => {});
throw new Error('empty download');
}
await fs.rename(tmp, destPath);
}
async function fetchToFile(url, headers, destPath) {
const res = await fetch(url, {
headers,
redirect: 'follow',
});
await downloadBodyToFile(res, destPath);
}
module.exports = { fetchToFile, downloadBodyToFile };
@@ -0,0 +1,117 @@
'use strict';
const path = require('path');
const fs = require('fs').promises;
const { downloadGitHubRepoFile, downloadReleaseAsset } = require('./github');
function pad2(n) {
return String(n).padStart(2, '0');
}
function backupSuffix() {
const d = new Date();
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
}
function wowExePath(cfg) {
const exe = (cfg.launch && cfg.launch.exe) || 'Wow.exe';
const parts = exe.replace(/\\/g, '/').split('/').filter(Boolean);
return path.join(cfg.game_dir, ...parts);
}
function wowInstallValid(cfg) {
if (!cfg.game_dir) return false;
return require('fs').existsSync(wowExePath(cfg));
}
async function installFile(cfg, entry) {
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
const destAbs = path.join(cfg.game_dir, ...parts);
if (entry.backup) {
try {
const st = await fs.stat(destAbs);
if (st.isFile()) {
const bak = `${destAbs}.bak-${backupSuffix()}`;
await fs.rename(destAbs, bak);
}
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
} else {
try {
await fs.unlink(destAbs);
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
}
const tmp = destAbs + '.new';
if (entry.from_release) {
await downloadReleaseAsset(cfg, entry.source, tmp);
} else {
await downloadGitHubRepoFile(cfg, entry.source, tmp);
}
await fs.rename(tmp, destAbs);
}
async function applyRealmlist(cfg) {
if (!cfg.realmlist || !cfg.realmlist.enabled) return;
let line = String(cfg.realmlist.line || '').trim();
if (!line) throw new Error('realmlist.line empty');
if (!line.toLowerCase().startsWith('set realmlist ')) {
line = `set realmlist ${line}`;
}
const content = line + '\n';
let paths = cfg.realmlist.paths;
if (!paths || !paths.length) paths = ['Data/enUS/realmlist.wtf'];
for (const rel of paths) {
const r = String(rel).trim().replace(/\\/g, '/');
if (!r) continue;
const segs = r.split('/').filter(Boolean);
const abs = path.join(cfg.game_dir, ...segs);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, content, 'utf8');
}
}
async function applyPatches(cfg, onStatus) {
for (const f of cfg.files || []) {
if (onStatus) onStatus(`Updating ${f.dest}`);
try {
await installFile(cfg, f);
} catch (e) {
throw new Error(`sync ${f.dest}: ${e.message || e}`);
}
}
if (cfg.realmlist && cfg.realmlist.enabled) {
if (onStatus) onStatus('Applying realmlist …');
await applyRealmlist(cfg);
}
if (onStatus) onStatus('All patches applied.');
}
async function doAuth(cfg, username, password) {
if (!cfg.auth || !cfg.auth.enabled) return;
const u = String(username || '').trim();
const p = String(password || '');
if (!u || !p) throw new Error('username and password required');
const body = {
[cfg.auth.username_field || 'username']: u,
[cfg.auth.password_field || 'password']: p,
};
const res = await fetch(cfg.auth.url, {
method: cfg.auth.method || 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
});
const t = await res.text();
if (res.status < 200 || res.status >= 300) {
throw new Error(`login failed ${res.status}: ${t.slice(0, 400)}`);
}
}
module.exports = {
applyPatches,
applyRealmlist,
wowExePath,
wowInstallValid,
doAuth,
};
+156
View File
@@ -0,0 +1,156 @@
'use strict';
const { app, BrowserWindow, ipcMain, dialog, Menu } = require('electron');
const path = require('path');
const { spawn } = require('child_process');
const { loadConfig, saveGameDir, resolveGameDir } = require('./lib/config-store');
const { applyPatches, wowExePath, wowInstallValid, doAuth } = require('./lib/patch');
const { readPatchState } = require('./lib/patch-manifest');
const { setupAutoUpdater } = require('./lib/auto-update');
let mainWindow;
let autoUpdateApi = {
checkNow: async () => ({ skipped: true, reason: 'not initialized' }),
};
function createWindow() {
mainWindow = new BrowserWindow({
width: 720,
height: 640,
show: false,
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
Menu.setApplicationMenu(null);
mainWindow.loadFile(path.join(__dirname, 'index.html'));
mainWindow.once('ready-to-show', () => mainWindow.show());
}
function sendProgress(msg) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('launcher:progress', msg);
}
}
async function readMergedConfig() {
const { configPath, config } = await loadConfig(app);
const gameDir = resolveGameDir(config, configPath);
const merged = { ...config, game_dir: gameDir };
return { configPath, config: merged };
}
app.whenReady().then(async () => {
createWindow();
const { config } = await loadConfig(app);
const ghEnv = config.github && config.github.token_env;
const githubToken =
(ghEnv && String(process.env[ghEnv] || '').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();
autoUpdateApi = await setupAutoUpdater(app, () => mainWindow, {
updateFeedUrl,
config,
githubOwner: config.github && config.github.owner,
githubRepo: config.github && config.github.repo,
githubToken,
giteaToken,
allowGithubLauncherUpdates: config.launcher_updates_from_github === true,
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
ipcMain.handle('launcher:load', async () => {
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 {
configPath,
gameDir: config.game_dir || '',
authEnabled: !!(config.auth && config.auth.enabled),
wowExe: (config.launch && config.launch.exe) || 'Wow.exe',
wowOk: wowInstallValid(config),
clientBuild,
};
});
ipcMain.handle('launcher:saveGameDir', async (_e, dir) => {
const trimmed = String(dir || '').trim();
if (!trimmed) throw new Error('folder path is empty');
const { configPath } = await loadConfig(app);
const norm = path.normalize(trimmed);
const probe = { ...(await readMergedConfig()).config, game_dir: norm };
if (!wowInstallValid(probe)) {
throw new Error(`That folder does not contain ${(probe.launch && probe.launch.exe) || 'Wow.exe'}`);
}
const c = await saveGameDir(configPath, norm);
const merged = { ...c, game_dir: resolveGameDir(c, configPath) };
return { ok: true, gameDir: merged.game_dir, wowOk: wowInstallValid(merged) };
});
ipcMain.handle('launcher:pickFolder', async (_e, startDir) => {
const win = BrowserWindow.getFocusedWindow() || mainWindow;
const r = await dialog.showOpenDialog(win, {
title: 'Select World of Warcraft 3.3.5a folder',
properties: ['openDirectory', 'createDirectory'],
defaultPath: startDir && String(startDir).trim() ? String(startDir).trim() : undefined,
});
if (r.canceled || !r.filePaths || !r.filePaths[0]) return { canceled: true, path: '' };
return { canceled: false, path: r.filePaths[0] };
});
ipcMain.handle('launcher:auth', async (_e, { user, pass }) => {
const { config } = await readMergedConfig();
await doAuth(config, user, pass);
return { ok: true };
});
ipcMain.handle('launcher:sync', async () => {
const { config } = await readMergedConfig();
if (!wowInstallValid(config)) {
throw new Error('Set a valid WoW folder (must contain Wow.exe) first.');
}
await applyPatches(config, sendProgress);
return { ok: true };
});
ipcMain.handle('launcher:checkUpdates', async () => {
try {
return await autoUpdateApi.checkNow();
} catch (e) {
const msg = e && (e.message || String(e));
return { ok: false, error: msg };
}
});
ipcMain.handle('launcher:play', async () => {
const { config } = await readMergedConfig();
const exe = wowExePath(config);
const args = (config.launch && config.launch.args) || [];
const child = spawn(exe, args, {
cwd: config.game_dir,
detached: true,
stdio: 'ignore',
windowsHide: true,
shell: false,
});
child.unref();
return { ok: true };
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
{
"name": "fractured-launcher-electron",
"version": "1.0.1",
"description": "Fractured WoW launcher (Electron) — no console window, native folder picker, auto-update",
"main": "main.js",
"repository": {
"type": "git",
"url": "https://github.com/Dawnforger/Fractured.git"
},
"scripts": {
"start": "electron .",
"pack:win": "node scripts/inject-release-channel.js && electron-builder --win nsis portable --x64 --publish never",
"publish:win": "node scripts/inject-release-channel.js && electron-builder --win nsis portable --x64 --publish never"
},
"author": "",
"license": "GPL-3.0",
"devDependencies": {
"electron": "^33.2.1",
"electron-builder": "^25.1.8"
},
"dependencies": {
"electron-updater": "^6.3.9"
},
"build": {
"appId": "net.fractured.launcher",
"productName": "Fractured Launcher",
"directories": {
"output": "dist"
},
"publish": null,
"files": [
"main.js",
"preload.cjs",
"index.html",
"renderer.js",
"styles.css",
"default-launcher.json",
"lib/**/*"
],
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64"]
},
{
"target": "portable",
"arch": ["x64"]
}
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"artifactName": "Fractured-Launcher-${version}-Setup.${ext}"
},
"portable": {
"artifactName": "Fractured-Launcher-${version}-Windows-Portable.${ext}"
}
}
}
@@ -0,0 +1,16 @@
'use strict';
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('launcher', {
load: () => ipcRenderer.invoke('launcher:load'),
saveGameDir: (dir) => ipcRenderer.invoke('launcher:saveGameDir', dir),
pickFolder: (startDir) => ipcRenderer.invoke('launcher:pickFolder', startDir),
auth: (user, pass) => ipcRenderer.invoke('launcher:auth', { user, pass }),
sync: () => ipcRenderer.invoke('launcher:sync'),
checkUpdates: () => ipcRenderer.invoke('launcher:checkUpdates'),
play: () => ipcRenderer.invoke('launcher:play'),
onProgress: (cb) => {
ipcRenderer.on('launcher:progress', (_e, msg) => cb(msg));
},
});
@@ -0,0 +1,130 @@
'use strict';
const logEl = document.getElementById('log');
const gameDirEl = document.getElementById('gameDir');
const btnBrowse = document.getElementById('btnBrowse');
const btnSave = document.getElementById('btnSaveFolder');
const btnSync = document.getElementById('btnSync');
const btnPlay = document.getElementById('btnPlay');
const btnCheckLauncher = document.getElementById('btnCheckLauncher');
const authCard = document.getElementById('authCard');
const btnAuth = document.getElementById('btnAuth');
const wowExeName = document.getElementById('wowExeName');
function log(msg) {
logEl.textContent += (logEl.textContent ? '\n' : '') + msg;
logEl.scrollTop = logEl.scrollHeight;
}
function setError(e) {
const m = e && (e.message || String(e));
log('Error: ' + m);
}
let authEnabled = false;
let signedIn = false;
async function refresh() {
try {
const s = await window.launcher.load();
authEnabled = s.authEnabled;
signedIn = !s.authEnabled;
wowExeName.textContent = s.wowExe || 'Wow.exe';
gameDirEl.value = s.gameDir || '';
authCard.classList.toggle('hidden', !authEnabled);
btnSync.disabled = !s.wowOk || (authEnabled && !signedIn);
btnPlay.classList.add('hidden');
btnPlay.disabled = true;
logEl.textContent = '';
if (!s.gameDir) log('Choose your WoW installation folder.');
else if (!s.wowOk) log('Folder does not look valid yet — pick the directory that contains ' + (s.wowExe || 'Wow.exe') + ', then Save folder.');
else if (authEnabled && !signedIn) log('Sign in, then download updates.');
else log('Ready — tap Download updates to sync from GitHub.');
} catch (e) {
setError(e);
}
}
window.launcher.onProgress((msg) => log(msg));
btnBrowse.addEventListener('click', async () => {
try {
const start = gameDirEl.value.trim();
const r = await window.launcher.pickFolder(start);
if (!r.canceled && r.path) {
gameDirEl.value = r.path;
log('Selected: ' + r.path);
}
} catch (e) {
setError(e);
}
});
btnSave.addEventListener('click', async () => {
try {
const dir = gameDirEl.value.trim();
if (!dir) {
log('Pick a folder with Browse… first.');
return;
}
const r = await window.launcher.saveGameDir(dir);
gameDirEl.value = r.gameDir;
btnSync.disabled = !r.wowOk || (authEnabled && !signedIn);
log('Saved installation folder.');
} catch (e) {
setError(e);
}
});
btnAuth.addEventListener('click', async () => {
try {
const u = document.getElementById('username').value;
const p = document.getElementById('password').value;
await window.launcher.auth(u, p);
signedIn = true;
log('Signed in.');
btnSync.disabled = !gameDirEl.value.trim() || (authEnabled && !signedIn);
const s = await window.launcher.load();
btnSync.disabled = !s.wowOk;
} catch (e) {
setError(e);
}
});
btnSync.addEventListener('click', async () => {
btnSync.disabled = true;
log('—');
try {
await window.launcher.sync();
btnPlay.classList.remove('hidden');
btnPlay.disabled = false;
log('Done. You can launch the game.');
} catch (e) {
setError(e);
} finally {
const s = await window.launcher.load().catch(() => null);
btnSync.disabled = !s || !s.wowOk || (authEnabled && !signedIn);
}
});
btnPlay.addEventListener('click', async () => {
try {
await window.launcher.play();
window.close();
} catch (e) {
setError(e);
}
});
btnCheckLauncher.addEventListener('click', async () => {
try {
log('Checking for launcher updates…');
const r = await window.launcher.checkUpdates();
if (r && r.skipped) log('Launcher auto-update: ' + (r.reason || 'skipped (use a packaged build).'));
else if (r && r.ok === false && r.error) setError(new Error(r.error));
} catch (e) {
setError(e);
}
});
refresh();
@@ -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,77 @@
#!/usr/bin/env node
'use strict';
/**
* Merge Gitea release channel (non-token) into default-launcher.json before pack.
* Precedence: env → fractured-release-channel.json → leave existing default-launcher values.
*
* Env (any of these names):
* FRACTURED_LAUNCHER_GITEA_BASE_URL | GITEA_BASE_URL
* FRACTURED_LAUNCHER_GITEA_OWNER | GITEA_OWNER
* FRACTURED_LAUNCHER_GITEA_REPO | GITEA_REPO
* FRACTURED_LAUNCHER_GITEA_RELEASE_TAG | GITEA_RELEASE_TAG
*/
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
const defPath = path.join(root, 'default-launcher.json');
const channelPath = path.join(root, 'fractured-release-channel.json');
function pickEnv() {
return {
base_url: String(
process.env.FRACTURED_LAUNCHER_GITEA_BASE_URL || process.env.GITEA_BASE_URL || ''
).trim(),
owner: String(
process.env.FRACTURED_LAUNCHER_GITEA_OWNER || process.env.GITEA_OWNER || ''
).trim(),
repo: String(
process.env.FRACTURED_LAUNCHER_GITEA_REPO || process.env.GITEA_REPO || ''
).trim(),
release_tag: String(
process.env.FRACTURED_LAUNCHER_GITEA_RELEASE_TAG || process.env.GITEA_RELEASE_TAG || ''
).trim(),
};
}
function main() {
const cfg = JSON.parse(fs.readFileSync(defPath, 'utf8'));
cfg.gitea = cfg.gitea && typeof cfg.gitea === 'object' ? cfg.gitea : {};
let fileGitea = {};
try {
const raw = JSON.parse(fs.readFileSync(channelPath, 'utf8'));
if (raw && raw.gitea && typeof raw.gitea === 'object') fileGitea = raw.gitea;
} catch (e) {
if (e.code !== 'ENOENT') throw e;
}
const env = pickEnv();
const keys = ['base_url', 'owner', 'repo', 'release_tag'];
let changed = false;
for (const k of keys) {
const fromEnv = env[k];
const fromFile =
fileGitea[k] != null && String(fileGitea[k]).trim() !== '' ? String(fileGitea[k]).trim() : '';
const val = (fromEnv && String(fromEnv).trim()) || fromFile;
if (!val) continue;
if (cfg.gitea[k] !== val) {
cfg.gitea[k] = val;
changed = true;
}
}
if (!changed) {
console.log(
'inject-release-channel: no overrides (set GITEA_* env and/or fill fractured-release-channel.json)'
);
return;
}
fs.writeFileSync(defPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
console.log('inject-release-channel: wrote gitea.* into default-launcher.json for this build');
}
main();
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Upload local files to a GitHub release on the public distro repo (default: Dawnforger/Fractured-Distro).
#
# Usage (from repo root or this directory):
# export GH_TOKEN=ghp_... # PAT with repo/releases on the distro repo
# ./tools/fractured-launcher-electron/scripts/publish-to-distro.sh v1.0.0 patch-Z.MPQ Wow-patched.exe
#
# Optional:
# DISTRO_REPO=YourOrg/Fratured-Distro # if your GitHub slug differs
# SRC_TAG=v1.0.0 ./publish-to-distro.sh v1.0.0 # copy all assets from SOURCE_REPO release SRC_TAG
#
set -euo pipefail
DISTRO_REPO="${DISTRO_REPO:-Dawnforger/Fractured-Distro}"
SOURCE_REPO="${SOURCE_REPO:-Dawnforger/Fractured}"
if ! command -v gh >/dev/null 2>&1; then
echo "Install GitHub CLI: https://cli.github.com/"
exit 1
fi
if [ -z "${GH_TOKEN:-}" ]; then
echo "Set GH_TOKEN to a PAT with releases write access to ${DISTRO_REPO}."
exit 1
fi
if [ "$#" -lt 1 ]; then
echo "Usage: $0 <release-tag> [files...]"
echo " or: SRC_TAG=v1.0.0 $0 <release-tag> # copies all assets from ${SOURCE_REPO} release SRC_TAG"
exit 1
fi
TAG="$1"
shift
if [ "$#" -eq 0 ] && [ -z "${SRC_TAG:-}" ]; then
echo "After the tag, list files to upload, or set SRC_TAG=... to copy all assets from ${SOURCE_REPO}."
exit 1
fi
tmpdir=$(mktemp -d)
cleanup() { rm -rf "$tmpdir"; }
trap cleanup EXIT
if [ "$#" -eq 0 ] && [ -n "${SRC_TAG:-}" ]; then
echo "Downloading assets from ${SOURCE_REPO}@${SRC_TAG}"
gh release download "$SRC_TAG" -R "$SOURCE_REPO" -D "$tmpdir"
else
for f in "$@"; do
if [ ! -f "$f" ]; then
echo "Not a file: $f"
exit 1
fi
cp -a "$f" "$tmpdir/"
done
fi
shopt -s nullglob
files=("$tmpdir"/*)
if [ "${#files[@]}" -eq 0 ]; then
echo "No files to upload."
exit 1
fi
if gh release view "$TAG" -R "$DISTRO_REPO" &>/dev/null; then
gh release upload "$TAG" -R "$DISTRO_REPO" "${files[@]}" --clobber
echo "Uploaded to https://github.com/${DISTRO_REPO}/releases/tag/${TAG}"
else
gh release create "$TAG" -R "$DISTRO_REPO" \
--title "Fractured ${TAG}" \
--notes "Published from ${SOURCE_REPO} (local script)." \
"${files[@]}"
echo "Created https://github.com/${DISTRO_REPO}/releases/tag/${TAG}"
fi
@@ -0,0 +1,91 @@
#!/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
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
while read -r aid; do
[ -z "$aid" ] || [ "$aid" = "null" ] && continue
curl -fsS -X DELETE "${AUTH_H[@]}" \
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${rel_id}/assets/${aid}" || true
done < <(jq -r '(.attachments // .assets // [])[] | .id' "$REL_JSON")
shopt -s nullglob
files=("$COMBINED_DIR"/*)
if [ "${#files[@]}" -eq 0 ]; then
echo "No files in $COMBINED_DIR" >&2
exit 1
fi
for f in "${files[@]}"; do
[ -f "$f" ] || continue
echo "Uploading $(basename "$f")"
curl -fsS -X POST "${AUTH_H[@]}" \
-F "attachment=@${f}" \
"$API/repos/${GITEA_OWNER}/${GITEA_REPO}/releases/${rel_id}/assets"
done
echo "Gitea release $TAG (id=$rel_id) updated with ${#files[@]} file(s)."
@@ -0,0 +1,126 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, Segoe UI, Roboto, sans-serif;
background: #121018;
color: #e8e4f0;
padding: 20px 24px 28px;
min-height: 100vh;
}
header h1 {
margin: 0 0 6px;
font-size: 1.35rem;
font-weight: 600;
}
.sub {
margin: 0 0 18px;
color: #9a92b0;
font-size: 0.9rem;
}
.card {
background: #1c1828;
border: 1px solid #2e2840;
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 14px;
}
.lbl {
display: block;
font-size: 0.8rem;
color: #b8b0d0;
margin-bottom: 8px;
}
.row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.row.stack {
flex-direction: column;
align-items: stretch;
}
#gameDir {
flex: 1;
min-width: 200px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #3d3558;
background: #0e0c14;
color: #f0ecff;
font-size: 0.85rem;
}
input[type='text'],
input[type='password'] {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #3d3558;
background: #0e0c14;
color: #f0ecff;
font-size: 0.9rem;
}
button {
padding: 10px 16px;
border-radius: 8px;
border: 1px solid #4a4268;
background: #2a243c;
color: #e8e4f0;
cursor: pointer;
font-size: 0.88rem;
}
button:hover:not(:disabled) {
background: #352d4c;
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
button.primary {
background: #4c3d8a;
border-color: #5c4d9a;
}
button.primary:hover:not(:disabled) {
background: #5a4a9e;
}
button.success {
background: #1d6b45;
border-color: #2a8a5a;
margin-top: 10px;
}
button.success:hover:not(:disabled) {
background: #258055;
}
button.ghost {
background: transparent;
border-color: #4a4268;
color: #b0a8d0;
font-size: 0.82rem;
}
button.ghost:hover:not(:disabled) {
background: #241f34;
}
.row-actions {
padding: 10px 16px;
}
button.wide {
width: 100%;
}
.log {
margin: 12px 0 0;
padding: 12px;
background: #0a090e;
border-radius: 8px;
border: 1px solid #2a2438;
min-height: 120px;
max-height: 200px;
overflow: auto;
font-size: 0.78rem;
color: #c4bdd8;
white-space: pre-wrap;
word-break: break-word;
}
.hidden {
display: none !important;
}