diff --git a/AGENTS.md b/AGENTS.md index 9e1da30f..05c827b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 8d6a7af5..f6edefd1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/BALANCE_LAB.md b/docs/BALANCE_LAB.md index 060eabd6..d28348ac 100644 --- a/docs/BALANCE_LAB.md +++ b/docs/BALANCE_LAB.md @@ -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 `. 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 diff --git a/docs/BALANCE_TUNING.md b/docs/BALANCE_TUNING.md new file mode 100644 index 00000000..ecb5107c --- /dev/null +++ b/docs/BALANCE_TUNING.md @@ -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 +``` diff --git a/docs/COMBAT_ROTATION_MATRIX.md b/docs/COMBAT_ROTATION_MATRIX.md index 247d2667..d0a8cce8 100644 --- a/docs/COMBAT_ROTATION_MATRIX.md +++ b/docs/COMBAT_ROTATION_MATRIX.md @@ -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 diff --git a/docs/DUNGEON_REPORT_TRIAGE.md b/docs/DUNGEON_REPORT_TRIAGE.md index 667dc8fa..f0aa00bd 100644 --- a/docs/DUNGEON_REPORT_TRIAGE.md +++ b/docs/DUNGEON_REPORT_TRIAGE.md @@ -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 diff --git a/package.json b/package.json index fec345d6..0d3fc962 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/balance_lab.py b/scripts/balance_lab.py index 78fa7cfa..9bfcedad 100644 --- a/scripts/balance_lab.py +++ b/scripts/balance_lab.py @@ -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, diff --git a/scripts/balance_lab_brackets.py b/scripts/balance_lab_brackets.py index 64d0e612..540369d4 100644 --- a/scripts/balance_lab_brackets.py +++ b/scripts/balance_lab_brackets.py @@ -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), diff --git a/scripts/combat-benchmark/run.ts b/scripts/combat-benchmark/run.ts index 8dbc78cd..24f52e9e 100644 --- a/scripts/combat-benchmark/run.ts +++ b/scripts/combat-benchmark/run.ts @@ -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"); } diff --git a/scripts/playtest-party-heal-popups.cjs b/scripts/playtest-party-heal-popups.cjs new file mode 100644 index 00000000..f27775b0 --- /dev/null +++ b/scripts/playtest-party-heal-popups.cjs @@ -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; +}); diff --git a/scripts/test_balance_lab.py b/scripts/test_balance_lab.py index ef36c7d5..0ec5089f 100644 --- a/scripts/test_balance_lab.py +++ b/scripts/test_balance_lab.py @@ -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"}, diff --git a/scripts/test_balance_lab_brackets.py b/scripts/test_balance_lab_brackets.py index e42a1f83..3b7b38aa 100644 --- a/scripts/test_balance_lab_brackets.py +++ b/scripts/test_balance_lab_brackets.py @@ -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()), diff --git a/src/game/abilityCatalog.ts b/src/game/abilityCatalog.ts index 877ed9a0..9d487ac3 100644 --- a/src/game/abilityCatalog.ts +++ b/src/game/abilityCatalog.ts @@ -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> = { ...CLASSIC_CORE_ABILITIES, }; +const sourceAbilitiesByClass: Partial> = { + ...CLASSIC_CORE_ABILITIES, +}; const abilityCatalog: Record = {}; let romEliteAbilities: readonly AbilityDefinition[] = []; let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null; @@ -904,8 +908,28 @@ export const ABILITY_CATALOG = abilityCatalog as Readonly 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) { diff --git a/src/game/balanceTuning.test.ts b/src/game/balanceTuning.test.ts new file mode 100644 index 00000000..a3471141 --- /dev/null +++ b/src/game/balanceTuning.test.ts @@ -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: {}, + }); + }); +}); diff --git a/src/game/balanceTuning.ts b/src/game/balanceTuning.ts new file mode 100644 index 00000000..39b0e0c4 --- /dev/null +++ b/src/game/balanceTuning.ts @@ -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>>; +} + +export interface BalanceTuningPolicy { + readonly schemaVersion: 1; + readonly abilityOverrides: Readonly>; + readonly talentOverrides: Readonly>; +} + +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; + const result: Record = {}; + 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; + const effectScalePercent = bounded(source.effectScalePercent, 0, 1_000); + const sourceModifiers = source.modifiersPerRank && typeof source.modifiersPerRank === "object" + ? source.modifiersPerRank as Record + : {}; + const modifiersPerRank: Partial> = {}; + 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; + const sourceAbilities = source.abilityOverrides && typeof source.abilityOverrides === "object" + ? source.abilityOverrides as Record + : {}; + const sourceTalents = source.talentOverrides && typeof source.talentOverrides === "object" + ? source.talentOverrides as Record + : {}; + 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>(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).basePointsPerDose ?? 0) * percent / 100, + attackPowerCoefficientPerDose: Number((effect.poison as Record).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, damagePercent) as unknown as AbilityEffect; + } else if (["heal", "hot", "shield"].includes(effect.kind)) { + tuned = tuneAmount(effect as unknown as Record, 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 = (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 = (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; +} diff --git a/src/game/benchmark/benchmarkBuilds.test.ts b/src/game/benchmark/benchmarkBuilds.test.ts index 72e6376d..0c2ccfa4 100644 --- a/src/game/benchmark/benchmarkBuilds.test.ts +++ b/src/game/benchmark/benchmarkBuilds.test.ts @@ -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"); diff --git a/src/game/benchmark/benchmarkBuilds.ts b/src/game/benchmark/benchmarkBuilds.ts index 480b3a24..1f7a6110 100644 --- a/src/game/benchmark/benchmarkBuilds.ts +++ b/src/game/benchmark/benchmarkBuilds.ts @@ -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 = new Set(BASE_CLASS_IDS); const COA_CLASS_ID_SET: ReadonlySet = new Set(COA_CLASS_IDS); const ROM_CLASS_ID_SET: ReadonlySet = new Set(ROM_CLASS_IDS); +const READY_SPECIALIZATION_ID_SET: ReadonlySet = new Set(READY_COMPANION_SPECIALIZATION_IDS); const WOW_ORIENTATION_BY_ROLE: Readonly< Record>>> @@ -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 { 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); diff --git a/src/game/combatStore.test.ts b/src/game/combatStore.test.ts index e2feffce..c547ed60 100644 --- a/src/game/combatStore.test.ts +++ b/src/game/combatStore.test.ts @@ -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); diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index c859ca71..947a5cda 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -4626,6 +4626,14 @@ export const useCombatStore = create((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((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((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((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)]; diff --git a/src/game/companionSpecReadiness.test.ts b/src/game/companionSpecReadiness.test.ts index b866bfd1..0a53bc79 100644 --- a/src/game/companionSpecReadiness.test.ts +++ b/src/game/companionSpecReadiness.test.ts @@ -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, diff --git a/src/game/companionSpecReadiness.ts b/src/game/companionSpecReadiness.ts index 07699f6a..4a5c2817 100644 --- a/src/game/companionSpecReadiness.ts +++ b/src/game/companionSpecReadiness.ts @@ -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({ diff --git a/src/game/heirloomEquipment.test.ts b/src/game/heirloomEquipment.test.ts index 065a9fd2..a65dde6c 100644 --- a/src/game/heirloomEquipment.test.ts +++ b/src/game/heirloomEquipment.test.ts @@ -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)); diff --git a/src/game/readyFiveCertification.test.ts b/src/game/readyFiveCertification.test.ts new file mode 100644 index 00000000..83a0c065 --- /dev/null +++ b/src/game/readyFiveCertification.test.ts @@ -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(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); + } + } + }); +}); diff --git a/src/game/talentModifiers.test.ts b/src/game/talentModifiers.test.ts index d8141e35..5f34cf6e 100644 --- a/src/game/talentModifiers.test.ts +++ b/src/game/talentModifiers.test.ts @@ -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); + }); }); diff --git a/src/game/talentModifiers.ts b/src/game/talentModifiers.ts index c225bf4b..747758cf 100644 --- a/src/game/talentModifiers.ts +++ b/src/game/talentModifiers.ts @@ -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 {} +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 { diff --git a/src/game/talentRuntimeCatalog.ts b/src/game/talentRuntimeCatalog.ts index be04cd25..126fe312 100644 --- a/src/game/talentRuntimeCatalog.ts +++ b/src/game/talentRuntimeCatalog.ts @@ -8,7 +8,7 @@ import generated from "./generated/talentRuntimeCatalog.json"; export type RuntimeTalentRanks = Readonly>; -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>; const nodeById = Object.freeze(Object.fromEntries(snapshot.nodes.map((node) => [node.id, node]))) as Readonly>; const nodesByTree = Object.freeze(Object.fromEntries(snapshot.trees.map((tree) => [ diff --git a/src/game/uiLayout.ts b/src/game/uiLayout.ts index 0dc0a1b2..a94d6063 100644 --- a/src/game/uiLayout.ts +++ b/src/game/uiLayout.ts @@ -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; diff --git a/src/styles.css b/src/styles.css index 45b37958..610e48b3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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 { diff --git a/src/ui/BalanceLabScreen.tsx b/src/ui/BalanceLabScreen.tsx index 6b1f6f3b..85f1a269 100644 --- a/src/ui/BalanceLabScreen.tsx +++ b/src/ui/BalanceLabScreen.tsx @@ -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("all"); + const [source, setSource] = useState("wow"); const [specSelection, setSpecSelection] = useState("random"); const [specSearch, setSpecSearch] = useState(""); const [selectedBuildIds, setSelectedBuildIds] = useState([]); @@ -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(""); }}> - + } {specSelection === "random" ? @@ -422,6 +435,7 @@ export function BalanceLabScreen({ +

Certification scope: Ready only. 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.

{levelCoverage === "brackets" &&

{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.

} diff --git a/src/ui/BalanceTuningScreen.test.ts b/src/ui/BalanceTuningScreen.test.ts new file mode 100644 index 00000000..b8b546eb --- /dev/null +++ b/src/ui/BalanceTuningScreen.test.ts @@ -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"); + }); +}); diff --git a/src/ui/BalanceTuningScreen.tsx b/src/ui/BalanceTuningScreen.tsx new file mode 100644 index 00000000..2477c16d --- /dev/null +++ b/src/ui/BalanceTuningScreen.tsx @@ -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> = { + "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(record: Readonly>, key: string): Record { + return Object.fromEntries(Object.entries(record).filter(([candidate]) => candidate !== key)); +} + +export function BalanceTuningScreen({ onClose }: { readonly onClose: () => void }) { + const [tab, setTab] = useState("abilities"); + const [query, setQuery] = useState(""); + const [classFilter, setClassFilter] = useState("all"); + const [draft, setDraft] = useState(() => currentBalanceTuningPolicy()); + const [published, setPublished] = useState(() => currentBalanceTuningPolicy()); + const [selectedAbilityId, setSelectedAbilityId] = useState(""); + const [selectedTalentId, setSelectedTalentId] = useState(""); + const [talentKind, setTalentKind] = useState("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 ( + +
+ +
Local GM sandboxAbility & Talent Tuning
+
+ {tunedCount} tuned entries + +
+
+ +
+
+

Runtime overrides

+

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.

+
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ {tab === "abilities" && selectedAbility && <> +
+
{classById(selectedAbility.classId).name} ability

{selectedAbility.name}

+ +
+

{selectedAbility.description}

+
+ Cooldown {seconds(selectedAbility.cooldownMs)} + Cast {seconds(selectedAbility.castTimeMs)} + Cost {selectedAbility.cost.amount} {selectedAbility.cost.resource} + Effects {selectedAbility.effects.map((effect) => effect.kind).join(", ") || "utility"} +
+
+ {ABILITY_FIELDS.map((field) => )} +
+ } + + {tab === "talents" && selectedTalent && <> +
+
{classById(classIdForTalent(selectedTalent)).name} · {selectedTalent.treeId.split("-").at(-1)} tree

{selectedTalent.name}

+ +
+

{talentDescription(selectedTalent)}

+ {talentUsesExplicitActivation(selectedTalent.name) &&

On-use talent: its tooltip is not applied passively. Adjust its cooldown and aura duration from the Abilities tab.

} + +
+
Supplemental modifier per rankAdd a signed percentage-point adjustment. Negative cooldown or cast-time values make abilities faster.
+
+ + updateTalentModifier(selectedTalent, talentKind, Number(event.currentTarget.value))} /> + % per rank +
+
    {Object.entries(draft.talentOverrides[selectedTalent.id]?.modifiersPerRank ?? {}).map(([kind, amount]) =>
  • {TALENT_KIND_LABELS[kind as TalentBalanceModifierKind]}{Number(amount) > 0 ? "+" : ""}{amount}% / rank
  • )}
+
+ } +
+
+ +
+ {notice || (dirty ? "Unapplied local changes" : "Saved tuning is active")} + + +
+
+ ); +} diff --git a/src/ui/CompanionReadinessScreen.tsx b/src/ui/CompanionReadinessScreen.tsx index 4e7522e9..0a6a70c1 100644 --- a/src/ui/CompanionReadinessScreen.tsx +++ b/src/ui/CompanionReadinessScreen.tsx @@ -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>((result, value) => { + const totals = COMPANION_READINESS_SPECIALIZATIONS.reduce>((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: () =>

Certification standard

-

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.

+

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.

{(["ready", "beta", "needs-testing"] as const).map((value) => ( @@ -81,11 +83,14 @@ export function CompanionReadinessScreen({ onClose }: { readonly onClose: () =>

{classById(classId).name}

{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
-
{definition.displayName}{definition.role} · {profile.characterName} · {profile.playerbotsSpecName ?? profile.id}
- {editable ? setStatus(definition.id, event.target.value as CompanionSpecReadinessStatus)}> : {STATUS_LABELS[value]}}
; diff --git a/src/ui/EditableHudElement.tsx b/src/ui/EditableHudElement.tsx index 59352827..c5c74ce5 100644 --- a/src/ui/EditableHudElement.tsx +++ b/src/ui/EditableHudElement.tsx @@ -12,7 +12,7 @@ const FALLBACKS: Partial> = { 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], }; diff --git a/src/ui/Hud.tsx b/src/ui/Hud.tsx index 87a54393..c67f71b6 100644 --- a/src/ui/Hud.tsx +++ b/src/ui/Hud.tsx @@ -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} />}
- {desktopBars && settings.showFloatingCombatText && feedback?.message - ?
{feedback.message}
: null}
{cameraLookActive && diff --git a/src/ui/MainMenuScreen.tsx b/src/ui/MainMenuScreen.tsx index 65fc0988..fa57d40e 100644 --- a/src/ui/MainMenuScreen.tsx +++ b/src/ui/MainMenuScreen.tsx @@ -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(null); if (editor) return setEditor(null)} />; if (bossTestingOpen) return Opening Boss Testing…

}> setBossTestingOpen(false)} />
; if (balanceLabOpen) return Opening Balance Lab…

}> setBalanceLabOpen(false)} />
; + if (balanceTuningOpen) return Opening balance tuning…

}> setBalanceTuningOpen(false)} />
; if (readinessOpen) return Opening Companion Readiness…

}> setReadinessOpen(false)} />
; if (randomizedBiteSizedOpen) return Opening Randomized Bite-Sized…

}> setRandomizedBiteSizedOpen(false)} />
; if (cardsOpen) return Opening collection log…

}> setCardsOpen(false)} />
; if (contentManagerOpen) return setContentManagerOpen(false)} />; if (reportsOpen) return setReportsOpen(false)} />; - return setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenReadiness={() => setReadinessOpen(true)} onOpenRandomizedBiteSized={() => setRandomizedBiteSizedOpen(true)} onOpenEditor={setEditor} />; + return 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}> Companion ReadinessReview certified NPC specializations and Playerbots identities.Open + + Ability & Talent TuningAdjust local cooldowns, costs, damage, healing, durations, and talent modifiers.{isGm ? "Local" : "GM account required"} + Balance LabQueue automatic party and boss simulations on the server.{isGm ? "Server" : "GM account required"} diff --git a/src/ui/OptionsPanel.tsx b/src/ui/OptionsPanel.tsx index c56fb896..b5e97799 100644 --- a/src/ui/OptionsPanel.tsx +++ b/src/ui/OptionsPanel.tsx @@ -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; diff --git a/src/ui/PartyFrames.tsx b/src/ui/PartyFrames.tsx index db0bc232..269b9950 100644 --- a/src/ui/PartyFrames.tsx +++ b/src/ui/PartyFrames.tsx @@ -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 ( + + ); +} + +function usePartyHealPopups(enabled: boolean): readonly PartyHealPopup[] { + const [popups, setPopups] = useState([]); + const timers = useRef(new Map()); + + 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 /> + ); } @@ -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({ ) : Connecting…} + ); } @@ -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 /> + {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) ? ( diff --git a/src/ui/partyHealPopups.test.ts b/src/ui/partyHealPopups.test.ts new file mode 100644 index 00000000..cfc326aa --- /dev/null +++ b/src/ui/partyHealPopups.test.ts @@ -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}`); + }); +}); diff --git a/src/ui/partyHealPopups.ts b/src/ui/partyHealPopups.ts new file mode 100644 index 00000000..be938245 --- /dev/null +++ b/src/ui/partyHealPopups.ts @@ -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); +} diff --git a/src/ui/uiLayout.css b/src/ui/uiLayout.css index ccce7aed..d0b3d1b5 100644 --- a/src/ui/uiLayout.css +++ b/src/ui/uiLayout.css @@ -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;