Update Healer Man server
This commit is contained in:
1 parent
fdb6739f8d
commit
f90ba7d989
41 files changed
+1671
-118
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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"},
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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) => ({
|
||||
id: benchmarkBuildId(classId, role, safeLevel),
|
||||
source: contentCategoryForClass(classId),
|
||||
classId,
|
||||
role,
|
||||
orientation: "Primary",
|
||||
level: safeLevel,
|
||||
}));
|
||||
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);
|
||||
|
||||
@@ -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
@@ -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)];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) => ({
|
||||
...modifier,
|
||||
sourceTalentId: active.talentId,
|
||||
sourceTalentName: active.talentName,
|
||||
rank: active.rank,
|
||||
description: active.description,
|
||||
})));
|
||||
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 {
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>}
|
||||
|
||||
@@ -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 & 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");
|
||||
});
|
||||
});
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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
@@ -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) ? (
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user