Compare commits

..

3 Commits

Author SHA1 Message Date
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
Docker Build a251e56c59 Paragon: Builds QoL -- share codes, unload, remaining AE/TE on hover
- Replace the "favorite" toggle with import-by-share-code: every build
  gets a 6-char realm-unique alphanumeric code on creation; pasting one
  into the BuildsPane share box copies the recipe (name + icon + spells
  + talents) into the importer's catalog as a new build, with a fresh
  share code so the imported copy can be re-shared independently.
- Add C BUILD UNLOAD verb so the client can clear a stale active-build
  pointer without forcing a swap. Wired to a new "Unload (clear active)"
  right-click context menu entry on the active build.
- Per-build tooltip now shows "Remaining if loaded: X AE / Y TE",
  computed server-side as total_earned - recipe_cost. Negative renders
  red so the player sees insufficient-currency cases before clicking
  Load. Suppressed for the active build (HandleBuildLoad short-circuits
  on target == active so the line would be misleading).
- Schema migration 2026_05_10_04.sql: drop is_favorite from
  character_paragon_builds and add share_code CHAR(6) UNIQUE NULL with
  lazy backfill on every PushBuildCatalog (so pre-migration rows pick
  up codes the first time the player opens the panel).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 04:15:11 -04:00
23 changed files with 7535 additions and 49 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,52 @@
# 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)
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
@@ -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,30 @@
-- mod-paragon Character Advancement: Builds catalog schema cleanup.
-- ----------------------------------------------------------------------------
-- Two changes:
-- 1. Drop `is_favorite` -- the favorite flag and shift-click-to-favorite
-- flow are removed. Builds are now ordered solely by build_id ASC.
-- 2. Add `share_code` CHAR(6) -- a random alphanumeric token generated
-- server-side at build creation that uniquely identifies a saved
-- build across the realm. Players exchange codes out-of-band and
-- use the BuildsPane "Load Build!" share box to import a copy of
-- the build (name + icon + spell + talent recipe) into their own
-- catalog. The copy gets a fresh share_code so re-sharing is
-- always traceable to the latest owner; the original isn't touched.
--
-- The column is NULL-tolerant so any rows that pre-date this migration
-- (created under 2026_05_10_03's schema) coexist cleanly. The server
-- backfills NULLs lazily in PushBuildCatalog -- the next time a player
-- opens the BuildsPane on a Paragon character, any of their builds that
-- still have a NULL share_code will get one generated and persisted.
--
-- Charset: 31 unambiguous chars (A-Z minus I/O minus 0/1) gives 31^6 ~=
-- 887M codes; collision retry on insert keeps probability of a duplicate
-- vanishing for any realistic catalog size.
-- ----------------------------------------------------------------------------
ALTER TABLE `character_paragon_builds`
DROP COLUMN `is_favorite`,
ADD COLUMN `share_code` CHAR(6) NULL DEFAULT NULL
COMMENT 'random alphanumeric token for import-by-code; lazily generated'
AFTER `icon`,
ADD UNIQUE INDEX `uk_share_code` (`share_code`);
@@ -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';
+714 -49
View File
@@ -14,6 +14,7 @@
#include "Config.h"
#include "Pet.h"
#include "Player.h"
#include "Random.h"
#include "RBAC.h"
#include "ScriptMgr.h"
#include "SharedDefines.h"
@@ -260,6 +261,14 @@ uint32 GetParagonPanelSpellRequiredLevel(SpellInfo const* info)
// 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);
@@ -373,8 +382,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());
}
@@ -1446,9 +1487,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;
}
@@ -1513,9 +1566,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;
}
@@ -1554,14 +1613,35 @@ 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 FAVORITE <id> <0|1> -- toggle favorite flag
// 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
// into our own catalog
//
// Server replies push `R BUILDS` after every mutation. Format:
//
// R BUILDS active=<id|->\t<id>:<fav>:<haspet>:<name>:<icon>; ...
// R BUILDS active=<id|->\t<id>:<haspet>:<sharecode>:<remainAE>:<remainTE>:<name>:<icon>; ...
//
// `sharecode` is a 6-character random alphanumeric token unique across
// the realm, generated at build creation. It's how players exchange
// builds: paste the code into the BuildsPane share box on a friend's
// client and the IMPORT command copies the recipe (name + icon + spell
// rows + talent rows) into the friend's catalog as a new build with a
// fresh sharecode (so the imported copy can be re-shared independently).
//
// `remainAE` / `remainTE` are SIGNED int32s representing the AE / TE
// the player would have unspent IF they loaded this build right now
// (load = refund all currently-learned panel spells/talents, then
// re-spend on the target recipe). Negative means the recipe costs
// more than the player has earned -- the client renders that case in
// red so the player knows they can't afford the load.
//
// Names and icon paths are sanitized server-side: name = ASCII printable
// up to 32 chars (no '\t', '\r', '\n', ';', ':' since those are wire
@@ -1574,6 +1654,133 @@ constexpr char const* kDefaultBuildIcon = "INV_Misc_QuestionMark";
constexpr std::size_t kBuildNameMaxLen = 32;
constexpr std::size_t kBuildIconMaxLen = 64;
// Share code charset: upper-case alphanumeric minus visually-ambiguous
// glyphs (I, O, 0, 1) so codes spoken aloud or copied by hand are
// unambiguous. 31 chars ^ 6 positions = ~887M unique codes; collision
// retry on insert keeps practical collision probability vanishingly
// small for any realistic per-realm catalog.
constexpr std::size_t kBuildShareCodeLen = 6;
constexpr char const kBuildShareCharset[] = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
constexpr std::size_t kBuildShareCharsetN = sizeof(kBuildShareCharset) - 1;
std::string GenerateBuildShareCode()
{
std::string code;
code.reserve(kBuildShareCodeLen);
for (std::size_t i = 0; i < kBuildShareCodeLen; ++i)
code += kBuildShareCharset[urand(0, static_cast<uint32>(kBuildShareCharsetN) - 1)];
return code;
}
// Strict whitelist on incoming `C BUILD IMPORT <code>` payloads. The
// SQL we'd feed into the lookup query interpolates the value via
// fmt::format (matching the rest of this file's style), so we vet
// length and charset up front and reject anything that isn't 6
// characters drawn from the same alphabet GenerateBuildShareCode emits.
bool IsValidShareCode(std::string const& s)
{
if (s.size() != kBuildShareCodeLen)
return false;
for (char c : s)
{
bool ok = false;
for (std::size_t i = 0; i < kBuildShareCharsetN; ++i)
{
if (c == kBuildShareCharset[i])
{
ok = true;
break;
}
}
if (!ok)
return false;
}
return true;
}
// Generate a fresh share code, retrying on collision against the
// existing rows. With a 31^6 alphabet and even 1M rows the probability
// of a single random pick colliding is < 0.001%, so 8 retries is far
// more than enough headroom; the loop is purely defensive.
std::string GenerateUniqueShareCode()
{
for (int attempt = 0; attempt < 8; ++attempt)
{
std::string code = GenerateBuildShareCode();
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
// ~887M codes are taken (impossible at any realistic scale), so
// collapse to the last attempt and let the unique index reject
// duplicates if the universe is broken.
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;
@@ -1650,37 +1857,212 @@ bool BuildBelongsToPlayer(uint32 lowGuid, uint32 buildId)
return r != nullptr;
}
// ----------------------------------------------------------------------------
// Cost / remaining-AE-TE helpers (used by PushBuildCatalog tooltip data).
// ----------------------------------------------------------------------------
// "Remaining if loaded" = (total earned by this player so far)
// - (cost of recipe stored in this build).
//
// The wire push computes both halves and ships the *remaining* numbers
// per build; the client just renders them. Computing total_earned from
// (current pool + currently-spent on panel) keeps us from having to
// add a dedicated "earned" counter to character_paragon_currency.
//
// Note on per-spec accuracy: character_paragon_panel_talents is keyed
// (guid, talent_id) -- it stores the highest rank in any spec, not a
// per-spec breakdown. If a Paragon char has DIFFERENT talent allocations
// in spec 0 vs spec 1 they may have paid TE multiple times for the same
// talent_id, but the "spent" walk only sees one row. This undercounts
// total_earned in that edge case, which makes "remaining if loaded"
// show conservatively LOW for builds that include cross-spec talents.
// The character_paragon_build_talents table IS keyed (build_id, spec,
// talent_id) so the BUILD cost side is always accurate. Net effect:
// the tooltip might say a build needs 2 more AE than it really does
// for a small fraction of players. That's preferable to over-promising
// and having the load fail with "not enough TE" mid-flight.
struct BuildCost
{
uint32 ae = 0;
uint32 te = 0;
};
BuildCost ComputeBuildRecipeCost(uint32 buildId)
{
BuildCost out{};
if (!buildId)
return out;
uint32 const tePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.TE.TalentLearnCost", 1);
uint32 const aePerRank = sConfigMgr->GetOption<uint32>("Paragon.Currency.AE.TalentLearnCost", 1);
// Spell side: each panel spell costs LookupSpellAECost. The recipe
// table doesn't carry rank info because PanelLearnSpellChain grants
// every higher rank of a chain in one charge, so one row = one
// chain-head buy = one cost lookup.
if (QueryResult r = CharacterDatabase.Query(
"SELECT spell_id FROM character_paragon_build_spells WHERE build_id = {}",
buildId))
{
do
{
uint32 sid = r->Fetch()[0].Get<uint32>();
if (!sid)
continue;
out.ae += LookupSpellAECost(sid);
} while (r->NextRow());
}
// Talent side: charge tePerRank * rank for every (spec, talent_id)
// row. addToSpellBook talents (the few that grant an active spell
// like Starfall / Bladestorm / Mirror Image) charge AE on top.
if (QueryResult r = CharacterDatabase.Query(
"SELECT talent_id, `rank` FROM character_paragon_build_talents "
"WHERE build_id = {}", buildId))
{
do
{
Field const* f = r->Fetch();
uint32 tid = f[0].Get<uint32>();
uint32 rank = f[1].Get<uint8>();
if (!tid || !rank)
continue;
TalentEntry const* te = sTalentStore.LookupEntry(tid);
if (!te)
continue;
out.te += rank * tePerRank;
if (te->addToSpellBook)
out.ae += rank * aePerRank;
} while (r->NextRow());
}
return out;
}
// Sum the AE/TE the player has currently SPENT on panel-bought spells
// and talents. Refunding everything via Reset would return exactly this
// total to the unspent pool, so total_earned == current + spent.
BuildCost ComputeCurrentlySpentOnPanel(uint32 lowGuid)
{
BuildCost out{};
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 spell_id FROM character_paragon_panel_spells WHERE guid = {}",
lowGuid))
{
do
{
uint32 sid = r->Fetch()[0].Get<uint32>();
if (!sid)
continue;
out.ae += LookupSpellAECost(sid);
} while (r->NextRow());
}
if (QueryResult r = CharacterDatabase.Query(
"SELECT talent_id, `rank` FROM character_paragon_panel_talents WHERE guid = {}",
lowGuid))
{
do
{
Field const* f = r->Fetch();
uint32 tid = f[0].Get<uint32>();
uint32 rank = f[1].Get<uint8>();
if (!tid || !rank)
continue;
TalentEntry const* te = sTalentStore.LookupEntry(tid);
if (!te)
continue;
out.te += rank * tePerRank;
if (te->addToSpellBook)
out.ae += rank * aePerRank;
} while (r->NextRow());
}
return out;
}
// Lazily assign share codes to any of this player's builds that still
// have a NULL share_code (rows created under the pre-2026_05_10_04
// schema). Runs at the top of every PushBuildCatalog so by the time
// the wire response is built every row has a non-NULL code. Cheap in
// steady-state -- the SELECT returns zero rows once backfilled.
void BackfillBuildShareCodes(uint32 lowGuid)
{
QueryResult r = CharacterDatabase.Query(
"SELECT build_id FROM character_paragon_builds "
"WHERE guid = {} AND (share_code IS NULL OR share_code = '')", lowGuid);
if (!r)
return;
do
{
uint32 buildId = r->Fetch()[0].Get<uint32>();
std::string code = GenerateUniqueShareCode();
CharacterDatabase.DirectExecute(
"UPDATE character_paragon_builds SET share_code = '{}' "
"WHERE build_id = {}", code, buildId);
} while (r->NextRow());
}
void PushBuildCatalog(Player* pl)
{
if (!pl || pl->getClass() != CLASS_PARAGON)
return;
uint32 const lowGuid = pl->GetGUID().GetCounter();
BackfillBuildShareCodes(lowGuid);
uint32 const active = GetActiveBuildId(lowGuid);
// total_earned (approx) = current unspent pool + amount currently
// spent on panel learns. Refunding everything via Reset would
// return exactly the spent portion, so we model "remaining if
// loaded" as `total_earned - build_cost`. See the long comment
// on ComputeCurrentlySpentOnPanel for the per-spec edge case.
BuildCost const spent = ComputeCurrentlySpentOnPanel(lowGuid);
int64 const earnedAE = int64(GetAE(pl)) + int64(spent.ae);
int64 const earnedTE = int64(GetTE(pl)) + int64(spent.te);
std::string activeStr = active ? std::to_string(active) : std::string("-");
std::string body = fmt::format("R BUILDS active={}\t", activeStr);
if (QueryResult r = CharacterDatabase.Query(
"SELECT build_id, is_favorite, pet_number, name, icon "
"SELECT build_id, pet_number, share_code, name, icon "
"FROM character_paragon_builds WHERE guid = {} "
"ORDER BY is_favorite DESC, build_id ASC", lowGuid))
"ORDER BY build_id ASC", lowGuid))
{
bool first = true;
do
{
Field const* f = r->Fetch();
uint32 id = f[0].Get<uint32>();
uint8 fav = f[1].Get<uint8>();
bool haspet = !f[2].IsNull() && f[2].Get<uint32>() != 0;
uint32 id = f[0].Get<uint32>();
bool haspet = !f[1].IsNull() && f[1].Get<uint32>() != 0;
std::string code = f[2].IsNull() ? std::string() : f[2].Get<std::string>();
std::string name = f[3].Get<std::string>();
std::string icon = f[4].Get<std::string>();
BuildCost const cost = ComputeBuildRecipeCost(id);
// Signed: negative means the recipe costs more than the
// player has earned to date (insufficient). Clamp at int32
// bounds out of paranoia though realistic catalogs stay
// far inside.
int32 const remainAE = static_cast<int32>(earnedAE - int64(cost.ae));
int32 const remainTE = static_cast<int32>(earnedTE - int64(cost.te));
if (!first)
body += ';';
first = false;
body += fmt::format("{}:{}:{}:{}:{}", id,
static_cast<unsigned>(fav),
// Wire format:
// <id>:<haspet>:<sharecode>:<remainAE>:<remainTE>:<name>:<icon>
// Sharecode is always 6 chars after backfill; remainAE/TE
// are signed (formatted with %+d-equivalent via fmt's "{}",
// which renders a leading '-' for negatives and bare digits
// for non-negatives, matching the client's "%-?%d+" parse).
body += fmt::format("{}:{}:{}:{}:{}:{}:{}", id,
haspet ? 1 : 0,
code,
remainAE, remainTE,
name, icon);
} while (r->NextRow());
}
@@ -1724,12 +2106,90 @@ bool HandleBuildNew(Player* pl, std::string const& payload, std::string* err)
}
}
std::string code = GenerateUniqueShareCode();
CharacterDatabase.DirectExecute(
"INSERT INTO character_paragon_builds (guid, name, icon) VALUES ({}, '{}', '{}')",
lowGuid, name, icon);
"INSERT INTO character_paragon_builds (guid, name, icon, share_code) "
"VALUES ({}, '{}', '{}', '{}')",
lowGuid, name, icon, code);
PushBuildCatalog(pl);
LOG_INFO("module", "Paragon build: {} created build '{}'", pl->GetName(), name);
LOG_INFO("module", "Paragon build: {} created build '{}' (share code {})",
pl->GetName(), name, code);
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;
}
@@ -1831,14 +2291,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);
@@ -1857,39 +2324,196 @@ bool HandleBuildDelete(Player* pl, std::string const& payload, std::string* err)
return true;
}
bool HandleBuildFavorite(Player* pl, std::string const& payload, std::string* err)
// Import a build from another player's catalog by share code. Copies
// the recipe (name + icon + per-spec talent rows + spell rows) into a
// new owned build for the requester with a freshly-generated share
// code. Crucially does NOT auto-load the imported build -- the player
// finds it in their catalog and clicks it like any other saved build,
// matching the "import_only" UX choice. The original build owner's
// row is untouched.
//
// Errors (sent back as "R ERR ..." for the addon channel):
// - malformed code (length / charset)
// - 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)
{
if (!pl || pl->getClass() != CLASS_PARAGON)
{
*err = "not a Paragon";
return false;
}
auto sp = payload.find(' ');
if (sp == std::string::npos)
// Trim whitespace and uppercase the input so users don't have to
// type the code in exact case. The wire-charset is upper-only so
// forcing upper preserves the lookup hit rate even if the player
// typed a lower-case 'a'.
std::string code = payload;
auto notSpace = [](unsigned char c) { return !std::isspace(c); };
auto first = std::find_if(code.begin(), code.end(), notSpace);
auto last = std::find_if(code.rbegin(), code.rend(), notSpace).base();
code = (first < last) ? std::string(first, last) : std::string();
for (char& c : code)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
if (!IsValidShareCode(code))
{
*err = "BUILD FAVORITE malformed";
return false;
}
uint32 buildId = static_cast<uint32>(std::strtoul(payload.substr(0, sp).c_str(), nullptr, 10));
int flag = std::atoi(payload.substr(sp + 1).c_str());
if (!buildId)
{
*err = "BUILD FAVORITE bad id";
*err = "share code must be 6 characters (A-Z minus I/O, 2-9)";
return false;
}
uint32 const lowGuid = pl->GetGUID().GetCounter();
if (!BuildBelongsToPlayer(lowGuid, buildId))
// 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);
bool fromArchive = false;
uint32 srcBuildId = 0;
uint32 srcOwner = 0;
std::string srcName;
std::string srcIcon;
if (srcRow)
{
*err = "build does not belong to player";
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;
}
if (!fromArchive && srcOwner == lowGuid)
{
*err = "this build is already in your catalog";
return false;
}
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;
}
}
// 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(
"UPDATE character_paragon_builds SET is_favorite = {} WHERE build_id = {}",
flag ? 1 : 0, buildId);
"INSERT INTO character_paragon_builds (guid, name, icon, share_code) "
"VALUES ({}, '{}', '{}', '{}')",
lowGuid, insertName, insertIcon, newCode);
QueryResult idRow = CharacterDatabase.Query(
"SELECT build_id FROM character_paragon_builds "
"WHERE share_code = '{}'", newCode);
if (!idRow)
{
*err = "import failed (could not allocate build_id)";
return false;
}
uint32 newBuildId = idRow->Fetch()[0].Get<uint32>();
// Copy recipe rows row-by-row via INSERT...SELECT so we don't
// 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.
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);
}
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);
// 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;
}
@@ -2118,21 +2742,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);
}
@@ -2275,7 +2902,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;
}
@@ -2480,6 +3108,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
{
@@ -2527,6 +3169,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;
@@ -2541,10 +3190,10 @@ public:
SendAddonMessage(player, "R ERR " + err);
return;
}
if (body.compare(0, 17, "C BUILD FAVORITE ") == 0)
if (body.compare(0, 15, "C BUILD IMPORT ") == 0)
{
std::string err;
if (!HandleBuildFavorite(player, body.substr(17), &err))
if (!HandleBuildImport(player, body.substr(15), &err))
SendAddonMessage(player, "R ERR " + err);
return;
}
@@ -2555,6 +3204,22 @@ public:
SendAddonMessage(player, "R ERR " + err);
return;
}
// "C BUILD UNLOAD" -- clears the active-build pointer without
// touching learned spells/talents or parking pets. Recovery
// path for a stale active pointer (e.g. a load that was
// interrupted between Phase 3 and Phase 4 in HandleBuildLoad,
// leaving the row pointing at a build whose recipe was already
// re-applied). Player retains current learns; the catalog push
// refreshes the UI so the "Active" glow + tooltip clear.
if (body == "C BUILD UNLOAD")
{
if (player->getClass() != CLASS_PARAGON)
return;
uint32 const lowGuid = player->GetGUID().GetCounter();
SetActiveBuildId(lowGuid, 0);
PushBuildCatalog(player);
return;
}
if (body == "C RESET PET TALENTS")
{
@@ -0,0 +1,5 @@
node_modules/
dist/
launcher.json
.DS_Store
Thumbs.db
@@ -0,0 +1,92 @@
# Fractured Launcher (Electron)
Windows launcher with **no extra console window**, **native Browse folder** dialog, GitHub **release assets** + repo file sync, **realmlist**, optional **auth**, **Play**, and **auto-update** (via `electron-updater`). This is the **only** supported client launcher in this repo.
## 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). By default **`github.repo`** is **`Fractured-Distro`** (public release assets); no token is required for patches. Set **GITHUB_TOKEN** only if you override `github` to a **private** repo.
## 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. |
## Auto-update behaviour
- **Packaged** builds only (`npm run pack:win` output). In `npm start` dev mode, update checks are skipped (button still explains that).
- **~5 seconds** after launch, then **every 6 hours**, the app checks for a newer version.
- When a download finishes, a dialog offers **Restart now** (calls `quitAndInstall`) or **Later**.
- **Manual check:** button **Check launcher updates** in the UI.
### Where updates are hosted
**`package.json`** → `build.publish` targets **`Dawnforger/Fractured-Distro`** (public). `electron-updater` reads **`latest.yml`** from the **latest** release there; players do not need a GitHub token for launcher updates.
**Private GitHub** (if you change `build.publish` or `github` back to a private repo): set **`GH_TOKEN`** / **`GITHUB_TOKEN`** / **`github.token_env`** before starting the launcher so the updater can authenticate.
**Public generic feed** (optional CDN): set **`update_feed_url`** or **`LAUNCHER_UPDATE_URL`** to a folder that hosts `latest.yml` + installers; optional Bearer token if the host requires it.
### Publishing a new launcher version
1. Bump **`version`** in `package.json` (semver, e.g. `1.0.1`).
2. Create a **GitHub personal access token** with `repo` (or `public_repo` for public repos).
3. From this directory:
```bash
set GH_TOKEN=ghp_your_token_here
npm run publish:win
```
That builds NSIS + portable and **uploads** update metadata and installers to the configured GitHub repos **releases** (see [electron-builder publish](https://www.electron.build/configuration/publish)).
Players on an older NSIS install will pick up the next version automatically on the next check.
## Public distro repo (patches + launcher binaries)
Default **`default-launcher.json`** uses **`github.repo`: `Fractured-Distro`** so **`from_release`** assets (`patch-Z.MPQ`, `Wow-patched.exe`, …) download from **[Dawnforger/Fractured-Distro releases](https://github.com/Dawnforger/Fractured-Distro/releases)** — **no player token**.
Publish assets there by:
- **GitHub Actions** — workflow **Sync release to Fractured-Distro** (`.github/workflows/distro-release-sync.yml`): on **release published** on `Dawnforger/Fractured`, it **builds the Electron launcher** from that tag on Windows (`npm run pack:win`), **downloads every asset** attached to that release (patches, `Wow-patched.exe`, …), merges them (launcher files overwrite on duplicate names), and creates or updates the **same tag** on **`Fractured-Distro`**. Requires repository secret **`DISTRO_SYNC_TOKEN`**. **Manual:** Actions → run workflow with an existing tag.
- **Locally:** `scripts/publish-to-distro.sh` (see script header) if you need to upload without a full release cycle.
If your public repo slug is not `Fractured-Distro`, edit **`DISTRO_REPO`** in the workflow / script and **`github.repo`** in `launcher.json`.
### Private `github.repo` (optional)
For a **private** release source, set `GITHUB_TOKEN` (or `github.token_env`) with read access — **do not** embed a shared PAT in shipped `launcher.json` for all players.
**Release asset names** must match **`files[].source`** exactly when **`from_release`**: true. Use **`release_tag`: `"latest"`** for the newest release, or pin a tag.
## CI
Workflow **Fractured launcher CI** (`.github/workflows/fractured-launcher-ci.yml`) runs on pushes/PRs under `tools/fractured-launcher-electron/`: Windows **`npm run pack:win`** and **artifacts** (`*.exe`, `latest.yml`, blockmaps). **Actions → Fractured launcher CI → Run workflow** runs it manually.
## 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 (overrides default GitHub feed when set).
- **`github`**: `owner`, `repo`, `ref` (repo file paths), **`release_tag`** (`latest` or e.g. `v1.0.0`), **`token_env`** (env var name for a PAT when using private sources).
- **`files`**: `source`, `dest`, `backup`, **`from_release`** (asset name on GitHub release vs repo path).
- **`realmlist`**, **`auth`**, **`launch`**.
@@ -0,0 +1,42 @@
{
"game_dir": "",
"update_feed_url": "",
"github": {
"owner": "Dawnforger",
"repo": "Fractured-Distro",
"ref": "main",
"release_tag": "latest",
"token_env": "GITHUB_TOKEN"
},
"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,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,111 @@
'use strict';
const { dialog } = require('electron');
const { autoUpdater } = require('electron-updater');
/**
* @param {import('electron').App} app
* @param {() => import('electron').BrowserWindow | null} getMainWindow
* @param {{ updateFeedUrl?: string, githubOwner?: string, githubRepo?: string, token?: string }} opts
*/
function setupAutoUpdater(app, getMainWindow, opts = {}) {
if (!app.isPackaged) {
return {
checkNow: async () => ({ skipped: true, reason: 'development build' }),
};
}
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
const token = String(opts.token || '').trim();
const envGeneric = String(process.env.LAUNCHER_UPDATE_URL || '').trim();
const configGeneric = String(opts.updateFeedUrl || '').trim();
const genericUrl = envGeneric || configGeneric;
const owner = String(opts.githubOwner || 'Dawnforger').trim();
const repo = String(opts.githubRepo || 'Fractured').trim();
if (genericUrl) {
const base = genericUrl.replace(/\/?$/, '/');
autoUpdater.setFeedURL({
provider: 'generic',
url: base,
});
if (token) {
autoUpdater.requestHeaders = {
...autoUpdater.requestHeaders,
Authorization: `Bearer ${token}`,
};
}
} else if (token && owner && repo) {
autoUpdater.setFeedURL({
provider: 'github',
owner,
repo,
private: true,
token,
});
}
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: could not read GitHub releases (404). ' +
'If the repo is private, set GITHUB_TOKEN (or your token_env) so the launcher can authenticate, ' +
'or set update_feed_url in launcher.json to a public HTTPS folder that contains latest.yml.'
);
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,114 @@
'use strict';
const path = require('path');
const fs = require('fs').promises;
const { githubToken } = require('./github-token');
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) {
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 or private repo without token?)';
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,
};
+142
View File
@@ -0,0 +1,142 @@
'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 { 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 tokenEnv = config.github && config.github.token_env;
const token =
(tokenEnv && String(process.env[tokenEnv] || '').trim()) ||
String(process.env.GH_TOKEN || process.env.GITHUB_TOKEN || '').trim();
const updateFeedUrl = String(process.env.LAUNCHER_UPDATE_URL || config.update_feed_url || '').trim();
autoUpdateApi = setupAutoUpdater(app, () => mainWindow, {
updateFeedUrl,
githubOwner: config.github && config.github.owner,
githubRepo: config.github && config.github.repo,
token,
});
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();
return {
configPath,
gameDir: config.game_dir || '',
authEnabled: !!(config.auth && config.auth.enabled),
wowExe: (config.launch && config.launch.exe) || 'Wow.exe',
wowOk: wowInstallValid(config),
};
});
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,67 @@
{
"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": "electron-builder --win nsis portable --x64",
"publish:win": "electron-builder --win nsis portable --x64 --publish always"
},
"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": [
{
"provider": "github",
"owner": "Dawnforger",
"repo": "Fractured-Distro"
}
],
"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,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,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;
}