Update Healer Man server

This commit is contained in:
phenom committed 2026-09-20 21:50:50 -04:00
1 parent fdb6739f8d
commit f90ba7d989
41 files changed
+1660 -107

No files matched your search

+7
View File
@@ -54,6 +54,13 @@ These rules apply to every change under this project root.
change is a separate external mutation; do not close a report merely because
a local fix passes.
## Active balance certification scope
- Automated balance, bracket, seat-sweep, and benchmark validation is Ready-only until the project owner promotes more specializations. The current Ready set is Protection Warrior, Discipline Priest, Marksmanship Hunter, Fire Mage, and Assassination Rogue.
- Do not run the full repository test suite or all-class WoW/CoA/RoM balance sweeps for routine balance work. Use `npm run test:ready-five` plus the smallest directly affected focused tests.
- CoA specializations and RoM class/role builds are Needs Testing. They may be inspected in an explicit targeted diagnostic, but must not enter random, all-eligible, regression, bracket, or NPC seat-audit runs.
- A broader balance run requires an explicit user request or a readiness-policy promotion. Record that expanded scope in the task handoff.
## Playable characters and equipment
- `src/avatar/CharacterModel.tsx` is the shared player, party, roster, and character-creation rendering path. Never replace it with a screen-specific loader.
+13 -2
View File
@@ -37,12 +37,12 @@ backup, and Gitea APK release procedures are documented in
Verification:
```powershell
npm test
npm run test:ready-five
npm run build
```
Boss and party balance simulations, including exact, random, and automatic
multi-party WoW, CoA, and RoM sweeps with complete per-boss/member/ability reports, are documented in
multi-party sweeps with complete per-boss/member/ability reports, are documented in
[`docs/BALANCE_LAB.md`](docs/BALANCE_LAB.md). Start the guided tool with
`npm run balance:lab`, or use the server-powered **Balance Lab** beneath GM
Tools. The GM screen keeps previous runs across server restarts and provides
@@ -51,6 +51,17 @@ graphs/tables. The Abilities tab retains every learned/available ability,
including zero-use rows, so missing rotations can be compared with casts, hits,
damage share, healing, and absorbs.
Automatic balance certification is Ready-only: Protection Warrior, Discipline
Priest, Marksmanship Hunter, Fire Mage, and Assassination Rogue. Every other
WoW spec plus every CoA and RoM entry is Needs Testing and excluded from broad
bracket, regression, and NPC seat-audit runs.
Authenticated GMs can open **Ability & Talent Tuning** from the main-menu GM
Menu. The local sandbox supplies searchable ability and talent catalogs plus
per-device runtime overrides for cooldowns, cast times, costs, damage, healing,
durations, area reach, and supported passive talent modifiers. See
[`docs/BALANCE_TUNING.md`](docs/BALANCE_TUNING.md).
Manastorms, Arenas/PvP, and their GM tools are shelved in normal browser and
Android builds. Their source and saved data remain intact. Use
`npm run dev:shelved` or `npm run build:shelved` for maintenance builds, and
+39 -19
View File
@@ -23,10 +23,11 @@ npm run balance:lab
The prompt selects:
1. one random party, five manual members, or an automatic multi-party sweep;
2. a WoW, CoA, or RoM party catalog; automatic mode can select all three;
2. a WoW, CoA, or RoM party catalog; automatic mode only uses entries marked
Ready (plus Beta when explicitly enabled);
3. a level from 1 through 80;
4. random parties, guaranteed coverage of all class/spec builds, or a
searchable selection of specific class/spec builds;
4. random Ready parties, guaranteed coverage of all Ready class/spec builds,
or a searchable targeted selection that may include Needs Testing builds;
5. how many unique 1-tank/1-healer/3-damage configurations to generate per
source when random selection is used;
6. all bosses, WoW bosses, RoM bosses, or a searchable multi-boss selection;
@@ -39,9 +40,10 @@ Automatic class/spec selection has three strategies:
to the requested number of unique parties per source. If a Ready/Beta pool
can form fewer unique 1/1/3 rosters, Balance Lab runs each available roster
once and reports the cap instead of failing or duplicating identical data.
- **All specs** creates the minimum deterministic set of valid parties needed
to include every available class/spec build for the selected WoW, CoA, or
RoM source at least once.
- **All Ready specs** creates the minimum deterministic set of valid parties
needed to include every Ready build for the selected source at least once.
At present this is the five certified WoW specs; CoA and RoM have no Ready
entries and therefore do not participate in automatic runs.
- **Specific specs** treats each selected build as required coverage and fills
the remaining tank, healer, and damage slots with distinct compatible builds.
When alternatives exist, required builds are not reused as fillers.
@@ -269,6 +271,12 @@ normalized slightly above the best compatible same-level item in the authored
WotLK-derived loot catalog. The saved catalog and heirloom summary make that
resolved loadout explicit for each local run.
Damage and healer heirloom profiles include both critical-strike rating and
haste rating. The WotLK stat resolver converts those ratings at the simulated
character level into the appropriate melee, ranged, or spell percentages.
Protection tank heirlooms intentionally spend that budget on defense,
avoidance, block, stamina, and expertise instead.
Run selected brackets as a companion-only seat audit:
```powershell
@@ -295,8 +303,10 @@ unless `--cooldown-policy` points to a JSON policy file.
## Companion specialization readiness
The shared revisioned readiness policy classifies all 31 WoW specializations
as Ready, Beta, or Needs Testing. Shipped defaults certify only Protection
The readiness catalog shows every WoW and CoA specialization plus each RoM
class/role profile as Ready, Beta, or Needs Testing. The revisioned GM policy
currently publishes the 31 WoW specialization statuses; all CoA and RoM entries
are fixed at Needs Testing until their certification work begins. Shipped defaults certify only Protection
Warrior/Aldren, Discipline Priest/Quorin, Marksmanship Hunter/Liora, Fire
Mage/Xavian, and Assassination Rogue/Orin. The main menu, Dungeon setup, and
Balance Lab link to the full Companion Readiness catalog. Players can inspect
@@ -304,19 +314,26 @@ it; authenticated GMs can edit and atomically publish the complete policy.
Published policies must retain at least one Ready tank, one Ready healer, and
three distinct Ready damage specializations.
Random and all-eligible WoW runs use Ready specs by default. **Include Beta
Specs** adds Beta specs and is saved per device; explicit targeted runs may
still select a Needs Testing spec, but their filler seats come only from the
eligible Ready/Beta pool. CoA and RoM Balance Lab catalogs are unchanged.
Random and all-eligible runs use Ready specs by default across every source.
Because no CoA or RoM entry is currently Ready, those catalogs contribute no
automatic parties. **Include Beta Specs** adds Beta specs and is saved per
device; explicit targeted diagnostics may still select a Needs Testing spec,
but `--ready-only` rejects those targets for certification and regression runs.
Filler seats always come from the eligible Ready/Beta pool.
Server jobs snapshot the published policy and beta choice. Local GUI runs use
the revisioned loopback policy saved under
`artifacts/balance-lab-gui-runtime`; corrupt or missing policy files fall back
to shipped defaults.
CLI parity is available through `--include-beta-specs` and
CLI parity is available through `--ready-only`, `--include-beta-specs`, and
`--companion-readiness-policy <json>`. The JSON may be the published envelope
or its policy config.
The standard bracket, regression, and NPC-audit npm commands pass
`--ready-only`. For focused code validation use `npm run test:ready-five`; do
not use a full-repository or all-class balance sweep unless the project owner
explicitly expands the certification scope.
List exact party build IDs at a level:
```powershell
@@ -348,7 +365,8 @@ python scripts/balance_lab.py `
--boss-category all
```
Generate ten parties for each of WoW, CoA, and RoM, but test only RoM bosses:
Run every currently Ready source against only RoM bosses. Until a CoA or RoM
build is promoted, `--source all` still produces only the five Ready WoW specs:
```powershell
python scripts/balance_lab.py `
@@ -498,10 +516,12 @@ change when the problem is confined to particular encounters.
## Coverage and limitations
The selectable roster includes every authored WoW and CoA specialization plus
every supported RoM class-role pair. Existing generated party talent plans are
used when available. Missing WoW plans are legally allocated through their
matching live talent tree; CoA builds allocate the chosen specialization tree
and class tree through the live essence/prerequisite rules.
every supported RoM class-role pair, but automatic certification is Ready-only.
Needs Testing entries remain selectable solely for an intentional targeted
diagnostic. Existing generated party talent plans are used when available.
Missing WoW plans are legally allocated through their matching live talent
tree; CoA builds allocate the chosen specialization tree and class tree through
the live essence/prerequisite rules.
Headless simulation executes combat-store boss attacks and their authored
damage, healing, control, aura, summon, threat, and timing effects. It does not
@@ -604,7 +624,7 @@ python -m py_compile scripts/balance_lab_brackets.py
python -m unittest scripts.test_balance_lab scripts.test_balance_lab_brackets
node --test server/local-balance-lab-api.test.mjs server/balance-lab-jobs.test.mjs server/server.test.mjs
python scripts/balance_lab.py --source wow --level 20 --party-mode automatic --party-configurations 2 --boss "gundrak/entry-29932" --simulations-per-party 1 --duration 5 --output-dir artifacts/balance-lab-smoke
npm test -- --run src/game/benchmark src/game/partyStore.test.ts src/game/partyRuntime.test.ts
npm run test:ready-five
npm run build
npm run balance:lab:playtest
npm run release:check
+67
View File
@@ -0,0 +1,67 @@
# Ability and Talent Tuning
The main-menu **GM Menu** exposes **Ability & Talent Tuning** to authenticated
GMs. It is a full-screen DOM tool intended for fast local combat iteration. The
catalog can be searched and filtered by class, and the selected entry exposes
its source values beside editable runtime percentages.
## Ability overrides
Ability overrides are keyed by stable ability ID. Each field is expressed as a
percentage of the source catalog value: `100` preserves the source, `50` halves
it, and `200` doubles it. The editor supports:
- cooldown;
- cast or channel time;
- resource cost;
- direct, periodic, finisher, and summon damage;
- direct, periodic, and absorb healing;
- aura, control, summon, and periodic duration;
- area radius, chain reach, and ground reach.
`src/game/balanceTuning.ts` normalizes and applies the policy.
`src/game/abilityCatalog.ts` retains the authored catalog separately and
rebuilds its live definitions whenever the GM applies a draft. This prevents
percentage changes from compounding when the editor is opened repeatedly.
## Talent overrides
Talent overrides can scale every supported passive modifier parsed from a
talent tooltip. They can also add a signed percentage-point modifier per learned
rank for damage, healing, critical chance, haste, costs, cooldowns, durations,
range, threat, attributes, defenses, or resources.
On-use talents are not passive modifiers. Inner Focus is explicitly excluded
from passive tooltip parsing. Activating its ability grants a temporary aura;
the next direct single-target or area heal costs no resource, gains 25
percentage points of critical chance, keeps its ordinary cast time, and consumes
the aura. Tune its cooldown and aura duration from the Abilities tab.
## Persistence and scope
The normalized policy is stored per device under
`healer-man:balance-tuning:v1`. Applying it updates player abilities, NPC
companion abilities, and client-side test runs that use the live browser
catalog. **Reset ability**, **Reset talent**, and **Reset all** remove overrides
without changing authored or generated source data.
This first editor is intentionally a local sandbox. Server Balance Lab jobs run
in a separate process and do not read browser local storage; permanent balance
changes should still be moved into authored source data after local validation.
## Heirloom ratings
Virtual heirlooms include specialization-aware critical-strike and haste
ratings for strength, agility, ranged, spell-damage, and healer profiles.
Protection tank gear instead prioritizes defense, avoidance, block, stamina,
and expertise. `src/game/wotlkStats.ts` converts the ratings at the character's
level into melee, ranged, and spell critical/haste percentages. Both player and
party heirlooms continue to cap at item level 79.
## Validation
```powershell
npx vitest run src/game/balanceTuning.test.ts src/game/talentModifiers.test.ts src/game/combatStore.test.ts src/game/heirloomEquipment.test.ts
npm run build
npm run release:check
```
+9 -4
View File
@@ -79,9 +79,12 @@ Active abilities fall into one of three audit buckets:
The shared WoW NPC policy begins with Protection Warrior (Aldren), Discipline
Priest (Quorin), Marksmanship Hunter (Liora), Fire Mage (Xavian), and
Assassination Rogue (Orin) as Ready. Every other WoW specialization begins as
Needs Testing; Beta is an explicit GM-published opt-in. Readiness filters only
NPC seats. Player character choices, RoM parties, and CoA/RoM Balance Lab
catalogs are unrestricted.
Needs Testing; Beta is an explicit GM-published opt-in. Every CoA specialization
and RoM class/role profile is also shown as Needs Testing. Automatic balance,
bracket, regression, and seat-audit scopes exclude Needs Testing entries.
Player character choices remain unrestricted, and an explicit targeted
diagnostic can still inspect a Needs Testing build without adding it to the
certified pool.
Ready is a certification result, not a tuning label. Its profile must resolve a
legal Playerbots-derived talent allocation and learned ability ranks at each
@@ -99,7 +102,9 @@ Power Word: Shield as named absorb capacity, and Renew on moderately injured
targets without replacing a pending HoT. Inner Focus is reserved for an ally
at or below 40% health or at least two allies at or below 60%, then empowers
the next direct single-target or area heal with a free cost and 25 percentage
points of critical chance. Protection Revenge is enabled for a
points of critical chance. Player Inner Focus uses the same one-heal aura and
retains the chosen heal's normal cast time; its activation tooltip is excluded
from permanent passive talent parsing. Protection Revenge is enabled for a
five-second window after an authoritative incoming dodge, parry, or block.
Untargeted cooldown resets affect other abilities only, so Readiness cannot
reset itself and monopolize the Hunter rotation. Blink and Disengage create
+2 -2
View File
@@ -186,8 +186,8 @@ Run focused tests while iterating, then use the relevant final checks:
# Required after authored dungeon mechanics change
npm run dungeon:catalog
# Combat or shared runtime changes
npm test
# Combat or shared runtime changes (current certified companion scope)
npm run test:ready-five
# Report API/server changes
npm run server:test
+4 -3
View File
@@ -118,12 +118,13 @@
"assets:performance:audit": "node scripts/asset-pipeline/audit-environment-performance.mjs",
"balance:benchmark": "vite-node scripts/combat-benchmark/run.ts",
"balance:lab": "python scripts/balance_lab.py",
"balance:lab:brackets": "python scripts/balance_lab_brackets.py",
"balance:lab:npc-audit": "python scripts/balance_lab_brackets.py --seat-sweep",
"balance:lab:brackets": "python scripts/balance_lab_brackets.py --ready-only",
"balance:lab:npc-audit": "python scripts/balance_lab_brackets.py --ready-only --seat-sweep",
"balance:gui": "vite --mode balance-lab-gui --host 127.0.0.1 --open",
"balance:lab:regression": "python scripts/balance_lab_brackets.py --check",
"balance:lab:regression": "python scripts/balance_lab_brackets.py --ready-only --check",
"balance:lab:playtest": "node scripts/playtest-balance-lab.cjs",
"readiness:playtest": "node scripts/playtest-companion-readiness.cjs",
"test:ready-five": "vitest run src/game/companionSpecReadiness.test.ts src/game/readyFiveCertification.test.ts && python -m unittest scripts.test_balance_lab scripts.test_balance_lab_brackets",
"balance:smoke": "vite-node scripts/combat-benchmark/run.ts --class mage --role damage --scenario damage-single-burst --seeds 1 --duration-scale 0.2",
"prebuild": "npm run loot:generate && npm run web:prepare && npm run assets:ktx2:audit && npm run assets:performance:audit",
"build": "node --max-old-space-size=8192 node_modules/typescript/bin/tsc -b && node --max-old-space-size=8192 node_modules/vite/bin/vite.js build",
+38 -8
View File
@@ -196,15 +196,12 @@ def readiness_eligible_builds(
policy: dict[str, Any] | None,
include_beta_specs: bool,
) -> list[dict[str, Any]]:
"""Filter only WoW NPC builds; CoA and RoM catalogs remain unchanged."""
"""Return the Ready pool across every source; Beta requires explicit opt-in."""
published = policy or {}
config = published.get("config", published)
statuses = config.get("statuses", {}) if isinstance(config, dict) else {}
eligible: list[dict[str, Any]] = []
for build in builds:
if build.get("source") != "wow":
eligible.append(build)
continue
specialization_id = build.get("specializationId")
status = statuses.get(specialization_id, build.get("readinessStatus", "needs-testing"))
if status == "ready" or (include_beta_specs and status == "beta"):
@@ -282,7 +279,12 @@ def coverage_parties(
continue
available_by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
targets_by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
source_fillers = [build for build in (filler_builds or builds) if build["source"] == source]
filler_catalog = list(filler_builds or builds)
# Prefer same-source companions, then fall back to the globally Ready
# pool so an intentional Needs Testing target can still be diagnosed
# without admitting other uncertified seats.
source_fillers = [build for build in filler_catalog if build["source"] == source]
source_fillers.extend(build for build in filler_catalog if build["source"] != source)
default_builds = [build for build in source_fillers if build.get("defaultTalentBuild")]
for build in default_builds or source_fillers:
available_by_role[build["role"]].append(build)
@@ -1443,7 +1445,9 @@ def parser() -> argparse.ArgumentParser:
result.add_argument("--companion-readiness-policy", type=Path,
help="published companion readiness policy JSON; defaults to shipped catalog statuses")
result.add_argument("--include-beta-specs", action="store_true",
help="include Beta WoW specs in automatic, random, and filler NPC seats")
help="include Beta specs in automatic, random, and filler NPC seats")
result.add_argument("--ready-only", action="store_true",
help="reject explicit Needs Testing targets; used by certification and regression runs")
result.add_argument("--output-dir", type=Path, help="report directory")
result.add_argument("--list-members", action="store_true")
result.add_argument("--list-bosses", action="store_true")
@@ -1507,7 +1511,14 @@ def main(argv: Sequence[str] | None = None) -> int:
raise SystemExit(f"Could not read --companion-readiness-policy: {error}") from error
eligible_builds = readiness_eligible_builds(builds, readiness_policy, args.include_beta_specs)
if args.party_mode == "automatic":
sources = ("wow", "coa", "rom") if args.source == "all" else (args.source,)
requested_sources = ("wow", "coa", "rom") if args.source == "all" else (args.source,)
sources = requested_sources if args.spec_selection == "specific" else tuple(
source for source in requested_sources
if any(build["source"] == source for build in eligible_builds)
)
if not sources:
session.close()
raise SystemExit("No Ready companion builds exist for the selected source. Promote specs before running automatic coverage.")
if args.spec_selection != "specific" and args.spec:
session.close()
raise SystemExit("--spec values require --spec-selection specific")
@@ -1521,6 +1532,14 @@ def main(argv: Sequence[str] | None = None) -> int:
selected_build_ids = list(dict.fromkeys(
resolve_build(selector, builds)["id"] for selector in args.spec
))
if args.ready_only:
eligible_ids = {build["id"] for build in eligible_builds}
rejected = [build_id for build_id in selected_build_ids if build_id not in eligible_ids]
if rejected:
session.close()
raise SystemExit(
"--ready-only rejected Needs Testing targets: " + ", ".join(rejected)
)
configurations = coverage_parties(
eligible_builds if args.spec_selection == "all" else builds,
sources,
@@ -1538,13 +1557,23 @@ def main(argv: Sequence[str] | None = None) -> int:
session.close()
raise SystemExit("Manual mode requires exactly five --member values.")
party = [resolve_build(selector, builds) for selector in args.member]
if args.ready_only:
eligible_ids = {build["id"] for build in eligible_builds}
rejected = [build["id"] for build in party if build["id"] not in eligible_ids]
if rejected:
session.close()
raise SystemExit("--ready-only rejected Needs Testing members: " + ", ".join(rejected))
active_slot = args.active_slot or next(
(index + 1 for index, build in enumerate(party) if build["role"] == "healer"), 1
)
configurations = [{"id": f"{args.source}-party-001", "source": args.source,
"party": party, "activeSlot": active_slot - 1}]
else:
party = random_party(eligible_builds, args.party_seed)
random_pool = [
build for build in eligible_builds
if args.source == "all" or build["source"] == args.source
]
party = random_party(random_pool, args.party_seed)
active_slot = args.active_slot or next(
(index + 1 for index, build in enumerate(party) if build["role"] == "healer"), 1
)
@@ -1583,6 +1612,7 @@ def main(argv: Sequence[str] | None = None) -> int:
"mode": "automatic",
"seatSweep": args.seat_sweep,
"includeBetaSpecs": args.include_beta_specs,
"readyOnly": args.ready_only,
"companionReadinessPolicy": readiness_policy,
"specSelection": args.spec_selection,
"selectedBuildIds": selected_build_ids,
+4
View File
@@ -82,6 +82,8 @@ def build_balance_lab_command(args: argparse.Namespace, level: int, output_dir:
command.extend(("--companion-readiness-policy", str(args.companion_readiness_policy.resolve())))
if getattr(args, "include_beta_specs", False):
command.append("--include-beta-specs")
if getattr(args, "ready_only", False):
command.append("--ready-only")
for selector in args.spec:
command.extend(("--spec", selector))
for selector in args.boss:
@@ -444,6 +446,8 @@ def parser() -> argparse.ArgumentParser:
result.add_argument("--cooldown-policy", type=Path)
result.add_argument("--companion-readiness-policy", type=Path)
result.add_argument("--include-beta-specs", action="store_true")
result.add_argument("--ready-only", action="store_true",
help="reject Needs Testing targets and retain only Ready/Beta automatic pools")
result.add_argument("--boss-category", choices=("all", "wow", "rom"), default="all")
result.add_argument("--boss", action="append", default=[])
result.add_argument("--jobs", type=int, default=min(4, os.cpu_count() or 1),
+4 -1
View File
@@ -90,7 +90,7 @@ function help(): string {
"Usage: npm run balance:benchmark -- [options]",
"",
"Options:",
" --class mage,templar Limit primary classes.",
" --class mage,templar Explicit targeted class diagnostic.",
" --role damage,healer Limit roles.",
" --scenario id,id Limit scenario IDs.",
" --level 60 Comparison level (default 60).",
@@ -99,6 +99,9 @@ function help(): string {
" --output path Output directory under the current path or absolute.",
" --help Show this help.",
"",
"Default scope: the five Ready WoW specializations only.",
"CoA, RoM, and other WoW specializations require an explicit --class filter.",
"",
].join("\n");
}
+114
View File
@@ -0,0 +1,114 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const directory = path.resolve('playtest-artifacts', 'party-heal-popups');
const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-party-heals-'));
fs.mkdirSync(directory, { recursive: true });
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir: runtimeData, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(4194, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(180_000);
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
const moduleUrls = fs.readdirSync('dist/assets')
.filter((file) => /^(?:index|GameRuntime|combatStore)-.*\.js$/.test(file))
.map((file) => `/assets/${file}`);
await page.addInitScript((urls) => {
const stores = async () => {
const values = (await Promise.all(urls.map((url) => import(url)))).flatMap(Object.values);
const candidates = values.filter((value) => value && typeof value.getState === 'function');
const party = candidates.find((store) => Array.isArray(store.getState()?.members)
&& typeof store.getState()?.initializeParty === 'function');
const combat = candidates.find((store) => Array.isArray(store.getState()?.combatEvents)
&& typeof store.getState()?.healActor === 'function');
const shell = candidates.find((store) => Array.isArray(store.getState()?.characters)
&& typeof store.getState()?.openMainMenu === 'function');
if (!party || !combat || !shell) throw new Error('Party/combat/shell stores were not found.');
return { party, combat, shell };
};
window.openMainMenuForPartyHealPlaytest = async () => {
const { shell } = await stores();
shell.getState().openMainMenu();
};
window.enterDungeonForPartyHealPlaytest = async () => {
const { shell } = await stores();
const ok = shell.getState().enterDungeon('wailing-caverns');
return { ok, notice: shell.getState().notice, phase: shell.getState().phase };
};
window.openDungeonsForPartyHealPlaytest = async () => {
const { shell } = await stores();
shell.getState().openDungeons();
};
window.triggerPartyHealPopup = async () => {
const { party, combat } = await stores();
const member = party.getState().members[0];
if (!member) throw new Error('No NPC party member is available.');
const injuredHealth = Math.max(1, member.maxHealth - 300);
party.setState({
members: party.getState().members.map((candidate) => candidate.id === member.id
? { ...candidate, health: injuredHealth }
: candidate),
});
const actor = combat.getState().actors[member.id];
if (!actor) throw new Error('The NPC party actor is unavailable.');
combat.setState({ actors: {
...combat.getState().actors,
[member.id]: { ...actor, health: injuredHealth, alive: true },
} });
const mob = Object.values(combat.getState().mobs).find((candidate) => !candidate.dead && !candidate.despawned);
if (!mob) throw new Error('No living dungeon enemy is available.');
combat.setState({ mobs: { ...combat.getState().mobs, [mob.id]: { ...mob, engaged: true } } });
const result = combat.getState().healActor(member.id, 137, {
sourceActorId: '__player__',
abilityId: 'party-frame-playtest-heal',
now: Date.now(),
amountAlreadyModified: true,
});
return { memberId: member.id, amount: result.effectiveAmount };
};
}, moduleUrls);
try {
await page.goto('http://127.0.0.1:4194', { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: /Play offline/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('HealpopupQA');
await page.getByRole('button', { name: /Create character/ }).click();
await page.evaluate(() => window.openMainMenuForPartyHealPlaytest());
await page.locator('.main-menu-screen').waitFor();
await page.evaluate(() => window.openDungeonsForPartyHealPlaytest());
await page.locator('.dungeon-select-screen').waitFor();
const entry = await page.evaluate(() => window.enterDungeonForPartyHealPlaytest());
assert.equal(entry.ok, true, JSON.stringify(entry));
await page.locator('.party-frames').waitFor();
await page.locator('.party-frame').nth(4).waitFor();
await page.locator('.loading-screen').waitFor({ state: 'detached' });
const result = await page.evaluate(() => window.triggerPartyHealPopup());
assert.equal(result.amount, 137);
const popup = page.locator('.party-frame__heal-popup', { hasText: '+137' });
await popup.waitFor({ state: 'visible' });
const targetFrame = page.locator('.party-frame').filter({ has: popup });
assert.equal(await targetFrame.count(), 1, 'heal number belongs to exactly one receiving frame');
assert.equal(await page.locator('.desktop-action-feedback').count(), 0, 'center action feedback is absent');
assert.equal((await page.locator('.action-bar__feedback').allInnerTexts()).join('').trim(), '', 'controller bar has no combat log text');
await page.screenshot({ path: path.join(directory, 'party-heal-popup.png') });
assert.deepEqual(pageErrors, []);
console.log(`Party-frame heal popup playtest passed for ${result.memberId}.`);
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
fs.rmSync(runtimeData, { recursive: true, force: true });
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+18 -3
View File
@@ -141,7 +141,7 @@ class BalanceLabControllerTests(unittest.TestCase):
self.assertTrue(set(selected).issubset(covered))
self.assertTrue(all(observed.count(build_id) == 1 for build_id in selected))
def test_readiness_filters_only_wow_and_beta_requires_opt_in(self) -> None:
def test_readiness_filters_every_source_and_beta_requires_opt_in(self) -> None:
builds = [
{"id": "ready-tank", "source": "wow", "specializationId": "warrior:protection"},
{"id": "beta-damage", "source": "wow", "specializationId": "paladin:retribution"},
@@ -155,11 +155,11 @@ class BalanceLabControllerTests(unittest.TestCase):
}}}
self.assertEqual(
[build["id"] for build in readiness_eligible_builds(builds, policy, False)],
["ready-tank", "rom-damage"],
["ready-tank"],
)
self.assertEqual(
[build["id"] for build in readiness_eligible_builds(builds, policy, True)],
["ready-tank", "beta-damage", "rom-damage"],
["ready-tank", "beta-damage"],
)
def test_targeted_needs_testing_build_uses_ready_fillers(self) -> None:
@@ -178,6 +178,21 @@ class BalanceLabControllerTests(unittest.TestCase):
self.assertIn("testing-shadow", ids)
self.assertTrue(all(build_id == "testing-shadow" or build_id.startswith("ready-") for build_id in ids))
def test_targeted_non_wow_build_uses_ready_wow_fillers(self) -> None:
ready = [
{"id": "ready-tank", "source": "wow", "role": "tank", "readinessStatus": "ready", "defaultTalentBuild": True},
{"id": "ready-healer", "source": "wow", "role": "healer", "readinessStatus": "ready", "defaultTalentBuild": True},
*(
{"id": f"ready-damage-{index}", "source": "wow", "role": "damage", "readinessStatus": "ready", "defaultTalentBuild": True}
for index in range(3)
),
]
target = {"id": "coa-target", "source": "coa", "role": "damage", "readinessStatus": "needs-testing", "defaultTalentBuild": True}
parties = coverage_parties([*ready, target], ("coa",), ["coa-target"], 21, ready)
ids = [member["id"] for member in parties[0]["party"]]
self.assertIn("coa-target", ids)
self.assertEqual(sum(build_id.startswith("ready-") for build_id in ids), 4)
def test_boss_search_matches_name_dungeon_key_and_source(self) -> None:
bosses = [
{"key": "gundrak/eck", "name": "Eck the Ferocious", "dungeonTitle": "Gundrak", "category": "wow"},
+2
View File
@@ -27,6 +27,7 @@ class BalanceLabBracketTests(unittest.TestCase):
active_slot=None, spec=[], boss=["stormwind-stockade/entry-1717"],
seat_sweep=True, cooldown_policy=None,
companion_readiness_policy=Path("policy.json"), include_beta_specs=True,
ready_only=True,
)
command = build_balance_lab_command(args, 40, Path("output/level-40"))
self.assertEqual(command[0], sys.executable)
@@ -36,6 +37,7 @@ class BalanceLabBracketTests(unittest.TestCase):
self.assertEqual(command[command.index("--seeds") + 1], "1,2,3")
self.assertIn("--seat-sweep", command)
self.assertIn("--include-beta-specs", command)
self.assertIn("--ready-only", command)
self.assertEqual(
command[command.index("--companion-readiness-policy") + 1],
str(Path("policy.json").resolve()),
+26 -2
View File
@@ -10,6 +10,7 @@ import {
import { loadCoaAbilityCatalogShard } from "./coaAbilityCatalogLoader";
import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras";
import { applyAbilityBalanceTuning, currentBalanceTuningPolicy } from "./balanceTuning";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
@@ -890,6 +891,9 @@ const CLASSIC_CORE_ABILITIES: Readonly<Record<BaseClassId, readonly AbilityDefin
const abilitiesByClass: Partial<Record<ClassId, readonly AbilityDefinition[]>> = {
...CLASSIC_CORE_ABILITIES,
};
const sourceAbilitiesByClass: Partial<Record<ClassId, readonly AbilityDefinition[]>> = {
...CLASSIC_CORE_ABILITIES,
};
const abilityCatalog: Record<string, AbilityDefinition> = {};
let romEliteAbilities: readonly AbilityDefinition[] = [];
let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null;
@@ -904,8 +908,28 @@ export const ABILITY_CATALOG = abilityCatalog as Readonly<Record<string, Ability
function registerClassAbilities(classId: ClassId, abilities: readonly AbilityDefinition[]): void {
for (const previous of abilitiesByClass[classId] ?? []) delete abilityCatalog[previous.id];
abilitiesByClass[classId] = abilities;
for (const ability of abilities) abilityCatalog[ability.id] = ability;
sourceAbilitiesByClass[classId] = abilities;
const policy = currentBalanceTuningPolicy();
const tuned = abilities.map((ability) => applyAbilityBalanceTuning(ability, policy));
abilitiesByClass[classId] = tuned;
for (const ability of tuned) abilityCatalog[ability.id] = ability;
}
/** Rebuilds the live catalog after a local balance policy is published. */
export function refreshAbilityBalanceTuning(): void {
const policy = currentBalanceTuningPolicy();
for (const [classId, source] of Object.entries(sourceAbilitiesByClass)) {
if (!source) continue;
for (const previous of abilitiesByClass[classId as ClassId] ?? []) delete abilityCatalog[previous.id];
const tuned = source.map((ability) => applyAbilityBalanceTuning(ability, policy));
abilitiesByClass[classId as ClassId] = tuned;
for (const ability of tuned) abilityCatalog[ability.id] = ability;
}
}
/** Untuned source definitions used by the GM balance editor. */
export function sourceAbilitiesForClass(classId: ClassId): readonly AbilityDefinition[] {
return sourceAbilitiesByClass[classId] ?? [];
}
for (const classId of BASE_CLASS_IDS) {
+103
View File
@@ -0,0 +1,103 @@
import { afterEach, describe, expect, it } from "vitest";
import type { AbilityDefinition } from "./abilityCatalog";
import {
applyAbilityBalanceTuning,
currentBalanceTuningPolicy,
loadBalanceTuningPolicy,
normalizeBalanceTuningPolicy,
resetBalanceTuningCache,
saveBalanceTuningPolicy,
} from "./balanceTuning";
const ability: AbilityDefinition = {
id: "test-fireball",
classId: "mage",
dbcSpellId: 1,
name: "Test Fireball",
unlockLevel: 1,
icon: "",
sigil: "TF",
description: "Test",
target: "hostile",
range: { min: 0, max: 30 },
radius: 8,
castMode: "cast",
castTimeMs: 2_000,
cooldownMs: 10_000,
gcdMs: 1_500,
cost: { resource: "mana", amount: 20 },
effects: [
{ kind: "damage", coefficient: 2, basePoints: 100 },
{ kind: "dot", coefficient: 0.5, ticks: 4, intervalMs: 1_000 },
{ kind: "utility", action: "class-script", effectType: 1, auraType: 0, miscValue: 0, triggerSpellId: 0, durationMs: -1 },
],
};
function memoryStorage(initial: string | null = null) {
let value = initial;
return {
getItem: () => value,
setItem: (_key: string, next: string) => { value = next; },
removeItem: () => { value = null; },
value: () => value,
};
}
describe("local ability and talent balance tuning", () => {
afterEach(() => resetBalanceTuningCache());
it("normalizes unsafe values and drops default-only overrides", () => {
expect(normalizeBalanceTuningPolicy({
abilityOverrides: {
default: { cooldownPercent: 100 },
tuned: { cooldownPercent: -50, damagePercent: 9_999 },
},
talentOverrides: {
talent: { effectScalePercent: 150, modifiersPerRank: { "critical-chance-percent": 2.5 } },
},
})).toEqual({
schemaVersion: 1,
abilityOverrides: { tuned: { cooldownPercent: 0, damagePercent: 1_000 } },
talentOverrides: {
talent: { effectScalePercent: 150, modifiersPerRank: { "critical-chance-percent": 2.5 } },
},
});
});
it("applies cooldown, cast, cost, damage, duration, and radius tuning", () => {
const tuned = applyAbilityBalanceTuning(ability, normalizeBalanceTuningPolicy({
abilityOverrides: {
[ability.id]: {
cooldownPercent: 50,
castTimePercent: 0,
resourceCostPercent: 75,
damagePercent: 125,
durationPercent: 150,
radiusPercent: 200,
},
},
}));
expect(tuned).toMatchObject({
castMode: "instant",
castTimeMs: 0,
cooldownMs: 5_000,
cost: { amount: 15 },
radius: 16,
});
expect(tuned.effects[0]).toMatchObject({ coefficient: 2.5, basePoints: 125 });
expect(tuned.effects[1]).toMatchObject({ coefficient: 0.625, ticks: 6 });
expect(tuned.effects[2]).toMatchObject({ durationMs: -1 });
});
it("persists valid policy data and falls back after corrupt storage", () => {
const storage = memoryStorage();
saveBalanceTuningPolicy({ abilityOverrides: { x: { cooldownPercent: 80 } } }, storage);
expect(JSON.parse(storage.value()!)).toMatchObject({ abilityOverrides: { x: { cooldownPercent: 80 } } });
expect(currentBalanceTuningPolicy().abilityOverrides.x.cooldownPercent).toBe(80);
expect(loadBalanceTuningPolicy(memoryStorage("not-json"))).toEqual({
schemaVersion: 1,
abilityOverrides: {},
talentOverrides: {},
});
});
});
+322
View File
@@ -0,0 +1,322 @@
import type {
AbilityDefinition,
AbilityEffect,
AbilityRankDefinition,
} from "./abilityCatalog";
export const BALANCE_TUNING_STORAGE_KEY = "healer-man:balance-tuning:v1";
export const BALANCE_TUNING_EVENT = "healer-man:balance-tuning-changed";
export const TALENT_BALANCE_MODIFIER_KINDS = [
"damage-percent",
"critical-damage-percent",
"healing-percent",
"damage-taken-percent",
"max-health-percent",
"max-resource-percent",
"resource-regeneration-percent",
"resource-cost-percent",
"critical-chance-percent",
"haste-percent",
"cast-time-percent",
"cooldown-percent",
"armor-percent",
"attribute-percent",
"range-percent",
"duration-percent",
"threat-percent",
] as const;
export type TalentBalanceModifierKind = (typeof TALENT_BALANCE_MODIFIER_KINDS)[number];
export interface AbilityBalanceOverride {
readonly cooldownPercent?: number;
readonly castTimePercent?: number;
readonly resourceCostPercent?: number;
readonly damagePercent?: number;
readonly healingPercent?: number;
readonly durationPercent?: number;
readonly radiusPercent?: number;
}
export interface TalentBalanceOverride {
/** Scales every supported passive modifier parsed from the talent tooltip. */
readonly effectScalePercent?: number;
/** Signed percentage-point adjustments applied per learned rank. */
readonly modifiersPerRank?: Readonly<Partial<Record<TalentBalanceModifierKind, number>>>;
}
export interface BalanceTuningPolicy {
readonly schemaVersion: 1;
readonly abilityOverrides: Readonly<Record<string, AbilityBalanceOverride>>;
readonly talentOverrides: Readonly<Record<string, TalentBalanceOverride>>;
}
interface StorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
const DEFAULT_POLICY: BalanceTuningPolicy = Object.freeze({
schemaVersion: 1,
abilityOverrides: Object.freeze({}),
talentOverrides: Object.freeze({}),
});
const ABILITY_FIELDS = [
"cooldownPercent",
"castTimePercent",
"resourceCostPercent",
"damagePercent",
"healingPercent",
"durationPercent",
"radiusPercent",
] as const;
let cachedPolicy: BalanceTuningPolicy | null = null;
function browserStorage(): StorageLike | null {
try {
return typeof globalThis.localStorage === "undefined" ? null : globalThis.localStorage;
} catch {
return null;
}
}
function bounded(value: unknown, minimum: number, maximum: number): number | undefined {
const numeric = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numeric)) return undefined;
return Math.min(maximum, Math.max(minimum, Math.round(numeric * 100) / 100));
}
function normalizeAbilityOverride(value: unknown): AbilityBalanceOverride | null {
if (!value || typeof value !== "object") return null;
const source = value as Record<string, unknown>;
const result: Record<string, number> = {};
for (const field of ABILITY_FIELDS) {
const normalized = bounded(source[field], 0, 1_000);
if (normalized !== undefined && normalized !== 100) result[field] = normalized;
}
return Object.keys(result).length ? result : null;
}
function normalizeTalentOverride(value: unknown): TalentBalanceOverride | null {
if (!value || typeof value !== "object") return null;
const source = value as Record<string, unknown>;
const effectScalePercent = bounded(source.effectScalePercent, 0, 1_000);
const sourceModifiers = source.modifiersPerRank && typeof source.modifiersPerRank === "object"
? source.modifiersPerRank as Record<string, unknown>
: {};
const modifiersPerRank: Partial<Record<TalentBalanceModifierKind, number>> = {};
for (const kind of TALENT_BALANCE_MODIFIER_KINDS) {
const normalized = bounded(sourceModifiers[kind], -1_000, 1_000);
if (normalized !== undefined && normalized !== 0) modifiersPerRank[kind] = normalized;
}
const result: TalentBalanceOverride = {
...(effectScalePercent !== undefined && effectScalePercent !== 100 ? { effectScalePercent } : {}),
...(Object.keys(modifiersPerRank).length ? { modifiersPerRank } : {}),
};
return Object.keys(result).length ? result : null;
}
export function normalizeBalanceTuningPolicy(value: unknown): BalanceTuningPolicy {
if (!value || typeof value !== "object") return DEFAULT_POLICY;
const source = value as Record<string, unknown>;
const sourceAbilities = source.abilityOverrides && typeof source.abilityOverrides === "object"
? source.abilityOverrides as Record<string, unknown>
: {};
const sourceTalents = source.talentOverrides && typeof source.talentOverrides === "object"
? source.talentOverrides as Record<string, unknown>
: {};
const abilityOverrides = Object.fromEntries(Object.entries(sourceAbilities).flatMap(([id, override]) => {
const normalized = normalizeAbilityOverride(override);
return normalized ? [[id, normalized]] : [];
}));
const talentOverrides = Object.fromEntries(Object.entries(sourceTalents).flatMap(([id, override]) => {
const normalized = normalizeTalentOverride(override);
return normalized ? [[id, normalized]] : [];
}));
return Object.freeze({
schemaVersion: 1,
abilityOverrides: Object.freeze(abilityOverrides),
talentOverrides: Object.freeze(talentOverrides),
});
}
export function loadBalanceTuningPolicy(storage: StorageLike | null = browserStorage()): BalanceTuningPolicy {
if (!storage) return DEFAULT_POLICY;
try {
const serialized = storage.getItem(BALANCE_TUNING_STORAGE_KEY);
return serialized ? normalizeBalanceTuningPolicy(JSON.parse(serialized)) : DEFAULT_POLICY;
} catch {
return DEFAULT_POLICY;
}
}
export function currentBalanceTuningPolicy(): BalanceTuningPolicy {
cachedPolicy ??= loadBalanceTuningPolicy();
return cachedPolicy;
}
export function saveBalanceTuningPolicy(
value: unknown,
storage: StorageLike | null = browserStorage(),
): BalanceTuningPolicy {
const policy = normalizeBalanceTuningPolicy(value);
cachedPolicy = policy;
try {
if (storage) {
const empty = !Object.keys(policy.abilityOverrides).length && !Object.keys(policy.talentOverrides).length;
if (empty) storage.removeItem(BALANCE_TUNING_STORAGE_KEY);
else storage.setItem(BALANCE_TUNING_STORAGE_KEY, JSON.stringify(policy));
}
} catch {
// Runtime tuning still applies for this session when persistent storage is unavailable.
}
if (typeof globalThis.dispatchEvent === "function" && typeof globalThis.CustomEvent === "function") {
globalThis.dispatchEvent(new CustomEvent(BALANCE_TUNING_EVENT, { detail: policy }));
}
return policy;
}
export function clearBalanceTuningPolicy(storage: StorageLike | null = browserStorage()): BalanceTuningPolicy {
return saveBalanceTuningPolicy(DEFAULT_POLICY, storage);
}
/** Test helper for isolating the module-level read cache without mutating browser storage. */
export function resetBalanceTuningCache(): void {
cachedPolicy = null;
}
function scale(value: number, percent: number): number {
return Math.max(0, Math.round(value * percent / 100));
}
function scaleOptional(value: number | undefined, percent: number): number | undefined {
return value === undefined ? undefined : value * percent / 100;
}
function scaleDuration(value: number, percent: number): number {
// Imported catalogs use negative durations as semantic sentinels. They are
// not elapsed time and must survive unrelated tuning unchanged.
return value < 0 ? value : scale(value, percent);
}
function tuneAmount<T extends Record<string, unknown>>(effect: T, percent: number): T {
return {
...effect,
...(typeof effect.coefficient === "number" ? { coefficient: effect.coefficient * percent / 100 } : {}),
...(typeof effect.baseCoefficient === "number" ? { baseCoefficient: effect.baseCoefficient * percent / 100 } : {}),
...(typeof effect.perComboCoefficient === "number" ? { perComboCoefficient: effect.perComboCoefficient * percent / 100 } : {}),
...(typeof effect.basePoints === "number" ? { basePoints: effect.basePoints * percent / 100 } : {}),
...(typeof effect.dieSides === "number" ? { dieSides: effect.dieSides * percent / 100 } : {}),
...(typeof effect.pointsPerLevel === "number" ? { pointsPerLevel: effect.pointsPerLevel * percent / 100 } : {}),
...(typeof effect.coefficientPerLevel === "number" ? { coefficientPerLevel: effect.coefficientPerLevel * percent / 100 } : {}),
...(typeof effect.bonusPowerCoefficient === "number" ? { bonusPowerCoefficient: effect.bonusPowerCoefficient * percent / 100 } : {}),
...(typeof effect.attackPowerCoefficient === "number" ? { attackPowerCoefficient: effect.attackPowerCoefficient * percent / 100 } : {}),
...(typeof effect.spiritCoefficient === "number" ? { spiritCoefficient: effect.spiritCoefficient * percent / 100 } : {}),
...(effect.poison && typeof effect.poison === "object" ? {
poison: {
...effect.poison,
basePointsPerDose: Number((effect.poison as Record<string, unknown>).basePointsPerDose ?? 0) * percent / 100,
attackPowerCoefficientPerDose: Number((effect.poison as Record<string, unknown>).attackPowerCoefficientPerDose ?? 0) * percent / 100,
},
} : {}),
};
}
function tuneEffect(
effect: AbilityEffect,
override: AbilityBalanceOverride,
): AbilityEffect {
const damagePercent = override.damagePercent ?? 100;
const healingPercent = override.healingPercent ?? 100;
const durationPercent = override.durationPercent ?? 100;
const radiusPercent = override.radiusPercent ?? 100;
let tuned: AbilityEffect = effect;
if (["damage", "finisher-damage", "dot", "execute"].includes(effect.kind)) {
tuned = tuneAmount(effect as unknown as Record<string, unknown>, damagePercent) as unknown as AbilityEffect;
} else if (["heal", "hot", "shield"].includes(effect.kind)) {
tuned = tuneAmount(effect as unknown as Record<string, unknown>, healingPercent) as unknown as AbilityEffect;
} else if (effect.kind === "summon") {
tuned = { ...effect, coefficient: effect.coefficient * damagePercent / 100 };
}
if (tuned.kind === "dot" || tuned.kind === "hot") {
tuned = { ...tuned, ticks: Math.max(1, Math.round(tuned.ticks * durationPercent / 100)) };
} else if (tuned.kind === "apply-aura") {
tuned = {
...tuned,
aura: {
...tuned.aura,
durationMs: tuned.aura.durationMs === null ? null : scale(tuned.aura.durationMs, durationPercent),
},
};
} else if ("durationMs" in tuned && typeof tuned.durationMs === "number") {
tuned = { ...tuned, durationMs: scaleDuration(tuned.durationMs, durationPercent) } as AbilityEffect;
}
if ("radius" in tuned && typeof tuned.radius === "number") {
tuned = { ...tuned, radius: tuned.radius * radiusPercent / 100 } as AbilityEffect;
}
return tuned;
}
function tuneRank(rank: AbilityRankDefinition, override: AbilityBalanceOverride): AbilityRankDefinition {
const castTimeMs = scale(rank.castTimeMs, override.castTimePercent ?? 100);
const tuneCost = <T extends { readonly amount: number }>(cost: T): T => ({
...cost,
amount: cost.amount * (override.resourceCostPercent ?? 100) / 100,
});
return {
...rank,
castMode: rank.castMode === "channel" ? "channel" : castTimeMs > 0 ? "cast" : "instant",
castTimeMs,
cooldownMs: scale(rank.cooldownMs, override.cooldownPercent ?? 100),
cost: tuneCost(rank.cost),
costs: rank.costs?.map(tuneCost),
effects: rank.effects.map((effect) => tuneEffect(effect, override)),
targeting: rank.targeting ? {
...rank.targeting,
radius: scaleOptional(rank.targeting.radius, override.radiusPercent ?? 100),
jumpRange: scaleOptional(rank.targeting.jumpRange, override.radiusPercent ?? 100),
groundRange: scaleOptional(rank.targeting.groundRange, override.radiusPercent ?? 100),
} : undefined,
};
}
export function applyAbilityBalanceTuning(
ability: AbilityDefinition,
policy: BalanceTuningPolicy = currentBalanceTuningPolicy(),
): AbilityDefinition {
const override = policy.abilityOverrides[ability.id];
if (!override) return ability;
const castTimeMs = scale(ability.castTimeMs, override.castTimePercent ?? 100);
const tuneCost = <T extends { readonly amount: number }>(cost: T): T => ({
...cost,
amount: cost.amount * (override.resourceCostPercent ?? 100) / 100,
});
return {
...ability,
castMode: ability.castMode === "channel" ? "channel" : castTimeMs > 0 ? "cast" : "instant",
castTimeMs,
cooldownMs: scale(ability.cooldownMs, override.cooldownPercent ?? 100),
cost: tuneCost(ability.cost),
costs: ability.costs?.map(tuneCost),
radius: scaleOptional(ability.radius, override.radiusPercent ?? 100),
effects: ability.effects.map((effect) => tuneEffect(effect, override)),
targeting: ability.targeting ? {
...ability.targeting,
radius: scaleOptional(ability.targeting.radius, override.radiusPercent ?? 100),
jumpRange: scaleOptional(ability.targeting.jumpRange, override.radiusPercent ?? 100),
groundRange: scaleOptional(ability.targeting.groundRange, override.radiusPercent ?? 100),
} : undefined,
ranks: ability.ranks?.map((rank) => tuneRank(rank, override)),
};
}
export function talentBalanceOverride(
talentId: string,
policy: BalanceTuningPolicy = currentBalanceTuningPolicy(),
): TalentBalanceOverride | null {
return policy.talentOverrides[talentId] ?? null;
}
+8 -11
View File
@@ -1,6 +1,4 @@
import { describe, expect, it } from "vitest";
import { CLASSES } from "../../app/characterCatalog";
import { partyRolesForClass } from "../partyRoles";
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
import {
loadCanonicalBenchmarkBuilds,
@@ -9,16 +7,15 @@ import {
} from "./benchmarkBuilds";
describe("canonical benchmark builds", () => {
it("covers every declared class-role pair exactly once with legal ranks", async () => {
it("defaults to the five Ready class/spec builds with legal ranks", async () => {
const builds = await loadCanonicalBenchmarkBuilds();
const expectedPairs = CLASSES.flatMap((definition) => (
partyRolesForClass(definition.id).map((role) => `${definition.id}:${role}`)
));
const actualPairs = builds.map((build) => `${build.classId}:${build.role}`);
expect(builds).toHaveLength(77);
expect(new Set(actualPairs).size).toBe(builds.length);
expect(new Set(actualPairs)).toEqual(new Set(expectedPairs));
expect(builds.map((build) => `${build.classId}:${build.orientation}:${build.role}`)).toEqual([
"warrior:Protection:tank",
"hunter:Marksmanship:damage",
"rogue:Assassination:damage",
"priest:Discipline:healer",
"mage:Fire:damage",
]);
for (const build of builds) {
expect(build.level).toBe(60);
expect(build.gearPresetId).toBe("virtual-heirloom");
+31 -10
View File
@@ -29,6 +29,7 @@ import {
} from "../partyTalentRuntime";
import { clampPlayerLevel } from "../progression";
import {
romGearProfile,
specializationsForClass,
type SpecializationDefinition,
} from "../specializationCatalog";
@@ -45,6 +46,7 @@ import {
} from "../talentUiCatalog";
import type { BenchmarkBuildDefinition } from "./benchmarkTypes";
import {
READY_COMPANION_SPECIALIZATION_IDS,
readinessStatusFor,
type CompanionSpecReadinessStatus,
} from "../companionSpecReadiness";
@@ -59,6 +61,7 @@ const EMPTY_TALENT_RANKS: RuntimeTalentRanks = Object.freeze({});
const BASE_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(BASE_CLASS_IDS);
const COA_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(COA_CLASS_IDS);
const ROM_CLASS_ID_SET: ReadonlySet<ClassId> = new Set(ROM_CLASS_IDS);
const READY_SPECIALIZATION_ID_SET: ReadonlySet<string> = new Set(READY_COMPANION_SPECIALIZATION_IDS);
const WOW_ORIENTATION_BY_ROLE: Readonly<
Record<BaseClassId, Readonly<Partial<Record<PartyRole, string>>>>
@@ -66,8 +69,8 @@ const WOW_ORIENTATION_BY_ROLE: Readonly<
warrior: { tank: "Protection", damage: "Fury" },
paladin: { tank: "Protection", healer: "Holy", damage: "Retribution" },
hunter: { damage: "Marksmanship" },
rogue: { damage: "Combat" },
priest: { healer: "Holy", damage: "Shadow" },
rogue: { damage: "Assassination" },
priest: { healer: "Discipline", damage: "Shadow" },
"death-knight": { tank: "Blood", damage: "Frost" },
shaman: { healer: "Restoration", damage: "Elemental" },
mage: { damage: "Fire" },
@@ -555,14 +558,19 @@ export function selectableBenchmarkBuildCatalog(
.map((definition) => definition.id)
.filter((classId) => !requested || requested.has(classId))
.flatMap((classId): readonly SelectableBenchmarkBuildCatalogEntry[] => {
if (isRomClassId(classId)) return partyRolesForClass(classId).map((role) => ({
if (isRomClassId(classId)) return partyRolesForClass(classId).map((role) => {
const specialization = romGearProfile(classId, role);
return {
id: benchmarkBuildId(classId, role, safeLevel),
source: contentCategoryForClass(classId),
classId,
role,
orientation: "Primary",
specializationId: specialization.id,
readinessStatus: readinessStatusFor(specialization.id),
level: safeLevel,
}));
};
});
return specializationsForClass(classId).flatMap((specialization) => {
const profiles = isBaseClassId(classId)
? runtimePartyTalentProfiles(classId, specialization.displayName)
@@ -583,8 +591,8 @@ export function selectableBenchmarkBuildCatalog(
classId,
role: specialization.role,
orientation: specialization.displayName,
specializationId: isBaseClassId(classId) ? specialization.id : undefined,
readinessStatus: isBaseClassId(classId) ? readinessStatusFor(specialization.id) : undefined,
specializationId: specialization.id,
readinessStatus: readinessStatusFor(specialization.id),
level: safeLevel,
characterName: talentProfile?.characterName,
talentBuildId: talentProfile?.id,
@@ -663,19 +671,25 @@ export async function loadSelectableBenchmarkBuilds(
}
/**
* Creates one stable, legal benchmark build for every declared class-role pair.
* A filter changes only membership; output always follows the canonical CLASSES
* and partyRolesForClass declaration order.
* Creates the five Ready benchmark builds by default. Supplying explicit class
* IDs remains a targeted diagnostic and returns those classes' declared roles.
* Output follows the canonical CLASSES and partyRolesForClass declaration order.
*/
export async function loadCanonicalBenchmarkBuilds(
level = CANONICAL_BENCHMARK_LEVEL,
classIds?: readonly ClassId[],
): Promise<BenchmarkBuildDefinition[]> {
const safeLevel = clampPlayerLevel(level);
const readyOnly = classIds === undefined;
const requested = classIds ? new Set(classIds) : null;
const selectedClassIds = CLASSES
.map((definition) => definition.id)
.filter((classId) => !requested || requested.has(classId));
.filter((classId) => {
if (requested) return requested.has(classId);
return isBaseClassId(classId) && specializationsForClass(classId).some((specialization) => (
READY_SPECIALIZATION_ID_SET.has(specialization.id)
));
});
const selectedCoaClassIds = selectedClassIds.filter(isCoaClassId);
await preloadAbilityCatalogsForClasses(selectedCoaClassIds);
@@ -690,6 +704,13 @@ export async function loadCanonicalBenchmarkBuilds(
for (const classId of selectedClassIds) {
for (const role of partyRolesForClass(classId)) {
if (isBaseClassId(classId)) {
if (readyOnly) {
const orientation = WOW_ORIENTATION_BY_ROLE[classId][role];
const specialization = specializationsForClass(classId).find((candidate) => (
candidate.displayName === orientation
));
if (!specialization || !READY_SPECIALIZATION_ID_SET.has(specialization.id)) continue;
}
builds.push(wowBuild(classId, role, safeLevel));
} else if (isCoaClassId(classId)) {
const catalog = coaCatalogs.get(classId);
+39
View File
@@ -25,6 +25,7 @@ import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { talentModifiersForRanks } from "./talentModifiers";
import {
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
@@ -483,6 +484,44 @@ describe("combat store", () => {
expect(useCombatStore.getState().shield).toBe(0);
});
it("keeps Inner Focus as an on-use, one-heal effect", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const talent = TALENT_NODES.find((node) => node.name === "Inner Focus")!;
const ranks = { [talent.id]: 1 };
useCombatStore.setState({
talentRanks: ranks,
talentModifiers: talentModifiersForRanks(ranks, "priest"),
});
const innerFocus = abilitiesForClass("priest").find((ability) => ability.name === "Inner Focus")!;
useCombatStore.getState().damagePlayer(500);
useCombatStore.setState((state) => ({
resource: 0,
resourcePools: { ...state.resourcePools, mana: 0 },
}));
expect(useCombatStore.getState().castAbility(innerFocus.id, undefined, 1_000).ok).toBe(true);
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("next-heal-free-critical")
))).toBe(true);
const heal = useCombatStore.getState().castAbility("wow335-priest-lesser-heal", undefined, 3_000);
expect(heal.ok).toBe(true);
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeGreaterThan(0);
const completion = useCombatStore.getState().activeCast!.completesAt;
const spentAtBeforeCompletion = useCombatStore.getState().lastResourceSpentAt.mana;
useCombatStore.getState().tick(0.1, completion);
expect(useCombatStore.getState().lastResourceSpentAt.mana).toBe(spentAtBeforeCompletion);
expect(useCombatStore.getState().auras.some((aura) => (
aura.definition.utilityTags?.includes("next-heal-free-critical")
))).toBe(false);
useCombatStore.setState((state) => ({
resource: 0,
resourcePools: { ...state.resourcePools, mana: 0 },
}));
expect(useCombatStore.getState().castAbility("wow335-priest-lesser-heal", undefined, completion + 2_000))
.toMatchObject({ ok: false, reason: "not-enough-resource" });
});
it("ticks damage over time and awards kill experience exactly once", () => {
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 1, xp: 0 });
registerTarget("ooze", [10, 0, 0], 5, 100);
+35 -4
View File
@@ -4626,6 +4626,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
? abilityAtLevel(catalogAbility, state.level, learnedTalentRank)
: null;
const activePlayerAuras = aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID);
const innerFocusAura = activePlayerAuras.find((aura) => (
aura.definition.utilityTags?.includes("next-heal-free-critical")
));
const consumesInnerFocus = Boolean(
innerFocusAura
&& ability?.name !== "Inner Focus"
&& ability?.effects.some((effect) => effect.kind === "heal"),
);
const hasRuneWakerState = (tag: string) => activePlayerAuras.some((aura) => aura.definition.utilityTags?.includes(tag));
const holyAuraFriendly = Boolean(
ability?.source === "runewaker"
@@ -4640,9 +4648,21 @@ export const useCombatStore = create<CombatState>((set, get) => ({
? completingCast.targetId && state.mobs[completingCast.targetId] ? "hostile" : "friendly"
: state.selectedFriendlyActorId || party.selectedMemberId || party.selfSelected ? "friendly" : "hostile"
: ability?.target;
const modifiers = state.talentModifiers;
const resourceCost = ability ? talentAdjustedResourceCost(ability, modifiers) : 0;
const costs = ability ? resolvedAbilityCosts(ability, modifiers, state) : [];
const modifiers: readonly TalentModifier[] = consumesInnerFocus ? [
...state.talentModifiers,
{
kind: "critical-chance-percent",
amount: 25,
unit: "percent",
scope: "your healing spells",
sourceTalentId: "talent-348:active",
sourceTalentName: "Inner Focus",
rank: 1,
description: "The next direct or area heal costs no resource and gains 25 percentage points of critical chance.",
},
] : state.talentModifiers;
const resourceCost = ability && !consumesInnerFocus ? talentAdjustedResourceCost(ability, modifiers) : 0;
const costs = ability && !consumesInnerFocus ? resolvedAbilityCosts(ability, modifiers, state) : [];
const costsToSpend = completingCast?.mode === "channel" ? [] : costs;
const targeting = ability ? targetingRulesForAbility(ability) : null;
const spellSchool = ability ? ability.castRules?.school ?? abilityDamageSchool(state.classId, ability) : "physical";
@@ -4959,6 +4979,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
raceId: state.raceId,
secondaryClassId: state.secondaryClassId,
};
if (consumesInnerFocus && innerFocusAura) {
work.auras = work.auras.filter((aura) => aura.instanceId !== innerFocusAura.instanceId);
}
const castThreatSource = playerThreatSource(state, ability.id);
if (targeting?.shape === "ground" && groundPosition) {
@@ -5194,7 +5217,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
playerCharacterStats(state.classId, state.level, state.gearStats, state.raceId, state.secondaryClassId, modifiers),
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
);
const criticalChance = Math.max(0, casterStats.spellCritPercent / 100);
const criticalChance = Math.max(0, (
casterStats.spellCritPercent
+ applyTalentModifierValue(
0,
modifiers,
"critical-chance-percent",
abilityTalentContext(ability),
)
) / 100);
let directHealing = 0;
healingTargetIds = [...healingRecipientIds(effect, PLAYER_AURA_ENTITY_ID, targetId, work.actors,
id => friendlyPosition(id) ?? getMobPosition(id), healingTargetIds, combatHasLineOfSight, now)];
+10
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
COMPANION_READINESS_SPECIALIZATIONS,
DEFAULT_COMPANION_SPEC_READINESS_POLICY,
READY_COMPANION_SPECIALIZATION_IDS,
WOW_COMPANION_SPECIALIZATIONS,
@@ -25,6 +26,15 @@ describe("companion specialization readiness", () => {
expect(readinessStatusFor("future:new-spec", normalized)).toBe("needs-testing");
});
it("marks every CoA specialization and RoM class-role profile as Needs Testing", () => {
const wowIds = new Set(WOW_COMPANION_SPECIALIZATIONS.map((definition) => definition.id));
const nonWow = COMPANION_READINESS_SPECIALIZATIONS.filter((definition) => !wowIds.has(definition.id));
expect(nonWow.length).toBeGreaterThan(0);
expect(nonWow.every((definition) => readinessStatusFor(definition.id) === "needs-testing")).toBe(true);
expect(nonWow.some((definition) => definition.classId === "barbarian")).toBe(true);
expect(nonWow.some((definition) => definition.classId === "rom-warrior")).toBe(true);
});
it("rejects policies without certified role coverage", () => {
expect(() => normalizeCompanionSpecReadinessPolicy({
schemaVersion: 1,
+15 -2
View File
@@ -1,7 +1,8 @@
import { BASE_CLASS_IDS } from "../app/characterCatalog";
import type { PartyRole } from "./partyRoles";
import { BASE_CLASS_IDS, ROM_CLASS_IDS } from "../app/characterCatalog";
import { partyRolesForClass, type PartyRole } from "./partyRoles";
import {
SPECIALIZATION_DEFINITIONS,
romGearProfile,
type SpecializationDefinition,
} from "./specializationCatalog";
@@ -35,6 +36,18 @@ export const WOW_COMPANION_SPECIALIZATIONS: readonly SpecializationDefinition[]
SPECIALIZATION_DEFINITIONS.filter((definition) => CLASSIC_CLASS_IDS.has(definition.classId)),
);
/**
* Complete certification catalog. CoA specializations and RoM class/role
* profiles are visible as Needs Testing, but remain outside the publishable
* WoW policy until their companion implementations enter certification.
*/
export const COMPANION_READINESS_SPECIALIZATIONS: readonly SpecializationDefinition[] = Object.freeze([
...SPECIALIZATION_DEFINITIONS,
...ROM_CLASS_IDS.flatMap((classId) => partyRolesForClass(classId).map((role) => (
romGearProfile(classId, role)
))),
]);
const WOW_SPECIALIZATION_IDS = new Set(WOW_COMPANION_SPECIALIZATIONS.map((definition) => definition.id));
export const DEFAULT_COMPANION_SPEC_READINESS_POLICY: CompanionSpecReadinessPolicy = Object.freeze({
+20 -1
View File
@@ -3,12 +3,13 @@ import type { ClassId } from "../app/characterCatalog";
import { classCanEquipItem, equipmentSlotForItem } from "./equipment";
import {
compareEquipmentUpgrade,
equipmentStatsForItems,
resolvedAverageItemLevel,
resolvedEquipmentItems,
resolvedEquipmentLoadout,
type HeirloomContext,
} from "./heirloomEquipment";
import { itemStatsFor } from "./itemStats";
import { EMPTY_ITEM_STATS, itemStatsFor } from "./itemStats";
import { LOOT_CATALOG, scaledLootItemLevel, type LootCatalogItem } from "./lootCatalog";
import type { InventoryItem } from "./lootTypes";
import {
@@ -17,6 +18,7 @@ import {
} from "./specializationCatalog";
import { useCombatStore } from "./combatStore";
import { normalizeInventory } from "./lootTypes";
import { deriveCharacterStats } from "./wotlkStats";
const templates = LOOT_CATALOG.bosses.flatMap((boss) => boss.items);
@@ -90,6 +92,23 @@ describe("specialization-aware heirlooms", () => {
expect(combatStats.agility).toBeGreaterThan(0);
});
it.each([
"priest:discipline",
"hunter:marksmanship",
"mage:fire",
"rogue:assassination",
])("turns %s heirloom crit and haste ratings into combat percentages", (specializationId) => {
const profile = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === specializationId)!;
const gear = equipmentStatsForItems(resolvedEquipmentItems({}, [], context(profile, 80)));
const geared = deriveCharacterStats(profile.classId, 80, gear);
const ungeared = deriveCharacterStats(profile.classId, 80, EMPTY_ITEM_STATS);
expect(gear.criticalStrikeRating).toBeGreaterThan(0);
expect(gear.hasteRating).toBeGreaterThan(0);
expect(Math.max(geared.meleeCritPercent, geared.rangedCritPercent, geared.spellCritPercent))
.toBeGreaterThan(Math.max(ungeared.meleeCritPercent, ungeared.rangedCritPercent, ungeared.spellCritPercent));
expect(Math.max(geared.meleeHastePercent, geared.rangedHastePercent, geared.spellHastePercent)).toBeGreaterThan(0);
});
it.each([1, 40, 79])("keeps compatible same-level boss items below heirlooms at level %i", (level) => {
for (const profile of SPECIALIZATION_DEFINITIONS) {
const loadout = resolvedEquipmentLoadout({}, [], context(profile, level));
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
import { rotationProfileFor } from "./combatRotation";
import { READY_COMPANION_SPECIALIZATION_IDS } from "./companionSpecReadiness";
import { createDungeonParty } from "./partyStore";
import { runtimeNormalizeTalentRanks } from "./talentRuntimeCatalog";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { specializationById } from "./specializationCatalog";
const READY_CASES = [
["warrior:protection", "warrior", "Protection"],
["priest:discipline", "priest", "Discipline"],
["hunter:marksmanship", "hunter", "Marksmanship"],
["mage:fire", "mage", "Fire"],
["rogue:assassination", "rogue", "Assassination"],
] as const;
describe("Ready-five certification scope", () => {
it("keeps the canonical benchmark default limited to the five Ready specs", async () => {
const builds = await loadCanonicalBenchmarkBuilds();
expect(builds.map((build) => `${build.classId}:${build.orientation.toLowerCase()}`))
.toEqual(expect.arrayContaining([...READY_COMPANION_SPECIALIZATION_IDS]));
expect(builds).toHaveLength(READY_COMPANION_SPECIALIZATION_IDS.length);
});
it("has legal level-bracket talents and an executable rotation for every Ready spec", () => {
for (const [specializationId, classId, specialization] of READY_CASES) {
expect(specializationById(specializationId)).not.toBeNull();
expect(rotationProfileFor(classId, specializationId, specialization)?.abilityPriority.length ?? 0)
.toBeGreaterThan(0);
for (const level of [1, 10, 20, 30, 40, 50, 60, 70, 80]) {
const ranks = runtimePartyTalentRanksForLevel(classId, specialization, level);
expect(runtimeNormalizeTalentRanks(classId, level, ranks)).toEqual(ranks);
}
}
});
it("draws random dungeon companions only from the Ready set", () => {
const ready = new Set<string>(READY_COMPANION_SPECIALIZATION_IDS);
for (const playerRole of ["tank", "healer", "damage"] as const) {
for (let seed = 0; seed < 12; seed += 1) {
const party = createDungeonParty(`ready-five-${playerRole}-${seed}`, 80, playerRole);
expect(party.every((member) => ready.has(member.specializationId ?? ""))).toBe(true);
}
}
});
});
+14
View File
@@ -74,4 +74,18 @@ describe("talent runtime modifiers", () => {
expect(applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName: "Arm of Thorim" })).toBe(100);
expect(applyTalentModifierValue(2, modifiers, "critical-damage-percent", { abilityName: "Arm of Thorim" })).toBe(5);
});
it("does not turn Inner Focus activation text into permanent spell modifiers", () => {
const innerFocus = TALENT_NODES.find((node) => node.name === "Inner Focus")!;
const modifiers = talentModifiersForRanks({ [innerFocus.id]: 1 }, "priest");
expect(modifiers).toEqual([]);
expect(applyTalentModifierValue(2_500, modifiers, "cast-time-percent", {
abilityName: "Greater Heal",
tags: ["cast-time", "holy"],
})).toBe(2_500);
expect(applyTalentModifierValue(20, modifiers, "resource-cost-percent", {
abilityName: "Flash Heal",
tags: ["cast-time", "holy"],
})).toBe(20);
});
});
+36 -2
View File
@@ -3,6 +3,7 @@ import {
runtimeActiveTalentRankEffects as activeTalentRankEffects,
type RuntimeTalentRanks as TalentRanks,
} from "./talentRuntimeCatalog";
import { talentBalanceOverride } from "./balanceTuning";
export type TalentModifierKind =
| "damage-percent"
@@ -40,6 +41,19 @@ export interface TalentModifier {
interface ParsedModifier extends Omit<TalentModifier, "sourceTalentId" | "sourceTalentName" | "rank" | "description"> {}
const EXPLICIT_ACTIVATION_TALENTS = new Set([
"Inner Focus",
]);
/**
* These talents are modeled as on-use abilities and must never be interpreted
* as always-on scalar bonuses merely because their activation tooltip contains
* phrases such as "reduces cast time" or "reducing mana cost".
*/
export function talentUsesExplicitActivation(talentName: string): boolean {
return EXPLICIT_ACTIVATION_TALENTS.has(talentName);
}
/**
* Converts the concrete client tooltip into common simulation modifiers.
* Exact DBC effects remain available through the full authoring catalog for
@@ -128,13 +142,33 @@ export function parseTalentModifiers(description: string): readonly ParsedModifi
}
export function talentModifiersForRanks(ranks: TalentRanks, classId?: ClassId): readonly TalentModifier[] {
return activeTalentRankEffects(ranks, classId).flatMap((active) => parseTalentModifiers(active.description).map((modifier) => ({
return activeTalentRankEffects(ranks, classId).flatMap((active) => {
if (talentUsesExplicitActivation(active.talentName)) return [];
const override = talentBalanceOverride(active.talentId);
const scale = (override?.effectScalePercent ?? 100) / 100;
const parsed = parseTalentModifiers(active.description).map((modifier) => ({
...modifier,
amount: modifier.amount * scale,
sourceTalentId: active.talentId,
sourceTalentName: active.talentName,
rank: active.rank,
description: active.description,
})));
}));
const supplemental = Object.entries(override?.modifiersPerRank ?? {}).flatMap(([kind, amount]) => {
if (!Number.isFinite(amount) || amount === 0) return [];
return [{
kind: kind as TalentModifierKind,
amount: amount * active.rank,
unit: "percent" as const,
scope: `Balance tuning supplement for ${active.talentName}`,
sourceTalentId: active.talentId,
sourceTalentName: active.talentName,
rank: active.rank,
description: active.description,
}];
});
return [...parsed, ...supplemental];
});
}
export interface TalentModifierContext {
+3 -1
View File
@@ -8,7 +8,7 @@ import generated from "./generated/talentRuntimeCatalog.json";
export type RuntimeTalentRanks = Readonly<Record<string, number>>;
interface RuntimeTalentTree {
export interface RuntimeTalentTree {
readonly id: string;
readonly classId: ClassId;
}
@@ -43,6 +43,8 @@ interface RuntimeTalentSnapshot {
}
const snapshot = generated as unknown as RuntimeTalentSnapshot;
export const RUNTIME_TALENT_TREES: readonly RuntimeTalentTree[] = snapshot.trees;
export const RUNTIME_TALENT_NODES: readonly RuntimeTalentNode[] = snapshot.nodes;
const treeById = Object.freeze(Object.fromEntries(snapshot.trees.map((tree) => [tree.id, tree]))) as Readonly<Record<string, RuntimeTalentTree>>;
const nodeById = Object.freeze(Object.fromEntries(snapshot.nodes.map((node) => [node.id, node]))) as Readonly<Record<string, RuntimeTalentNode>>;
const nodesByTree = Object.freeze(Object.fromEntries(snapshot.trees.map((tree) => [
+1 -1
View File
@@ -11,7 +11,7 @@ export const HUD_ELEMENTS = {
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
manastorm: "Manastorm status", completion: "Dungeon completion", assets: "Asset notice",
objective: "Manastorm objective", messages: "Manastorm messages", cache: "Reward cache", feedback: "Action feedback",
objective: "Manastorm objective", messages: "Manastorm messages", cache: "Reward cache",
"bar-1": "Action bar 1", "bar-2": "Action bar 2", "bar-3": "Action bar 3",
"bar-4": "Action bar 4", "bar-5": "Action bar 5",
} as const;
+84
View File
@@ -753,6 +753,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.party-frames > header b { color: #dfce8a; }
.party-frames > header b.is-warning { color: #e9a36f; }
.party-frame {
position: relative;
display: grid;
width: 100%;
min-height: 39px;
@@ -803,6 +804,15 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.party-frame__resource--runic-power i { background: linear-gradient(90deg, #327784, #62c5ce); }
.party-frame__resource--focus i { background: linear-gradient(90deg, #4b7d6b, #71c8a7); }
.party-frame__dispel { justify-self: end; margin-top: -2px; padding: 1px 3px; border: 1px solid rgba(var(--dispel-rgb),.78); border-radius: 3px; color: #9dffa7; font-size: 5px; letter-spacing: .08em; box-shadow: 0 0 5px rgba(var(--dispel-rgb),.35); }
.party-frame__heal-popups { position: absolute; z-index: 4; inset: 0; overflow: visible; pointer-events: none; }
.party-frame__heal-popup { position: absolute; top: 45%; right: 10px; color: #72f59c; font: 800 12px Georgia, serif; letter-spacing: .01em; text-shadow: 0 1px 2px #001d09, 0 0 6px rgba(65,220,112,.78); opacity: 0; animation: party-frame-heal-float 1.5s ease-out forwards; }
.party-frame__heal-popup.is-critical { color: #b0ffc7; font-size: 14px; text-shadow: 0 1px 2px #001d09, 0 0 9px rgba(92,255,143,.95); }
@keyframes party-frame-heal-float {
0% { opacity: 0; transform: translate(var(--party-heal-x, 0), 5px) scale(.82); }
14% { opacity: 1; transform: translate(var(--party-heal-x, 0), 0) scale(1); }
72% { opacity: 1; }
100% { opacity: 0; transform: translate(var(--party-heal-x, 0), -27px) scale(1.05); }
}
.summon-frames { position: absolute; top: max(101px, calc(env(safe-area-inset-top) + 89px)); left: max(244px, calc(env(safe-area-inset-left) + 232px)); display: grid; width: 150px; gap: 3px; scale: var(--game-ui-scale, 1); transform-origin: top left; }
.summon-frame { width: 100%; padding: 4px 6px; border: 1px solid rgba(115,183,205,.28); border-radius: 5px; background: rgba(5,16,19,.88); box-shadow: 0 5px 14px rgba(0,0,0,.3); color: inherit; text-align: left; cursor: pointer; }
.summon-frame.is-selected { border-color: rgba(117,225,181,.82); box-shadow: 0 0 0 1px rgba(117,225,181,.28), 0 5px 14px rgba(0,0,0,.3); }
@@ -1676,6 +1686,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
@media (prefers-reduced-motion: reduce) {
.boss-loot-window { animation: none; }
.party-frame__heal-popup { animation: none; opacity: 1; }
.spellbook-panel__filter > span::after { transition: none; }
}
@@ -1952,6 +1963,79 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
.companion-readiness__class > div { grid-template-columns: 1fr; }
.companion-readiness__actions { flex-wrap: wrap; }
}
.balance-tuning { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; gap: 12px; }
.balance-tuning__header { display: flex; align-items: center; gap: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--front-line); }
.balance-tuning__header > div:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; }
.balance-tuning__header > div:nth-child(2) span, .balance-tuning__details header span { color: var(--front-dim); font-size: 8px; letter-spacing: .13em; text-transform: uppercase; }
.balance-tuning__header > div:nth-child(2) strong { font: 20px Georgia, serif; }
.balance-tuning__header-actions { display: flex; align-items: center; gap: 10px; }
.balance-tuning__header-actions > span { color: var(--front-gold); font-size: 8px; text-transform: uppercase; }
.balance-tuning__intro { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.balance-tuning__intro p:last-child { max-width: 820px; margin: 5px 0 0; color: var(--front-dim); font-size: 10px; line-height: 1.45; }
.balance-tuning__tabs { display: flex; overflow: hidden; border: 1px solid var(--front-line); border-radius: 8px; }
.balance-tuning__tabs button { min-width: 94px; min-height: 36px; border: 0; border-right: 1px solid var(--front-line); background: #0a1812; color: var(--front-dim); cursor: pointer; font: 10px Georgia, serif; }
.balance-tuning__tabs button:last-child { border-right: 0; }
.balance-tuning__tabs button[aria-selected="true"] { background: linear-gradient(135deg, rgba(89,119,71,.65), rgba(54,83,53,.65)); color: var(--front-cream); }
.balance-tuning__filters { display: grid; grid-template-columns: minmax(200px, 1fr) 210px; gap: 8px; }
.balance-tuning__filters label { display: grid; gap: 3px; color: var(--front-dim); font-size: 8px; text-transform: uppercase; }
.balance-tuning input, .balance-tuning select { min-height: 34px; padding: 6px 8px; border: 1px solid var(--front-line); border-radius: 6px; background: #08140f; color: var(--front-cream); }
.balance-tuning__workspace { display: grid; min-height: 0; grid-template-columns: minmax(230px, 31%) minmax(0, 1fr); overflow: hidden; border: 1px solid var(--front-line); border-radius: 10px; background: linear-gradient(145deg, rgba(8,22,16,.9), rgba(5,14,11,.93)); }
.balance-tuning__catalog { min-height: 0; padding: 8px; overflow: auto; border-right: 1px solid var(--front-line); scrollbar-gutter: stable; }
.balance-tuning__catalog > button { display: flex; width: 100%; min-height: 48px; align-items: center; justify-content: space-between; gap: 8px; padding: 7px 9px; border: 0; border-bottom: 1px solid rgba(204,224,180,.08); background: transparent; color: var(--front-cream); cursor: pointer; text-align: left; }
.balance-tuning__catalog > button:hover, .balance-tuning__catalog > button[aria-current="true"] { background: rgba(168,198,143,.09); }
.balance-tuning__catalog > button[aria-current="true"] { box-shadow: inset 2px 0 var(--front-gold); }
.balance-tuning__catalog > button > span { display: block; min-width: 0; padding-left: 8px; border-left: 2px solid var(--front-line); }
.balance-tuning__catalog strong, .balance-tuning__catalog small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.balance-tuning__catalog strong { font-size: 10px; }
.balance-tuning__catalog small { margin-top: 3px; color: var(--front-dim); font-size: 7px; text-transform: capitalize; }
.balance-tuning__catalog em { padding: 3px 5px; border: 1px solid rgba(213,177,94,.4); border-radius: 999px; color: #e7c879; font-size: 7px; font-style: normal; text-transform: uppercase; }
.balance-tuning__catalog > p { padding: 14px; color: var(--front-dim); font-size: 9px; }
.balance-tuning__details { min-height: 0; padding: 18px 20px; overflow: auto; scrollbar-gutter: stable; }
.balance-tuning__details > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.balance-tuning__details h1 { margin: 3px 0 0; color: var(--front-cream); font: 24px Georgia, serif; }
.balance-tuning__details > header button { min-height: 30px; padding: 5px 9px; border: 1px solid var(--front-line); border-radius: 6px; background: rgba(255,255,255,.03); color: var(--front-dim); cursor: pointer; font-size: 8px; }
.balance-tuning__details > header button:disabled { cursor: not-allowed; opacity: .4; }
.balance-tuning__description { margin: 12px 0; color: var(--front-dim); font-size: 9px; line-height: 1.5; }
.balance-tuning__source-strip { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.balance-tuning__source-strip span { padding: 5px 7px; border: 1px solid rgba(204,224,180,.12); border-radius: 6px; color: var(--front-dim); font-size: 8px; }
.balance-tuning__source-strip b { color: var(--front-cream); font-weight: 600; }
.balance-tuning__field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.balance-tuning__field-grid label, .balance-tuning__scale-field { display: flex; min-height: 62px; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 10px; border: 1px solid rgba(204,224,180,.12); border-radius: 8px; background: rgba(255,255,255,.025); }
.balance-tuning__field-grid label > span:first-child, .balance-tuning__scale-field > span:first-child { min-width: 0; }
.balance-tuning__field-grid strong, .balance-tuning__field-grid small, .balance-tuning__scale-field strong, .balance-tuning__scale-field small { display: block; }
.balance-tuning__field-grid strong, .balance-tuning__scale-field strong { font-size: 9px; }
.balance-tuning__field-grid small, .balance-tuning__scale-field small { margin-top: 3px; color: var(--front-dim); font-size: 7px; line-height: 1.35; }
.balance-tuning__field-grid label > span:last-child, .balance-tuning__scale-field > span:last-child { display: flex; align-items: center; gap: 4px; }
.balance-tuning__field-grid input, .balance-tuning__scale-field input { width: 78px; text-align: right; }
.balance-tuning__field-grid em, .balance-tuning__scale-field em { color: var(--front-gold); font-size: 9px; font-style: normal; }
.balance-tuning__callout { padding: 8px 10px; border-left: 2px solid var(--front-gold); background: rgba(185,139,57,.08); color: #d8c994; font-size: 9px; line-height: 1.4; }
.balance-tuning__talent-modifiers { margin-top: 12px; padding: 11px; border: 1px solid rgba(204,224,180,.12); border-radius: 8px; }
.balance-tuning__talent-modifiers > header strong, .balance-tuning__talent-modifiers > header small { display: block; }
.balance-tuning__talent-modifiers > header strong { font-size: 10px; }
.balance-tuning__talent-modifiers > header small { margin-top: 3px; color: var(--front-dim); font-size: 8px; }
.balance-tuning__talent-modifiers > div { display: grid; grid-template-columns: minmax(160px, 1fr) 100px auto; gap: 7px; align-items: center; margin-top: 9px; }
.balance-tuning__talent-modifiers > div > span { color: var(--front-gold); font-size: 8px; }
.balance-tuning__talent-modifiers ul { display: flex; flex-wrap: wrap; gap: 5px; margin: 10px 0 0; padding: 0; list-style: none; }
.balance-tuning__talent-modifiers li { display: flex; align-items: center; gap: 6px; padding: 4px 6px; border: 1px solid rgba(213,177,94,.28); border-radius: 999px; color: var(--front-dim); font-size: 8px; }
.balance-tuning__talent-modifiers li b { color: #e7c879; }
.balance-tuning__talent-modifiers li button { width: 18px; height: 18px; padding: 0; border: 0; border-radius: 50%; background: rgba(255,255,255,.07); color: var(--front-cream); cursor: pointer; }
.balance-tuning__actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding-top: 9px; border-top: 1px solid var(--front-line); }
.balance-tuning__actions > span { margin-right: auto; color: var(--front-dim); font-size: 9px; }
@media (max-width: 760px) {
.balance-tuning__header { align-items: flex-start; flex-wrap: wrap; }
.balance-tuning__header-actions { margin-left: auto; }
.balance-tuning__intro { align-items: stretch; flex-direction: column; }
.balance-tuning__tabs button { flex: 1; }
.balance-tuning__filters { grid-template-columns: 1fr; }
.balance-tuning__workspace { grid-template-columns: 1fr; overflow: auto; }
.balance-tuning__catalog { max-height: 190px; border-right: 0; border-bottom: 1px solid var(--front-line); }
.balance-tuning__details { overflow: visible; }
.balance-tuning__field-grid { grid-template-columns: 1fr; }
.balance-tuning__talent-modifiers > div { grid-template-columns: 1fr 90px; }
.balance-tuning__talent-modifiers > div > span { grid-column: 1 / -1; }
.balance-tuning__actions { flex-wrap: wrap; }
.balance-tuning__actions > span { width: 100%; }
}
.text-action { border: 0; background: transparent; color: var(--front-green-bright); cursor: pointer; font-size: 10px; text-decoration: underline; text-underline-offset: 3px; }
.text-action--danger { color: #cf8176; }
.front-notice {
+24 -10
View File
@@ -31,6 +31,7 @@ import { BrandMark, FrontSurface } from "./FrontSurface";
import { CompanionPoolControls } from "./CompanionPoolControls";
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
import { useCompanionPartyPreferences } from "../game/companionPartyPreferences";
import type { CompanionSpecReadinessPolicy } from "../game/companionSpecReadiness";
import { useCompanionSpecReadinessStore } from "../game/companionSpecReadinessStore";
type BossScope = BalanceLabBossCategory | "specific";
@@ -93,6 +94,17 @@ function buildIdentity(build: SelectableBenchmarkBuildCatalogEntry): string {
return `${build.source}:${build.classId}:${build.role}:${build.orientation}:${build.talentBuildId ?? "default"}`;
}
function readinessEligible(
build: SelectableBenchmarkBuildCatalogEntry,
policy: CompanionSpecReadinessPolicy,
includeBetaSpecs: boolean,
): boolean {
const status = policy.statuses[build.specializationId ?? ""]
?? build.readinessStatus
?? "needs-testing";
return status === "ready" || (includeBetaSpecs && status === "beta");
}
function randomPartyCapacity(builds: readonly SelectableBenchmarkBuildCatalogEntry[]): number {
const defaults = builds.filter((build) => build.defaultTalentBuild);
const pool = defaults.length > 0 ? defaults : builds;
@@ -123,7 +135,7 @@ export function BalanceLabScreen({
const session = useShellStore((state) => state.session);
const local = executionTarget === "local";
const token = local ? "" : session?.accessToken ?? "";
const [source, setSource] = useState<BalanceLabPartySource>("all");
const [source, setSource] = useState<BalanceLabPartySource>("wow");
const [specSelection, setSpecSelection] = useState<BalanceLabSpecSelection>("random");
const [specSearch, setSpecSearch] = useState("");
const [selectedBuildIds, setSelectedBuildIds] = useState<readonly string[]>([]);
@@ -175,11 +187,9 @@ export function BalanceLabScreen({
|| left.role.localeCompare(right.role)
)), [catalogLevel, source]);
const selectedBuildSet = useMemo(() => new Set(selectedBuildIds), [selectedBuildIds]);
const automaticBuilds = useMemo(() => availableBuilds.filter((build) => {
if (build.source !== "wow" || !build.specializationId) return true;
const status = readinessPolicy.statuses[build.specializationId] ?? "needs-testing";
return status === "ready" || (includeBetaSpecs && status === "beta");
}), [availableBuilds, includeBetaSpecs, readinessPolicy]);
const automaticBuilds = useMemo(() => availableBuilds.filter((build) => (
readinessEligible(build, readinessPolicy, includeBetaSpecs)
)), [availableBuilds, includeBetaSpecs, readinessPolicy]);
const selectedBuilds = useMemo(() => availableBuilds.filter((build) => selectedBuildSet.has(build.id)), [
availableBuilds,
selectedBuildSet,
@@ -198,7 +208,8 @@ export function BalanceLabScreen({
? selectedBossKeys.length
: BOSS_TESTING_CATALOG.filter((boss) => bossScope === "all" || boss.category === bossScope).length;
const coverageBuilds = specSelection === "all" ? automaticBuilds : selectedBuilds;
const requestedRandomPartyCount = partyConfigurations * (source === "all" ? 3 : 1);
const eligibleSourceCount = new Set(automaticBuilds.map((build) => build.source)).size;
const requestedRandomPartyCount = partyConfigurations * eligibleSourceCount;
const partyCount = specSelection === "random"
? cappedRandomPartyCount(automaticBuilds, partyConfigurations)
: coveragePartyCount(coverageBuilds);
@@ -310,7 +321,9 @@ export function BalanceLabScreen({
.filter((build) => source === "all" || build.source === source);
const chosenBuilds = specSelection === "specific"
? levelBuilds.filter((build) => selectedIdentities.has(buildIdentity(build)))
: specSelection === "all" ? levelBuilds : [];
: specSelection === "all"
? levelBuilds.filter((build) => readinessEligible(build, readinessPolicy, includeBetaSpecs))
: [];
if (specSelection === "specific" && chosenBuilds.length !== selectedIdentities.size) {
throw new Error(`One or more selected class/spec builds are unavailable at level ${targetLevel}.`);
}
@@ -393,7 +406,7 @@ export function BalanceLabScreen({
setSelectedBuildIds([]);
setSpecSearch("");
}}>
<option value="all">WoW + CoA + RoM</option><option value="wow">WoW only</option><option value="coa">CoA only</option><option value="rom">RoM only</option>
<option value="all">All sources (Ready only)</option><option value="wow">WoW only</option><option value="coa">CoA only (Needs Testing)</option><option value="rom">RoM only (Needs Testing)</option>
</select></label>
<label><span>Level coverage</span><select value={levelCoverage} onChange={(event) => {
setLevelCoverage(event.currentTarget.value as LevelCoverage);
@@ -409,7 +422,7 @@ export function BalanceLabScreen({
setSelectedBuildIds([]);
}}><option value={10}>Every 10 levels</option><option value={5}>Every 5 levels</option><option value={1}>Every 1 level</option></select></label>}
<label className="balance-lab-form-grid__wide"><span>Class/spec selection</span><select value={specSelection} onChange={(event) => setSpecSelection(event.currentTarget.value as BalanceLabSpecSelection)}>
<option value="random">Random parties</option><option value="all">All specs</option><option value="specific">Specific specs</option>
<option value="random">Random Ready parties</option><option value="all">All Ready specs</option><option value="specific">Specific targeted specs</option>
</select></label>
{specSelection === "random"
? <label><span>Parties per source</span><input type="number" min={1} max={100} value={partyConfigurations} onChange={(event) => setPartyConfigurations(event.currentTarget.valueAsNumber)} /></label>
@@ -422,6 +435,7 @@ export function BalanceLabScreen({
<option value="all">Every boss</option><option value="wow">Every WoW boss</option><option value="rom">Every RoM boss</option><option value="specific">Pick specific bosses</option>
</select></label>
</div>
<p className="balance-lab-coverage-note"><strong>Certification scope: Ready only.</strong> Automatic, all-spec, bracket, regression, and seat-audit runs currently use Protection Warrior, Discipline Priest, Marksmanship Hunter, Fire Mage, and Assassination Rogue. CoA and RoM remain Needs Testing.</p>
{levelCoverage === "brackets" && <p className="balance-lab-coverage-note">
{targetLevels.length} separate level jobs: {bracketStep === 1 ? "levels 1 through 80" : targetLevels.join(", ")}. Up to {runtimeStatus?.maxConcurrent ?? 4} run at the same time; each keeps its own report.
</p>}
+16
View File
@@ -0,0 +1,16 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { BalanceTuningScreen } from "./BalanceTuningScreen";
describe("Ability and Talent Tuning screen", () => {
it("renders the searchable local GM catalogs and apply controls", () => {
const html = renderToStaticMarkup(createElement(BalanceTuningScreen, { onClose: () => undefined }));
expect(html).toContain("Ability &amp; Talent Tuning");
expect(html).toContain("Runtime overrides");
expect(html).toContain("Abilities");
expect(html).toContain("Talents");
expect(html).toContain("Apply locally");
expect(html).toContain("Reset all");
});
});
+277
View File
@@ -0,0 +1,277 @@
import { useEffect, useMemo, useState } from "react";
import { CLASSES, classById, type ClassId } from "../app/characterCatalog";
import {
refreshAbilityBalanceTuning,
sourceAbilitiesForClass,
type AbilityDefinition,
} from "../game/abilityCatalog";
import {
currentBalanceTuningPolicy,
normalizeBalanceTuningPolicy,
saveBalanceTuningPolicy,
TALENT_BALANCE_MODIFIER_KINDS,
type AbilityBalanceOverride,
type BalanceTuningPolicy,
type TalentBalanceModifierKind,
} from "../game/balanceTuning";
import { talentUsesExplicitActivation } from "../game/talentModifiers";
import {
RUNTIME_TALENT_NODES,
RUNTIME_TALENT_TREES,
type RuntimeTalentNode,
} from "../game/talentRuntimeCatalog";
import { BrandMark, FrontSurface } from "./FrontSurface";
type TuningTab = "abilities" | "talents";
const ABILITY_FIELDS: readonly {
readonly key: keyof AbilityBalanceOverride;
readonly label: string;
readonly description: string;
}[] = [
{ key: "cooldownPercent", label: "Cooldown", description: "Recharge time" },
{ key: "castTimePercent", label: "Cast time", description: "Cast or channel time" },
{ key: "resourceCostPercent", label: "Resource cost", description: "Mana, Rage, Energy, and other costs" },
{ key: "damagePercent", label: "Damage", description: "Direct, periodic, finisher, and summon damage" },
{ key: "healingPercent", label: "Healing / absorbs", description: "Direct, periodic, and shield amounts" },
{ key: "durationPercent", label: "Duration", description: "Aura, control, summon, and periodic duration" },
{ key: "radiusPercent", label: "Radius / reach", description: "Area radius, jumps, and ground reach" },
];
const TALENT_KIND_LABELS: Readonly<Record<TalentBalanceModifierKind, string>> = {
"damage-percent": "Damage done",
"critical-damage-percent": "Critical damage",
"healing-percent": "Healing done",
"damage-taken-percent": "Damage taken",
"max-health-percent": "Maximum health",
"max-resource-percent": "Maximum resource",
"resource-regeneration-percent": "Resource regeneration",
"resource-cost-percent": "Resource cost",
"critical-chance-percent": "Critical chance",
"haste-percent": "Haste",
"cast-time-percent": "Cast time",
"cooldown-percent": "Cooldown",
"armor-percent": "Armor",
"attribute-percent": "Attributes",
"range-percent": "Range",
"duration-percent": "Duration",
"threat-percent": "Threat",
};
function seconds(milliseconds: number): string {
return milliseconds <= 0 ? "None" : `${Math.round(milliseconds / 100) / 10}s`;
}
function classIdForTalent(node: RuntimeTalentNode): ClassId {
return RUNTIME_TALENT_TREES.find((tree) => tree.id === node.treeId)!.classId;
}
function talentDescription(node: RuntimeTalentNode): string {
return node.rankDescriptions.at(-1) ?? node.name;
}
function withoutKey<T>(record: Readonly<Record<string, T>>, key: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([candidate]) => candidate !== key));
}
export function BalanceTuningScreen({ onClose }: { readonly onClose: () => void }) {
const [tab, setTab] = useState<TuningTab>("abilities");
const [query, setQuery] = useState("");
const [classFilter, setClassFilter] = useState("all");
const [draft, setDraft] = useState<BalanceTuningPolicy>(() => currentBalanceTuningPolicy());
const [published, setPublished] = useState<BalanceTuningPolicy>(() => currentBalanceTuningPolicy());
const [selectedAbilityId, setSelectedAbilityId] = useState("");
const [selectedTalentId, setSelectedTalentId] = useState("");
const [talentKind, setTalentKind] = useState<TalentBalanceModifierKind>("damage-percent");
const [notice, setNotice] = useState("");
const abilities = useMemo(() => CLASSES.flatMap((definition) => (
sourceAbilitiesForClass(definition.id)
)).sort((left, right) => classById(left.classId).name.localeCompare(classById(right.classId).name)
|| left.name.localeCompare(right.name)), []);
const talents = useMemo(() => [...RUNTIME_TALENT_NODES].sort((left, right) => {
const leftClass = classIdForTalent(left);
const rightClass = classIdForTalent(right);
return classById(leftClass).name.localeCompare(classById(rightClass).name)
|| left.treeId.localeCompare(right.treeId)
|| left.row - right.row
|| left.index - right.index;
}), []);
const normalizedQuery = query.trim().toLowerCase();
const visibleAbilities = useMemo(() => abilities.filter((ability) => (
(classFilter === "all" || ability.classId === classFilter)
&& (!normalizedQuery || `${classById(ability.classId).name} ${ability.name} ${ability.description}`.toLowerCase().includes(normalizedQuery))
)), [abilities, classFilter, normalizedQuery]);
const visibleTalents = useMemo(() => talents.filter((talent) => {
const classId = classIdForTalent(talent);
return (classFilter === "all" || classId === classFilter)
&& (!normalizedQuery || `${classById(classId).name} ${talent.name} ${talent.treeId} ${talentDescription(talent)}`.toLowerCase().includes(normalizedQuery));
}), [classFilter, normalizedQuery, talents]);
const classOptions = useMemo(() => {
const ids = new Set((tab === "abilities" ? abilities.map((item) => item.classId) : talents.map(classIdForTalent)));
return CLASSES.filter((definition) => ids.has(definition.id));
}, [abilities, tab, talents]);
useEffect(() => {
if (tab === "abilities" && !visibleAbilities.some((ability) => ability.id === selectedAbilityId)) {
setSelectedAbilityId(visibleAbilities[0]?.id ?? "");
}
if (tab === "talents" && !visibleTalents.some((talent) => talent.id === selectedTalentId)) {
setSelectedTalentId(visibleTalents[0]?.id ?? "");
}
}, [selectedAbilityId, selectedTalentId, tab, visibleAbilities, visibleTalents]);
const selectedAbility = abilities.find((ability) => ability.id === selectedAbilityId) ?? null;
const selectedTalent = talents.find((talent) => talent.id === selectedTalentId) ?? null;
const dirty = JSON.stringify(draft) !== JSON.stringify(published);
const tunedCount = Object.keys(draft.abilityOverrides).length + Object.keys(draft.talentOverrides).length;
const updateAbility = (ability: AbilityDefinition, key: keyof AbilityBalanceOverride, value: number) => {
setDraft((current) => normalizeBalanceTuningPolicy({
...current,
abilityOverrides: {
...current.abilityOverrides,
[ability.id]: { ...current.abilityOverrides[ability.id], [key]: value },
},
}));
setNotice("");
};
const updateTalentScale = (talent: RuntimeTalentNode, value: number) => {
setDraft((current) => normalizeBalanceTuningPolicy({
...current,
talentOverrides: {
...current.talentOverrides,
[talent.id]: { ...current.talentOverrides[talent.id], effectScalePercent: value },
},
}));
setNotice("");
};
const updateTalentModifier = (talent: RuntimeTalentNode, kind: TalentBalanceModifierKind, value: number) => {
setDraft((current) => normalizeBalanceTuningPolicy({
...current,
talentOverrides: {
...current.talentOverrides,
[talent.id]: {
...current.talentOverrides[talent.id],
modifiersPerRank: { ...current.talentOverrides[talent.id]?.modifiersPerRank, [kind]: value },
},
},
}));
setNotice("");
};
const resetSelected = () => {
setDraft((current) => normalizeBalanceTuningPolicy(tab === "abilities" ? {
...current,
abilityOverrides: withoutKey(current.abilityOverrides, selectedAbilityId),
} : {
...current,
talentOverrides: withoutKey(current.talentOverrides, selectedTalentId),
}));
setNotice("");
};
const apply = () => {
const saved = saveBalanceTuningPolicy(draft);
refreshAbilityBalanceTuning();
setDraft(saved);
setPublished(saved);
setNotice("Local tuning is active. New fights and client-side test runs will use it.");
};
const resetAll = () => {
setDraft(normalizeBalanceTuningPolicy({}));
setNotice("");
};
return (
<FrontSurface className="balance-tuning" ariaLabel="Ability and talent balance tuning">
<header className="balance-tuning__header">
<BrandMark compact />
<div><span>Local GM sandbox</span><strong>Ability &amp; Talent Tuning</strong></div>
<div className="balance-tuning__header-actions">
<span>{tunedCount} tuned entries</span>
<button className="front-button front-button--quiet" type="button" onClick={onClose}>Back</button>
</div>
</header>
<section className="balance-tuning__intro">
<div>
<p className="front-kicker">Runtime overrides</p>
<p>Values are percentages of source data: 100 keeps the original, 50 halves it, and 200 doubles it. Changes are saved on this device and affect players, NPC companions, and client-side test runs after you apply them.</p>
</div>
<div className="balance-tuning__tabs" role="tablist" aria-label="Tuning catalog">
<button type="button" role="tab" aria-selected={tab === "abilities"} onClick={() => { setTab("abilities"); setClassFilter("all"); }}>Abilities</button>
<button type="button" role="tab" aria-selected={tab === "talents"} onClick={() => { setTab("talents"); setClassFilter("all"); }}>Talents</button>
</div>
</section>
<section className="balance-tuning__filters" aria-label="Tuning filters">
<label>Search<input type="search" value={query} onChange={(event) => setQuery(event.currentTarget.value)} placeholder={`Find ${tab === "abilities" ? "an ability" : "a talent"}`} /></label>
<label>Class<select value={classFilter} onChange={(event) => setClassFilter(event.currentTarget.value)}><option value="all">All classes</option>{classOptions.map((definition) => <option key={definition.id} value={definition.id}>{definition.name}</option>)}</select></label>
</section>
<main className="balance-tuning__workspace">
<nav className="balance-tuning__catalog" aria-label={`${tab} catalog`}>
{(tab === "abilities" ? visibleAbilities : visibleTalents).map((item) => {
const classId = tab === "abilities" ? (item as AbilityDefinition).classId : classIdForTalent(item as RuntimeTalentNode);
const selected = tab === "abilities" ? item.id === selectedAbilityId : item.id === selectedTalentId;
const overridden = tab === "abilities" ? Boolean(draft.abilityOverrides[item.id]) : Boolean(draft.talentOverrides[item.id]);
return <button key={item.id} type="button" aria-current={selected ? "true" : undefined} onClick={() => tab === "abilities" ? setSelectedAbilityId(item.id) : setSelectedTalentId(item.id)}>
<span style={{ borderColor: classById(classId).color }}><strong>{item.name}</strong><small>{classById(classId).name}{tab === "talents" ? ` · ${(item as RuntimeTalentNode).treeId.split("-").at(-1)}` : ""}</small></span>
{overridden && <em>Tuned</em>}
</button>;
})}
{!(tab === "abilities" ? visibleAbilities : visibleTalents).length && <p>No entries match these filters.</p>}
</nav>
<section className="balance-tuning__details">
{tab === "abilities" && selectedAbility && <>
<header>
<div><span>{classById(selectedAbility.classId).name} ability</span><h1>{selectedAbility.name}</h1></div>
<button type="button" disabled={!draft.abilityOverrides[selectedAbility.id]} onClick={resetSelected}>Reset ability</button>
</header>
<p className="balance-tuning__description">{selectedAbility.description}</p>
<div className="balance-tuning__source-strip">
<span>Cooldown <b>{seconds(selectedAbility.cooldownMs)}</b></span>
<span>Cast <b>{seconds(selectedAbility.castTimeMs)}</b></span>
<span>Cost <b>{selectedAbility.cost.amount} {selectedAbility.cost.resource}</b></span>
<span>Effects <b>{selectedAbility.effects.map((effect) => effect.kind).join(", ") || "utility"}</b></span>
</div>
<div className="balance-tuning__field-grid">
{ABILITY_FIELDS.map((field) => <label key={field.key}>
<span><strong>{field.label}</strong><small>{field.description}</small></span>
<span><input type="number" min="0" max="1000" step="5" value={draft.abilityOverrides[selectedAbility.id]?.[field.key] ?? 100} onChange={(event) => updateAbility(selectedAbility, field.key, Number(event.currentTarget.value))} /><em>%</em></span>
</label>)}
</div>
</>}
{tab === "talents" && selectedTalent && <>
<header>
<div><span>{classById(classIdForTalent(selectedTalent)).name} · {selectedTalent.treeId.split("-").at(-1)} tree</span><h1>{selectedTalent.name}</h1></div>
<button type="button" disabled={!draft.talentOverrides[selectedTalent.id]} onClick={resetSelected}>Reset talent</button>
</header>
<p className="balance-tuning__description">{talentDescription(selectedTalent)}</p>
{talentUsesExplicitActivation(selectedTalent.name) && <p className="balance-tuning__callout"><strong>On-use talent:</strong> its tooltip is not applied passively. Adjust its cooldown and aura duration from the Abilities tab.</p>}
<label className="balance-tuning__scale-field">
<span><strong>Supported passive effect scale</strong><small>Scales parsed damage, healing, haste, cooldown, duration, and similar passive values together.</small></span>
<span><input type="number" min="0" max="1000" step="5" disabled={talentUsesExplicitActivation(selectedTalent.name)} value={draft.talentOverrides[selectedTalent.id]?.effectScalePercent ?? 100} onChange={(event) => updateTalentScale(selectedTalent, Number(event.currentTarget.value))} /><em>%</em></span>
</label>
<section className="balance-tuning__talent-modifiers">
<header><div><strong>Supplemental modifier per rank</strong><small>Add a signed percentage-point adjustment. Negative cooldown or cast-time values make abilities faster.</small></div></header>
<div>
<select aria-label="Talent modifier type" value={talentKind} onChange={(event) => setTalentKind(event.currentTarget.value as TalentBalanceModifierKind)}>{TALENT_BALANCE_MODIFIER_KINDS.map((kind) => <option key={kind} value={kind}>{TALENT_KIND_LABELS[kind]}</option>)}</select>
<input aria-label="Talent modifier amount per rank" type="number" min="-1000" max="1000" step="1" value={draft.talentOverrides[selectedTalent.id]?.modifiersPerRank?.[talentKind] ?? 0} onChange={(event) => updateTalentModifier(selectedTalent, talentKind, Number(event.currentTarget.value))} />
<span>% per rank</span>
</div>
<ul>{Object.entries(draft.talentOverrides[selectedTalent.id]?.modifiersPerRank ?? {}).map(([kind, amount]) => <li key={kind}><span>{TALENT_KIND_LABELS[kind as TalentBalanceModifierKind]}</span><b>{Number(amount) > 0 ? "+" : ""}{amount}% / rank</b><button type="button" aria-label={`Remove ${TALENT_KIND_LABELS[kind as TalentBalanceModifierKind]}`} onClick={() => updateTalentModifier(selectedTalent, kind as TalentBalanceModifierKind, 0)}>×</button></li>)}</ul>
</section>
</>}
</section>
</main>
<footer className="balance-tuning__actions">
<span>{notice || (dirty ? "Unapplied local changes" : "Saved tuning is active")}</span>
<button className="front-button front-button--quiet" type="button" disabled={!Object.keys(draft.abilityOverrides).length && !Object.keys(draft.talentOverrides).length} onClick={resetAll}>Reset all</button>
<button className="front-button front-button--primary" type="button" disabled={!dirty} onClick={apply}>Apply locally</button>
</footer>
</FrontSurface>
);
}
+15 -10
View File
@@ -1,9 +1,10 @@
import { useMemo, useState } from "react";
import { classById } from "../app/characterCatalog";
import { classById, contentCategoryForClass } from "../app/characterCatalog";
import { sessionCanUseGmTools } from "../app/releaseFeatures";
import { useShellStore } from "../app/shellStore";
import {
WOW_COMPANION_SPECIALIZATIONS,
COMPANION_READINESS_SPECIALIZATIONS,
readinessStatusFor,
type CompanionSpecReadinessStatus,
} from "../game/companionSpecReadiness";
import { useCompanionSpecReadinessStore } from "../game/companionSpecReadinessStore";
@@ -35,18 +36,19 @@ export function CompanionReadinessScreen({ onClose }: { readonly onClose: () =>
const [status, setStatusFilter] = useState("all");
const active = editable ? draft : policy;
const rows = useMemo(() => WOW_COMPANION_SPECIALIZATIONS.filter((definition) => {
const rows = useMemo(() => COMPANION_READINESS_SPECIALIZATIONS.filter((definition) => {
const className = classById(definition.classId).name;
const search = `${className} ${definition.displayName}`.toLowerCase();
return (!query.trim() || search.includes(query.trim().toLowerCase()))
&& (role === "all" || definition.role === role)
&& (status === "all" || active.statuses[definition.id] === status);
&& (status === "all" || readinessStatusFor(definition.id, active) === status);
}), [active, query, role, status]);
const groups = useMemo(() => [...new Set(rows.map((definition) => definition.classId))].map((classId) => ({
classId,
definitions: rows.filter((definition) => definition.classId === classId),
})), [rows]);
const totals = Object.values(active.statuses).reduce<Record<CompanionSpecReadinessStatus, number>>((result, value) => {
const totals = COMPANION_READINESS_SPECIALIZATIONS.reduce<Record<CompanionSpecReadinessStatus, number>>((result, definition) => {
const value = readinessStatusFor(definition.id, active);
result[value] += 1;
return result;
}, { ready: 0, beta: 0, "needs-testing": 0 });
@@ -61,7 +63,7 @@ export function CompanionReadinessScreen({ onClose }: { readonly onClose: () =>
<section className="companion-readiness__intro">
<div>
<p className="front-kicker">Certification standard</p>
<p>Ready companions have a legal Playerbots talent build at every bracket, verified gear and resources, a complete priority rotation, role reactions, and proven offensive and defensive cooldown use without unexplained idle windows.</p>
<p>Ready companions have a legal Playerbots talent build at every bracket, verified gear and resources, a complete priority rotation, role reactions, and proven offensive and defensive cooldown use without unexplained idle windows. CoA and RoM entries remain Needs Testing and are excluded from automatic certification runs.</p>
</div>
<div className="companion-readiness__totals" aria-label="Readiness totals">
{(["ready", "beta", "needs-testing"] as const).map((value) => (
@@ -81,11 +83,14 @@ export function CompanionReadinessScreen({ onClose }: { readonly onClose: () =>
<h2 style={{ color: classById(classId).color }}>{classById(classId).name}</h2>
<div>
{definitions.map((definition) => {
const profile = runtimeDefaultPartyTalentProfile(definition.classId, definition.displayName);
const value = active.statuses[definition.id] ?? "needs-testing";
const source = contentCategoryForClass(definition.classId);
const profile = source === "wow"
? runtimeDefaultPartyTalentProfile(definition.classId, definition.displayName)
: null;
const value = readinessStatusFor(definition.id, active);
return <article key={definition.id} className="companion-readiness__spec">
<div><strong>{definition.displayName}</strong><small>{definition.role} · {profile.characterName} · {profile.playerbotsSpecName ?? profile.id}</small></div>
{editable ? <select aria-label={`${definition.displayName} readiness`} value={value} onChange={(event) => setStatus(definition.id, event.target.value as CompanionSpecReadinessStatus)}>
<div><strong>{definition.displayName}</strong><small>{source.toUpperCase()} · {definition.role}{profile ? ` · ${profile.characterName} · ${profile.playerbotsSpecName ?? profile.id}` : " · certification not started"}</small></div>
{editable && source === "wow" ? <select aria-label={`${definition.displayName} readiness`} value={value} onChange={(event) => setStatus(definition.id, event.target.value as CompanionSpecReadinessStatus)}>
<option value="ready">Ready</option><option value="beta">Beta</option><option value="needs-testing">Needs Testing</option>
</select> : <span className={`readiness-badge readiness-badge--${value}`}>{STATUS_LABELS[value]}</span>}
</article>;
+1 -1
View File
@@ -12,7 +12,7 @@ const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160], dps: [.13, .8, 230],
cast: [.5, .68, 330], actions: [.5, .86, 420], hints: [.5, .65, 420], experience: [.5, .99, 900],
reticle: [.5, .5, 20], loot: [.5, .3, 320], interaction: [.5, .6, 320], manastorm: [.5, .05, 400],
completion: [.5, .39, 360], assets: [.78, .6, 200], feedback: [.5, .71, 400],
completion: [.5, .39, 360], assets: [.78, .6, 200],
objective: [.5, .1, 400], messages: [.5, .22, 350], cache: [.78, .42, 200],
};
-3
View File
@@ -523,12 +523,9 @@ export function Hud({ surface = "top", onLayoutChange, completion, onFinish, onR
activeLayer={actionLayer}
onActivate={castBinding}
inputMode={surface === "bottom" ? "gamepad" : inputMode}
feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null}
/></EditableHudElement>}
</div>
<DesktopActionBars items={abilityItems} />
<EditableHudElement id="feedback">{desktopBars && settings.showFloatingCombatText && feedback?.message
? <div className="desktop-action-feedback" role="status">{feedback.message}</div> : null}</EditableHudElement>
<EditableHudElement id="experience"><ExperienceBar level={level} current={xp} required={xpToNext} /></EditableHudElement>
<EditableHudElement id="reticle">{cameraLookActive && <span className="reticle" aria-hidden="true" />}</EditableHudElement>
+12 -3
View File
@@ -15,6 +15,7 @@ import { MainMenuCharacterEditor, type MainMenuEditor } from "./MainMenuCharacte
const BossCardCollectionScreen = lazy(() => import("./BossCardCollection"));
const BossTestingScreen = lazy(() => import("./BossTestingScreen").then((module) => ({ default: module.BossTestingScreen })));
const BalanceLabScreen = lazy(() => import("./BalanceLabScreen").then((module) => ({ default: module.BalanceLabScreen })));
const BalanceTuningScreen = lazy(() => import("./BalanceTuningScreen").then((module) => ({ default: module.BalanceTuningScreen })));
const CompanionReadinessScreen = lazy(() => import("./CompanionReadinessScreen").then((module) => ({ default: module.CompanionReadinessScreen })));
const RandomizedBiteSizedScreen = lazy(() => import("./RandomizedBiteSizedScreen").then((module) => ({ default: module.RandomizedBiteSizedScreen })));
@@ -82,26 +83,29 @@ export function MainMenuScreen() {
const [cardsOpen, setCardsOpen] = useState(false);
const [bossTestingOpen, setBossTestingOpen] = useState(false);
const [balanceLabOpen, setBalanceLabOpen] = useState(false);
const [balanceTuningOpen, setBalanceTuningOpen] = useState(false);
const [readinessOpen, setReadinessOpen] = useState(false);
const [randomizedBiteSizedOpen, setRandomizedBiteSizedOpen] = useState(false);
const [editor, setEditor] = useState<MainMenuEditor | null>(null);
if (editor) return <MainMenuCharacterEditor editor={editor} onClose={() => setEditor(null)} />;
if (bossTestingOpen) return <Suspense fallback={<p role="status">Opening Boss Testing…</p>}><BossTestingScreen onClose={() => setBossTestingOpen(false)} /></Suspense>;
if (balanceLabOpen) return <Suspense fallback={<p role="status">Opening Balance Lab…</p>}><BalanceLabScreen onClose={() => setBalanceLabOpen(false)} /></Suspense>;
if (balanceTuningOpen) return <Suspense fallback={<p role="status">Opening balance tuning…</p>}><BalanceTuningScreen onClose={() => setBalanceTuningOpen(false)} /></Suspense>;
if (readinessOpen) return <Suspense fallback={<p role="status">Opening Companion Readiness…</p>}><CompanionReadinessScreen onClose={() => setReadinessOpen(false)} /></Suspense>;
if (randomizedBiteSizedOpen) return <Suspense fallback={<p role="status">Opening Randomized Bite-Sized…</p>}><RandomizedBiteSizedScreen onClose={() => setRandomizedBiteSizedOpen(false)} /></Suspense>;
if (cardsOpen) return <Suspense fallback={<p role="status">Opening collection log…</p>}><BossCardCollectionScreen onClose={() => setCardsOpen(false)} /></Suspense>;
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenReadiness={() => setReadinessOpen(true)} onOpenRandomizedBiteSized={() => setRandomizedBiteSizedOpen(true)} onOpenEditor={setEditor} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenBalanceTuning={() => setBalanceTuningOpen(true)} onOpenReadiness={() => setReadinessOpen(true)} onOpenRandomizedBiteSized={() => setRandomizedBiteSizedOpen(true)} onOpenEditor={setEditor} />;
}
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenReadiness, onOpenRandomizedBiteSized, onOpenEditor }: {
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenBalanceTuning, onOpenReadiness, onOpenRandomizedBiteSized, onOpenEditor }: {
readonly onOpenDownloads: () => void;
readonly onOpenReports: () => void;
readonly onOpenCards: () => void;
readonly onOpenBossTesting: () => void;
readonly onOpenBalanceLab: () => void;
readonly onOpenBalanceTuning: () => void;
readonly onOpenReadiness: () => void;
readonly onOpenRandomizedBiteSized: () => void;
readonly onOpenEditor: (editor: MainMenuEditor) => void;
@@ -125,6 +129,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
...(isGm ? [{ id: "gm-menu-gm-tools", run: openGmAdmin, neighbors: { up: "gm-menu-close" } }] : []),
{ id: "gm-menu-boss-testing", run: onOpenBossTesting, ...(!isGm ? { neighbors: { up: "gm-menu-close" } } : {}) },
{ id: "gm-menu-companion-readiness", run: onOpenReadiness },
...(isGm ? [{ id: "gm-menu-balance-tuning", run: onOpenBalanceTuning }] : []),
...(isGm ? [{ id: "gm-menu-balance-lab", run: onOpenBalanceLab }] : []),
{ id: "gm-menu-reports", run: onOpenReports },
{ id: "gm-menu-close", run: () => setGmMenuOpen(false), neighbors: { down: isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing" } },
@@ -153,7 +158,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
] : []),
{ id: "mode-downloads", run: onOpenDownloads },
{ id: "back-to-roster", run: returnToCharacters, neighbors: { down: "mode-dungeons" } },
], [character, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
], [character, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
const controller = useMenuController(actions, {
columns: 2,
initialId: gmMenuOpen ? (isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing") : "mode-dungeons",
@@ -303,6 +308,10 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
className="front-button main-menu-mode main-menu-mode--gm" type="button" onClick={onOpenReadiness}>
<b aria-hidden="true">CR</b><span><strong>Companion Readiness</strong><small>Review certified NPC specializations and Playerbots identities.</small></span><em>Open</em>
</ControllerButton>
<ControllerButton controlId="gm-menu-balance-tuning" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={onOpenBalanceTuning}>
<b aria-hidden="true">AT</b><span><strong>Ability &amp; Talent Tuning</strong><small>Adjust local cooldowns, costs, damage, healing, durations, and talent modifiers.</small></span><em>{isGm ? "Local" : "GM account required"}</em>
</ControllerButton>
<ControllerButton controlId="gm-menu-balance-lab" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={onOpenBalanceLab}>
<b aria-hidden="true">BL</b><span><strong>Balance Lab</strong><small>Queue automatic party and boss simulations on the server.</small></span><em>{isGm ? "Server" : "GM account required"}</em>
+1 -1
View File
@@ -65,7 +65,7 @@ const TOGGLES: readonly ToggleOption[] = [
{ id: "showMobAggroRanges", label: "Mob aggro ranges", description: "Show the boundary where idle dungeon enemies automatically attack your party." },
{ id: "showTargetFrame", label: "Target frame", description: "Show detailed status for your current target." },
{ id: "showThreatMeter", label: "Threat meter", description: "Show the compact movable threat table for your selected enemy." },
{ id: "showFloatingCombatText", label: "Combat numbers", description: "Show damage, healing, and absorb feedback." },
{ id: "showFloatingCombatText", label: "Party healing numbers", description: "Float effective healing amounts over party frames during combat." },
{ id: "autoTargetNearest", label: "Automatic targeting", description: "Pick the nearest valid enemy when an offensive ability needs one." },
{ id: "enableScreenShake", label: "Screen shake", description: "Use restrained camera impact for major hits." },
] as const;
+92 -2
View File
@@ -1,4 +1,4 @@
import { useState, type CSSProperties } from "react";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { classById, isRomClassId, raceById } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore";
@@ -21,6 +21,12 @@ import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../ga
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
import { useGameStore } from "../game/store";
import { useHudSurface } from "./HudSurface";
import {
PARTY_HEAL_POPUP_LIFETIME_MS,
addedPartyHealPopups,
appendPartyHealPopups,
type PartyHealPopup,
} from "./partyHealPopups";
const PARTY_COMMANDS: readonly {
command: PartyCommand;
@@ -109,10 +115,85 @@ function healthPercent(current: number, maximum: number): number {
return Math.max(0, Math.min(100, current / maximum * 100));
}
function MemberFrame({ member, selected, onSelect }: {
function HealPopups({ popups, targetId }: {
popups: readonly PartyHealPopup[];
targetId: string;
}) {
const targetPopups = popups.filter((popup) => popup.targetActorId === targetId);
if (targetPopups.length === 0) return null;
return (
<span className="party-frame__heal-popups" aria-hidden="true">
{targetPopups.map((popup) => (
<span
key={popup.key}
className={`party-frame__heal-popup ${popup.critical ? "is-critical" : ""}`}
style={{ "--party-heal-x": `${popup.horizontalOffset}px` } as CSSProperties}
>+{popup.amount}</span>
))}
</span>
);
}
function usePartyHealPopups(enabled: boolean): readonly PartyHealPopup[] {
const [popups, setPopups] = useState<PartyHealPopup[]>([]);
const timers = useRef(new Map<string, number>());
useEffect(() => {
const clearPopups = () => {
for (const timer of timers.current.values()) window.clearTimeout(timer);
timers.current.clear();
setPopups([]);
};
if (!enabled) {
clearPopups();
return undefined;
}
const unsubscribe = useCombatStore.subscribe((state, previous) => {
const inCombat = Object.values(state.mobs).some((mob) => (
mob.engaged && !mob.dead && !mob.despawned
));
const wasInCombat = Object.values(previous.mobs).some((mob) => (
mob.engaged && !mob.dead && !mob.despawned
));
if (!inCombat) {
if (wasInCombat) clearPopups();
return;
}
const visibleTargetIds = new Set([
PLAYER_AGGRO_ID,
...usePartyStore.getState().members.map((member) => member.id),
...(useOnlineSessionStore.getState().snapshot?.players ?? []).map((player) => (
onlinePlayerActorId(player.accountId)
)),
]);
const added = addedPartyHealPopups(previous.combatEvents, state.combatEvents, true)
.filter((popup) => visibleTargetIds.has(popup.targetActorId));
if (added.length === 0) return;
setPopups((current) => appendPartyHealPopups(current, added));
for (const popup of added) {
const existing = timers.current.get(popup.key);
if (existing !== undefined) window.clearTimeout(existing);
timers.current.set(popup.key, window.setTimeout(() => {
timers.current.delete(popup.key);
setPopups((current) => current.filter((candidate) => candidate.key !== popup.key));
}, PARTY_HEAL_POPUP_LIFETIME_MS));
}
});
return () => {
unsubscribe();
for (const timer of timers.current.values()) window.clearTimeout(timer);
timers.current.clear();
};
}, [enabled]);
return popups;
}
function MemberFrame({ member, selected, onSelect, healPopups }: {
member: PartyMember;
selected: boolean;
onSelect: () => void;
healPopups: readonly PartyHealPopup[];
}) {
const characterClass = classById(member.classId);
const race = raceById(member.raceId);
@@ -163,6 +244,7 @@ function MemberFrame({ member, selected, onSelect }: {
highlightDispellableDebuffs
/>
</span>
<HealPopups popups={healPopups} targetId={member.id} />
</button>
);
}
@@ -172,11 +254,13 @@ function OnlineMemberFrame({
player,
selected,
onSelect,
healPopups,
}: {
member: OnlineGroupMember;
player: OnlineSessionPlayer | null;
selected: boolean;
onSelect: () => void;
healPopups: readonly PartyHealPopup[];
}) {
const character = member.character;
if (!character) return null;
@@ -207,6 +291,7 @@ function OnlineMemberFrame({
</>
) : <span className="party-frame__online-status">Connecting…</span>}
</span>
<HealPopups popups={healPopups} targetId={onlinePlayerActorId(member.accountId)} />
</button>
);
}
@@ -221,6 +306,8 @@ export function PartyFrames() {
const playerResource = useCombatStore((state) => state.resource);
const playerMaxResource = useCombatStore((state) => state.maxResource);
const playerResourceName = useCombatStore((state) => state.resourceName);
const showHealPopups = useCombatStore((state) => state.settings.showFloatingCombatText);
const healPopups = usePartyHealPopups(showHealPopups);
const playerAuraAbsorb = useCombatStore((state) => state.auras.reduce((total, aura) => (
aura.targetId === PLAYER_AGGRO_ID
? total + aura.absorbs.reduce((sum, pool) => sum + pool.remaining, 0)
@@ -312,6 +399,7 @@ export function PartyFrames() {
highlightDispellableDebuffs
/>
</span>
<HealPopups popups={healPopups} targetId={PLAYER_AGGRO_ID} />
</button>
{onlineMembers.map((member) => {
const actorId = onlinePlayerActorId(member.accountId);
@@ -322,6 +410,7 @@ export function PartyFrames() {
player={onlinePlayersByAccount.get(member.accountId) ?? null}
selected={selectedOnlineActorId === actorId}
onSelect={() => selectOnlineMember(member.accountId)}
healPopups={healPopups}
/>
);
})}
@@ -331,6 +420,7 @@ export function PartyFrames() {
member={member}
selected={member.id === selectedMemberId}
onSelect={() => selectMember(member.id)}
healPopups={healPopups}
/>
))}
{gameMode === "dungeon" && members.some((member) => member.health <= 0) ? (
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import type { StructuredCombatEvent } from "../game/combatEvents";
import {
MAX_PARTY_HEAL_POPUPS,
addedPartyHealPopups,
appendPartyHealPopups,
} from "./partyHealPopups";
function event(
id: number,
kind: StructuredCombatEvent["kind"] = "healing",
effectiveAmount = 125,
): StructuredCombatEvent {
return {
id,
occurredAt: 1_000 + id,
kind,
sourceActorId: "healer",
targetActorId: `party-${id}`,
rawAmount: effectiveAmount,
effectiveAmount,
overheal: 0,
absorbed: 0,
critical: id % 2 === 0,
};
}
describe("party-frame healing popups", () => {
it("turns newly committed effective heals into rounded green-number events", () => {
const previous = [event(1)];
const next = [...previous, event(2, "damage", 80), event(3, "healing", 149.6)];
expect(addedPartyHealPopups(previous, next, true)).toEqual([{
key: "1003:3",
targetActorId: "party-3",
amount: 150,
critical: false,
horizontalOffset: -14,
}]);
});
it("ignores old heals, pure overheal, and all healing outside combat", () => {
const previous = [event(1)];
const overheal = event(2, "healing", 0);
expect(addedPartyHealPopups(previous, [...previous, overheal], true)).toEqual([]);
expect(addedPartyHealPopups(previous, [...previous, event(3)], false)).toEqual([]);
});
it("deduplicates replayed keys and bounds the active burst", () => {
const first = addedPartyHealPopups([], [event(1)], true)[0];
const burst = Array.from({ length: MAX_PARTY_HEAL_POPUPS + 4 }, (_, index) => (
addedPartyHealPopups([], [event(index + 2)], true)[0]
));
const result = appendPartyHealPopups([first], [first, ...burst]);
expect(result).toHaveLength(MAX_PARTY_HEAL_POPUPS);
expect(new Set(result.map((popup) => popup.key)).size).toBe(MAX_PARTY_HEAL_POPUPS);
expect(result.at(-1)?.key).toBe(`${1_000 + MAX_PARTY_HEAL_POPUPS + 5}:${MAX_PARTY_HEAL_POPUPS + 5}`);
});
});
+49
View File
@@ -0,0 +1,49 @@
import type { StructuredCombatEvent } from "../game/combatEvents";
export const PARTY_HEAL_POPUP_LIFETIME_MS = 1_500;
export const MAX_PARTY_HEAL_POPUPS = 20;
export interface PartyHealPopup {
readonly key: string;
readonly targetActorId: string;
readonly amount: number;
readonly critical: boolean;
readonly horizontalOffset: number;
}
function eventKey(event: StructuredCombatEvent): string {
return `${event.occurredAt}:${event.id}`;
}
/** Convert only newly committed, effective in-combat heals into party-frame UI events. */
export function addedPartyHealPopups(
previousEvents: readonly StructuredCombatEvent[],
nextEvents: readonly StructuredCombatEvent[],
inCombat: boolean,
): PartyHealPopup[] {
if (!inCombat || previousEvents === nextEvents) return [];
const previousKeys = new Set(previousEvents.map(eventKey));
return nextEvents
.filter((event) => (
event.kind === "healing"
&& event.effectiveAmount > 0
&& !previousKeys.has(eventKey(event))
))
.map((event) => ({
key: eventKey(event),
targetActorId: event.targetActorId,
amount: Math.max(1, Math.round(event.effectiveAmount)),
critical: event.critical,
horizontalOffset: ((event.id % 3) - 1) * 14,
}));
}
export function appendPartyHealPopups(
current: readonly PartyHealPopup[],
added: readonly PartyHealPopup[],
): PartyHealPopup[] {
if (added.length === 0) return [...current];
const addedKeys = new Set(added.map((popup) => popup.key));
return [...current.filter((popup) => !addedKeys.has(popup.key)), ...added]
.slice(-MAX_PARTY_HEAL_POPUPS);
}
-1
View File
@@ -84,7 +84,6 @@
.desktop-action-button.is-unavailable .action-slot__icon { opacity: .4; }
.desktop-action-button:is(:hover, :focus-visible, .is-selected) { border-color: #ffe3a0; outline: 2px solid var(--gold); outline-offset: -2px; }
.desktop-action-button__cooldown { position: absolute; inset: 0; display: grid; place-items: center; background: #0006; font-size: 15px; font-weight: bold; text-shadow: 0 1px 2px #000; }
.desktop-action-feedback { position: absolute; top: 65%; left: 50%; transform: translateX(-50%); max-width: 80%; color: #f3dfaa; text-align: center; text-shadow: 0 1px 3px #000; font-size: 12px; }
.ui-layout-editor {
position: absolute;
z-index: 80;