Compare commits

...
11 Commits
Author SHA1 Message Date
phenom a05a79eb83 Release Healer Man 0.1.56 2026-09-19 13:16:17 -04:00
phenom 38b3d4d160 Release Healer Man 0.1.55 2026-09-19 12:23:33 -04:00
phenom 2dbb0acfc1 Release Healer Man 0.1.54 2026-09-18 16:09:25 -04:00
phenom d6976e6ef6 Release Healer Man 0.1.53 2026-09-18 12:32:51 -04:00
phenom d0a02487db Release Healer Man 0.1.52 2026-09-18 09:34:59 -04:00
phenom dd583023ac Release Healer Man 0.1.51 2026-09-17 18:01:29 -04:00
phenom 79153ca40d Release Healer Man 0.1.50 2026-09-17 13:33:55 -04:00
phenom 717d12e5af Document Obsidian project memory 2026-09-17 11:36:54 -04:00
phenom 612170afd0 Release Healer Man 0.1.49 2026-09-17 09:13:15 -04:00
phenom 39425f2494 Release Healer Man 0.1.48 2026-09-16 20:58:10 -04:00
phenom 2162039b49 Release Healer Man 0.1.47 2026-09-16 20:00:13 -04:00
130 changed files with 6551 additions and 1439 deletions

No files matched your search

+21 -4
View File
@@ -11,10 +11,11 @@ These rules apply to every change under this project root.
## Workspace and reference boundaries ## Workspace and reference boundaries
- The active project root is `D:\Projects\HealerMan`. All implementation, - The active project root is the directory containing this `AGENTS.md`
generated content, scripts, tests, builds, documentation, and validation for (currently `F:\Projects\HealerMan`); do not assume a fixed drive letter. All
this project belong here. implementation, generated content, scripts, tests, builds, documentation, and
- `D:\Projects\wow-pvt-server-backups-2` is historical reference material only. validation for this project belong here.
- The sibling `../wow-pvt-server-backups-2` tree is historical reference material only.
Inspect it only when old-server behavior, AzerothCore data, creature IDs, Inspect it only when old-server behavior, AzerothCore data, creature IDs,
spells, encounters, maps, or assets are useful as source information. spells, encounters, maps, or assets are useful as source information.
- Never treat `wow-pvt-server-backups-2` as the active project, primary checkout, - Never treat `wow-pvt-server-backups-2` as the active project, primary checkout,
@@ -27,6 +28,22 @@ These rules apply to every change under this project root.
structure as appropriate, and validate it through HealerMan's tests/build and structure as appropriate, and validate it through HealerMan's tests/build and
playtest workflow. playtest workflow.
## Obsidian project memory
- The separate Healer-Man knowledge vault lives at the sibling path
`../ObsidianNotes/ObsidianVault1`. Start at
`00 Home/Healer-Man Hub.md` and follow
`40 Agent Workflows/Agent Memory Protocol.md` for broad or cross-system work.
- The code and tests in this repository remain authoritative. Check a note's
`last_verified` date and verify material claims against current code before
acting on them.
- Update the relevant vault note in the same task when a change alters feature
behavior, architectural ownership, entry points, cross-file relationships,
invariants, or required validation. Use repository-relative code paths and
keep transient investigation details in the vault's `50 Worklog/` area.
- The vault is its own Git repository. Review and commit its changes separately
from HealerMan changes; never stage one repository from the other.
## Dungeon report triage ## Dungeon report triage
- For any request to inspect or fix current dungeon reports, start with - For any request to inspect or fix current dungeon reports, start with
+23 -10
View File
@@ -129,14 +129,17 @@ services:
healerman: healerman:
image: node:24-bookworm-slim image: node:24-bookworm-slim
command: >- command: >-
sh -lc "npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start" sh -lc "apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* && python3 --version && npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
environment: environment:
BALANCE_LAB_MAX_CONCURRENT: "4"
BALANCE_LAB_MAX_QUEUED: "100"
BALANCE_LAB_PYTHON: "python3"
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost" CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost"
CONTENT_DIR: /app/content CONTENT_DIR: /app/content
DATA_DIR: /app/runtime-data DATA_DIR: /app/runtime-data
HOST: 0.0.0.0 HOST: 0.0.0.0
HEALER_MAN_ENABLE_SHELVED_MODES: "0" HEALER_MAN_ENABLE_SHELVED_MODES: "0"
NODE_OPTIONS: "--max-old-space-size=4096" NODE_OPTIONS: "--max-old-space-size=8192"
PORT: "4173" PORT: "4173"
SESSION_TTL_DAYS: "30" SESSION_TTL_DAYS: "30"
STATIC_DIR: /app/dist STATIC_DIR: /app/dist
@@ -162,23 +165,27 @@ new or changed content objects, builds the compact required-content browser
bundle, and starts the combined static/API/content server. Existing content bundle, and starts the combined static/API/content server. Existing content
objects are reused. objects are reused.
### Optional GM Balance Lab server dependency ### Required GM Balance Lab server dependency
To run Balance Lab simulations on TrueNAS instead of the local workstation, Balance Lab uses the repository's Python controller to orchestrate the live
the server container needs Python 3 in addition to the existing Node TypeScript combat runtime. The canonical YAML above therefore installs Python
development dependencies. Replace the service command with: 3 before installing/building the app and explicitly selects `python3` with
`BALANCE_LAB_PYTHON`.
If an existing app reports `Could not start python3`, edit that app's YAML to
use this command:
```yaml ```yaml
command: >- command: >-
sh -lc "apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* && npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start" sh -lc "apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* && python3 --version && npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
``` ```
Add or change these environment values: and add or correct these environment values:
```yaml ```yaml
BALANCE_LAB_PYTHON: "python3" BALANCE_LAB_PYTHON: "python3"
BALANCE_LAB_MAX_CONCURRENT: "1" BALANCE_LAB_MAX_CONCURRENT: "4"
BALANCE_LAB_MAX_QUEUED: "8" BALANCE_LAB_MAX_QUEUED: "100"
``` ```
The app stores job reports in The app stores job reports in
@@ -192,6 +199,12 @@ job returns as failed after restart, while any job with a complete
unless the server has enough spare CPU for multiple full combat sweeps. All unless the server has enough spare CPU for multiple full combat sweeps. All
Balance Lab routes independently require a server-authorized GM account. Balance Lab routes independently require a server-authorized GM account.
After saving the YAML, restart the TrueNAS app and watch its startup log for a
line beginning with `Python 3.`. Reopen Balance Lab; the setup panel must show
**Server runtime ready** before **Run on server** becomes available. The server
now performs this preflight before queueing, so a missing runtime cannot create
another doomed job.
After startup, test on the LAN: After startup, test on the LAN:
```sh ```sh
+127
View File
@@ -3512,3 +3512,130 @@ Vite's existing large-chunk advisory remains informational.
browser errors. Evidence is in browser errors. Evidence is in
`playtest-artifacts/balance-lab/04b-ability-breakdown.png` and `playtest-artifacts/balance-lab/04b-ability-breakdown.png` and
`playtest-artifacts/balance-lab/06-mobile-abilities.png`. `playtest-artifacts/balance-lab/06-mobile-abilities.png`.
## Balance Lab server Python preflight (2026-09-16)
- The production failure was a deployment mismatch: the TrueNAS app used the
canonical Node-only startup command even though server Balance Lab jobs need
the Python controller. The canonical YAML in both `DEPLOYMENT.md` and the
release-runbook appendix now installs `python3`, prints its version during
startup, sets `BALANCE_LAB_PYTHON=python3`, and includes the queue limits.
- A GM-authenticated runtime endpoint now probes the configured executable with
`--version`. The Balance Lab setup shows the resolved Python 3 version, and a
missing runtime disables **Run on server**. POST requests independently
repeat the preflight and return a dedicated 503 without creating a failed job.
- Nine focused Balance Lab service tests plus two server integration tests, the
8 GB TypeScript check, and the production Vite build passed. Production Edge
displayed `Server runtime ready · Python 3.14.0`, submitted a real Eck run,
generated all reports, and completed desktop/mobile QA without browser errors.
Evidence is in `playtest-artifacts/balance-lab/02-server-job-config.png`.
## Party melee animation cadence (2026-09-16)
- Party character animation now follows authoritative combat-presentation
phases instead of treating every `nextActionAt` retry timestamp as an attack.
Auto-attacks produce one ordinary attack event, while real abilities retain
their cast-start, release, and cancel identities.
- Warrior and Paladin auto-attacks now prefer the normal one-handed/two-handed
attack clips before special clips. Special/spinning clips remain available to
explicitly matched abilities rather than every weapon swing.
- The focused combat-animation suite passed 10 tests, all 47 avatar tests
passed, and the production build plus texture/performance and release-bundle
audits passed. The live level-three Stockades pass completed without browser
errors; all five bodies and their equipped weapon/staff silhouettes rendered
intact. Evidence is in
`playtest-artifacts/dungeon-health-scaling/stockades-level-three.png`.
## Balance Lab healing done and received analytics (2026-09-17)
- Raw Balance Lab member metrics now attribute effective healing to both the
caster and the party target. Party and suite reports expose healing done and
received per active second plus absolute totals; self-heals count on both
sides of the attribution.
- The GM overview adds separate **Healing done by class/spec** and **Healing
received by class/spec** graphs. The class/spec table can display and sort
both rates and totals. Older saved reports remain readable but show no target
healing until they are rerun.
- All six Python controller tests and the production build passed. A live
one-seed, 15-second Eck the Ferocious run recorded the Holy Paladin's 867
effective healing as 57.8 HPS done and the Blood Death Knight's same 867 as
57.8 HPS received. A matching automatic-suite run verified the new JSON and
CSV fields under `playtest-artifacts/balance-lab-healing-suite/`.
## Balance Lab deterministic class/spec coverage (2026-09-17)
- Automatic sweeps now offer **Random parties**, **All specs**, and **Specific
specs**. All-spec mode derives the minimum valid 1-tank/1-healer/3-damage
party set that covers every available build. Specific mode guarantees every
selected build appears and uses distinct compatible fillers for missing
roles; selected builds are not reused as fillers when alternatives exist.
- A real level-20 WoW all-spec sweep against Eck the Ferocious generated eight
distinct parties and placed all 31 catalog builds in `suite-report.json`
with zero missing. The combined catalog audit found 70 CoA builds requiring
16 coverage parties and 16 RoM builds requiring four; every source exposes
enough tank, healer, and damage builds for valid coverage parties.
- Eight Python controller tests, nine Balance Lab service tests, two server
integration tests, and the production build passed. Production Edge
automation verified the All Specs estimate, selected Paladin Holy through the
Specific Specs picker, completed the server job, rendered all seven overview
graphs, and passed desktop plus 480×900 overflow checks without browser
errors. Evidence is in `playtest-artifacts/balance-lab/02-server-job-config.png`
and `playtest-artifacts/balance-lab/03-server-job-complete.png`.
## Balance Lab class/spec ability damage drill-down (2026-09-17)
- Every class/spec row now exposes **View ability damage** when ability telemetry
exists. It opens the Abilities tab on that exact source/build, clears
conflicting search and role filters, and defaults to abilities with positive
effective damage sorted highest first.
- The drill-down graph and table retain ability name/ID, casts, casts per minute,
hits, effective/raw damage, damage share, DPS, and damage per hit. Healing,
overheal, absorbs, usage, and unused-ability filters remain available.
- The production build passed. A real five-second level-20 WoW run against Eck
the Ferocious opened a Death Knight Unholy row through the new action and
verified positive effective ability damage plus the expected cast, damage
share, and DPS columns. Desktop and 480×900 checks passed with no browser
errors. Evidence is in
`playtest-artifacts/balance-lab/04b-ability-breakdown.png` and
`playtest-artifacts/balance-lab/06-mobile-abilities.png`.
## WoW full-spec mechanics and rotation pass (2026-09-18)
- `playerbots-inspired-v3` now gives every one of the 31 Wrath specs a legal,
level-scaled talent plan and executable profile. The pass repaired stale
talent prerequisites, companion Rage/Runic Power persistence, short melee
reach, warrior rage banking, and explicit profile ordering.
- Scripted payloads now make Bloodthirst, Bloodrage, Execute, Mutilate, Killing
Spree, Penance, Feral Spirit, Conflagrate, and Earth Shield mechanically
meaningful. Frost/Unholy disease setup, Arms Rend/Mortal Strike order, Arcane
Barrage, and healer maintenance behavior have direct regression coverage.
- The exhaustive level-80 headless audit completed 2,672 fights with no
simulation failures under `artifacts/balance-fixes-20260918-level80/`.
The final 40-run Stockades confirmation under
`artifacts/balance-fixes-20260918-stockade-level80-final/` exercised all 31
specs: Feral Spirit, Bloodrage, and Earth Shield were active in every eligible
run, while the repaired damage signatures produced damage in their applicable
runs. Low-level boss floors were recalibrated so ordinary/final Stockades
encounters still retain their 25/45-second mechanic windows. These are
structured combat checks, so no scene screenshot is applicable. Remaining
cross-spec DPS/HPS outliers require a separate coefficient-tuning pass.
## Balance Lab level batches and parallel workers (2026-09-19)
- The GM setup now switches between one level and all level brackets. The three
schedules expose 9 jobs (level 1 plus every 10), 17 jobs (level 1 plus every
5), or 80 jobs (every level). Every-boss, source-specific boss, and explicit
boss selection remain available, and the estimate totals all scheduled jobs.
- A browser-driven nine-level exploratory submission created nine persisted
jobs in one request, started four independent Python/TypeScript processes,
and completed the first four level reports before the automation time limit;
the test server then stopped the remaining isolated test jobs. Server tests
separately prove parallel worker utilization, queued overflow, and all-or-
nothing rejection when a complete batch will not fit.
- The final production-build smoke pass verified the 9/17/80 schedule controls,
worker/queue status, specific Paladin Holy and Eck selection, one complete
real level-20 report, all report/ability analytics, and 480x900 layout with no
browser errors. Evidence is in
`playtest-artifacts/balance-lab/02-level-batch-config.png`,
`playtest-artifacts/balance-lab/04-server-job-complete.png`, and
`playtest-artifacts/balance-lab/06-mobile-analytics.png`.
+13 -2
View File
@@ -18,6 +18,14 @@ approval before it commits, pushes `main`, builds the signed APK, and publishes
the Gitea release. Signing passwords and the Gitea token are passed to the the Gitea release. Signing passwords and the Gitea token are passed to the
child process in memory rather than on its command line. child process in memory rather than on its command line.
If a content, server, normal-application, or shelved-application test command
fails, the visual manager pauses with the failed command, exit code, and recent
test output. **Stop Release** remains the safe default, but **Continue Anyway**
allows an intentional override and records that decision in the release log.
Build, content-publication, Git, signing, and APK-publication failures are not
overridable and still stop the workflow. The manual procedure below remains
fail-closed.
Before changing versions or running the longer test/build sequence, the manager Before changing versions or running the longer test/build sequence, the manager
runs an isolated release-candidate check. It validates tracked and untracked runs an isolated release-candidate check. It validates tracked and untracked
files without changing the real Git index, so extra blank lines at EOF and files without changing the real Git index, so extra blank lines at EOF and
@@ -651,14 +659,17 @@ services:
healerman: healerman:
image: node:24-bookworm-slim image: node:24-bookworm-slim
command: >- command: >-
sh -lc "npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start" sh -lc "apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* && python3 --version && npm ci --include=dev && npm run db:init && npm run content:publish && npm run build && npm start"
environment: environment:
BALANCE_LAB_MAX_CONCURRENT: "4"
BALANCE_LAB_MAX_QUEUED: "100"
BALANCE_LAB_PYTHON: "python3"
CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost" CORS_ORIGINS: "https://iwanttoheal.phenomrom.com,capacitor://localhost,http://localhost,https://localhost"
CONTENT_DIR: /app/content CONTENT_DIR: /app/content
DATA_DIR: /app/runtime-data DATA_DIR: /app/runtime-data
HOST: 0.0.0.0 HOST: 0.0.0.0
HEALER_MAN_ENABLE_SHELVED_MODES: "0" HEALER_MAN_ENABLE_SHELVED_MODES: "0"
NODE_OPTIONS: "--max-old-space-size=4096" NODE_OPTIONS: "--max-old-space-size=8192"
PORT: "4173" PORT: "4173"
SESSION_TTL_DAYS: "30" SESSION_TTL_DAYS: "30"
STATIC_DIR: /app/dist STATIC_DIR: /app/dist
+106 -31
View File
@@ -25,10 +25,28 @@ The prompt selects:
1. one random party, five manual members, or an automatic multi-party sweep; 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 can select all three;
3. a level from 1 through 80; 3. a level from 1 through 80;
4. how many unique 1-tank/1-healer/3-damage configurations to generate per source; 4. random parties, guaranteed coverage of all class/spec builds, or a
5. all bosses, WoW bosses, RoM bosses, or a searchable multi-boss selection; searchable selection of specific class/spec builds;
6. how many deterministic simulations to run for every party/boss pairing; and 5. how many unique 1-tank/1-healer/3-damage configurations to generate per
7. the maximum duration of each fight. source when random selection is used;
6. all bosses, WoW bosses, RoM bosses, or a searchable multi-boss selection;
7. how many deterministic simulations to run for every party/boss pairing; and
8. the maximum duration of each fight.
Automatic class/spec selection has three strategies:
- **Random parties** preserves the original seeded behavior and generates the
requested number of unique parties per source.
- **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.
- **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.
Coverage parties always contain one tank, one healer, and three different
damage builds. Their composition is deterministic for the party seed, and no
build is duplicated inside one party.
The active member is driven by the shared benchmark policy while the other four The active member is driven by the shared benchmark policy while the other four
use the live companion AI. The healer is the default active member. Use use the live companion AI. The healer is the default active member. Use
@@ -38,13 +56,18 @@ use the live companion AI. The healer is the default active member. Use
A server-authorized GM sees **Balance Lab** directly underneath **GM Tools** on A server-authorized GM sees **Balance Lab** directly underneath **GM Tools** on
the main menu. That screen exposes the automatic sweep controls: party source, the main menu. That screen exposes the automatic sweep controls: party source,
level, unique parties per source, simulations per party/boss, duration, random a single level or an all-brackets schedule, class/spec selection strategy, a
seed, boss category, and a searchable multi-boss picker. searchable spec picker, simulations per party/boss, duration, random seed, boss
category, and a searchable multi-boss picker. The bracket schedules cover level
1 plus every 10 levels, level 1 plus every 5 levels, or every level from 1
through 80. Random mode exposes the requested parties per source; coverage
modes show the derived minimum party count before submission.
**Run on server** sends only that allow-listed configuration to the authenticated **Run on server** sends one allow-listed configuration to the authenticated GM
GM API. The server places it in a one-at-a-time queue, launches the same Python API. **Run all level brackets** submits the complete schedule atomically and
controller and live TypeScript runtime used by the command line, and writes creates one independently persisted job and report per level. The bounded worker
artifacts under: pool runs several jobs at the same time, launches the same Python controller and
live TypeScript runtime used by the command line, and writes artifacts under:
```text ```text
DATA_DIR/balance-lab/jobs/<job-id>/ DATA_DIR/balance-lab/jobs/<job-id>/
@@ -80,11 +103,14 @@ The dashboard includes:
- party damage applied directly to each boss; - party damage applied directly to each boss;
- average successful kill time; - average successful kill time;
- boss DPS and damage taken for every class/specialization; - boss DPS and damage taken for every class/specialization;
- effective healing done and effective healing received per second for every
class/specialization;
- complete boss tables with pressure versus median, total incoming damage, - complete boss tables with pressure versus median, total incoming damage,
defeat/wipe rates, deaths, and health; defeat/wipe rates, deaths, and health;
- complete class/spec tables with total and boss DPS, HPS, DTPS, absolute - complete class/spec tables with total and boss DPS, healing done/received per
damage taken, damage/healing/absorb totals, deaths, and source/role-median second, DTPS, absolute damage taken, healing done/received and absorb totals,
tuning flags; and deaths, source/role-median tuning flags, and a direct ability-damage
drill-down for every build; and
- a complete Abilities tab with learned/available abilities (including zero-use - a complete Abilities tab with learned/available abilities (including zero-use
rows), exact runtime IDs, AI categories, active-run coverage, casts, hits, rows), exact runtime IDs, AI categories, active-run coverage, casts, hits,
casts per minute, effective/raw damage, DPS, damage share, damage per hit, casts per minute, effective/raw damage, DPS, damage share, damage per hit,
@@ -96,7 +122,10 @@ The overview graphs intentionally show at most the 15 highest visible values so
labels remain readable. They never discard report data: the Bosses, Class/spec, labels remain readable. They never discard report data: the Bosses, Class/spec,
and Parties tabs retain every matching row. Search, party-source, boss-source, and Parties tabs retain every matching row. Search, party-source, boss-source,
role, metric, and sort-direction controls filter the display without modifying role, metric, and sort-direction controls filter the display without modifying
the saved report. the saved report. **View ability damage** on a class/spec row opens the
Abilities tab with that exact build selected, damaging abilities ranked by
effective damage, and its casts, hits, DPS, damage share, and damage-per-hit
details visible in the table. **Show all specs** clears that drill-down.
Server requirements: Server requirements:
@@ -106,10 +135,19 @@ Server requirements:
and and
- the signed-in account must have the server-issued `gm` role. - the signed-in account must have the server-issued `gm` role.
`BALANCE_LAB_MAX_CONCURRENT` defaults to `1` and is capped at four. The GM screen calls `/api/gm/balance-lab/runtime` when it opens and shows the
`BALANCE_LAB_MAX_QUEUED` defaults to eight and is capped at 50. Raising resolved Python version. If Python 3 is unavailable, **Run on server** is
concurrency permits parallel server processes but can make both the game server disabled and no job is queued. On the canonical TrueNAS deployment, the service
and simulations less responsive. Requests are capped before launch, subprocess command installs `python3`, prints `python3 --version` during startup, and sets
`BALANCE_LAB_PYTHON=python3`; use the exact YAML in `DEPLOYMENT.md` rather than
the older Node-only command.
`BALANCE_LAB_MAX_CONCURRENT` defaults to four and is capped at 16.
`BALANCE_LAB_MAX_QUEUED` defaults to 100 and is capped at 200, which leaves room
for an 80-job every-level sweep. Raising concurrency permits more parallel
server processes but can make both the game server and simulations less
responsive. A batch contains at most 80 jobs and is validated against the
remaining queue capacity before any of its jobs are created. Subprocess
arguments are passed as an array without a shell, artifact names are arguments are passed as an array without a shell, artifact names are
allow-listed, and every job and artifact endpoint authenticates the account's allow-listed, and every job and artifact endpoint authenticates the account's
server-issued GM role. server-issued GM role.
@@ -243,18 +281,32 @@ under `parties/<party-id>/` and adds these suite-level files:
direct boss damage. Suite class/spec `dtps` is damage taken per active second; direct boss damage. Suite class/spec `dtps` is damage taken per active second;
`totalDamageTaken` is the absolute healer/survivability burden across every `totalDamageTaken` is the absolute healer/survivability burden across every
included run. included run.
- Ability `activeRuns` counts simulations with at least one cast, hit, damage, - Member `hps` is effective healing done per active second and excludes
healing, overheal, or absorb event. `usageRate` divides that by every run for overhealing. `hpsReceived` is effective healing received per active second,
that build; `damageShare` divides the ability's effective damage by the attributed from each healing event's target (including self-heals).
class/spec's total effective damage. `available · unused` means the ability `totalHealing` and `totalHealingReceived` are the corresponding absolute
was learned and executable at the tested level/talent setup but emitted no values across the included runs. Reports created before target attribution
combat event. This is a review signal, not proof of an AI defect: resurrection, was added remain readable but require a rerun to populate healing received.
- Member and ability `absorbs` count shield capacity granted by both direct
shield effects and named absorb auras such as Power Word: Shield. Member
`deathEvents` counts each alive-to-dead transition, so a member that is
resurrected and defeated again contributes another death.
- Ability `activeRuns` counts simulations with at least one cast, completed
non-throughput release, hit, damage, healing, overheal, or absorb event.
Completed resource, aura, pet, and summon actions are recorded as casts even
though they create no immediate throughput event. `usageRate` divides that by
every run for that build; `damageShare` divides the ability's effective
damage by the class/spec's total effective damage. `available · unused` means
the ability was learned and executable at the tested level/talent setup but
emitted no combat event. This is a review signal, not proof of an AI defect:
resurrection,
execute, interrupt, dispel, emergency, and long-cooldown abilities can be execute, interrupt, dispel, emergency, and long-cooldown abilities can be
legitimately unused when their condition never occurs. legitimately unused when their condition never occurs.
- Companion AI currently applies several instant attacks directly and therefore - Companion AI currently applies several instant attacks directly and therefore
may emit hits/damage without a separate cast event. Use **Active runs** and may emit hits/damage without a separate cast event. Use **Active runs** and
**Hits** as the authoritative usage indicators; use **Casts** and **Casts/min** **Hits** as the authoritative usage indicators; use **Casts** and **Casts/min**
as additional detail, especially for the active-player benchmark controller. as additional detail, especially for the active-player benchmark controller
and non-throughput actions.
- Boss pressure flags in a party report compare DTPS with the non-zero median - Boss pressure flags in a party report compare DTPS with the non-zero median
boss for that exact party. Suite boss flags compare across all generated boss for that exact party. Suite boss flags compare across all generated
parties. Suite class/spec flags compare DPS with every observed spec from the parties. Suite class/spec flags compare DPS with every observed spec from the
@@ -290,6 +342,27 @@ member. When a suspected class outlier could be policy-sensitive, rerun with a
different `--active-slot`; do not compare active and companion results without different `--active-slot`; do not compare active and companion results without
noting that control-policy difference. noting that control-policy difference.
Companion builds initialize every resource pool required by their learned
abilities, not only the class's primary pool. Secondary Energy and Focus
regenerate, and any companion with a Rage pool gains Rage from landed weapon
attacks; this is what makes druid Cat and Bear rotations executable while Mana
remains the class's primary resource. A completed companion cast resolves the
member's currently learned rank before applying its effects, so cast-time spell
damage and healing cannot fall back to rank one.
`playerbots-inspired-v4` keeps level-10-and-up non-healer builds on their
specialization's authored damage priority whenever that build knows a profile
damage action. Reactive interrupts, taunts, control, and defenses still take
precedence. Bear and Cat actions are isolated to their matching Feral specs;
Overpower, Revenge, and Victory Rush require an explicitly modeled trigger;
and Drain Soul, Hammer of Wrath, and Kill Shot obey their target-health gates.
Off-profile control, interrupt, and debuff buttons cannot consume ordinary
rotation GCDs after their reactive conditions have been checked. Incidental DBC
aura categories cannot outrank explicit profile actions. Rage and Runic Power
generated during an action persist without erasing that action's cooldown/GCD;
short-range melee abilities use companion combat reach; and pets move every
combat tick while retaining their own attack cadence.
## Code map ## Code map
- `scripts/balance_lab.py`: prompts, command-line parsing, random/manual/automatic - `scripts/balance_lab.py`: prompts, command-line parsing, random/manual/automatic
@@ -304,15 +377,17 @@ noting that control-policy difference.
cards, and complete boss/class-spec/party tables. Add new dashboard metrics cards, and complete boss/class-spec/party tables. Add new dashboard metrics
here only after they exist in `suite-report.json`. here only after they exist in `suite-report.json`.
- `src/app/balanceLabClient.ts`: typed GM Balance Lab API client. - `src/app/balanceLabClient.ts`: typed GM Balance Lab API client.
- `server/balance-lab-jobs.mjs`: validated job specifications, bounded - `server/balance-lab-jobs.mjs`: validated single and batch job specifications,
one-at-a-time process queue, progress parsing, `job.json` persistence/startup bounded parallel process queue, progress parsing, `job.json`
restoration, report-summary extraction, and artifact allow-list. persistence/startup restoration, report-summary extraction, and artifact
- `server/server.mjs`: GM-authenticated job/status/artifact HTTP routes. allow-list.
- `server/server.mjs`: GM-authenticated runtime-preflight, job, status, and
artifact HTTP routes.
- `src/game/benchmark/benchmarkBuilds.ts`: selectable spec builds and legal - `src/game/benchmark/benchmarkBuilds.ts`: selectable spec builds and legal
talent allocation. The older canonical cohort remains unchanged. talent allocation. The older canonical cohort remains unchanged.
- `src/game/partyStore.ts`: `createConfiguredPartyMember`, the narrow factory - `src/game/partyStore.ts`: `createConfiguredPartyMember`, the narrow factory
that preserves production stats, equipment, resources, and appearance for an that preserves production stats, equipment, all learned-ability resource
exact companion. pools, and appearance for an exact companion.
- `src/game/bossTesting.ts`: the authoritative all-boss catalog and isolated - `src/game/bossTesting.ts`: the authoritative all-boss catalog and isolated
room construction. room construction.
+75
View File
@@ -0,0 +1,75 @@
# Combat rotation matrix
Healer Man uses AzerothCore playerbots commit `531282e` as a read-only behavioral reference. The complete historical checkout is available locally at `../wow-pvt-server-backups-2/azerothcore-wotlk/modules/mod-playerbots`; do not modify it or treat it as the active project. The implementation is an independent TypeScript policy over Healer Man's executable catalog, not a port of playerbots C++.
The most useful upstream starting points are `src/Ai/Class/<Class>/Strategy/` for actions, triggers, and relative priorities; `conf/playerbots.conf.dist` for the level-60 and level-80 PvE talent build links; `src/PlayerbotAIConfig.cpp` for parsing those links; and `src/Bot/Factory/PlayerbotFactory.cpp` plus `src/Mgr/Talent/Talentspec.cpp` for level-aware talent application. Translate these inputs through Healer Man's own talent legality, executable-effect, resource, encounter-state, and deterministic-simulation rules rather than copying C++ behavior literally.
| Concern | Playerbots behavior translated into Healer Man |
| --- | --- |
| Core rotation | One deterministic priority profile for each of the 31 Wrath specializations. |
| Maintenance | DoTs, debuffs, buffs, pets, and summons are refreshed only after their existing effect ends. |
| Execute | Execute-category abilities move ahead of the normal profile below 20% target health. |
| Multiple targets | Cleave gains value at two hostiles; area damage moves ahead at three or more. |
| Healing | Critical, medium, and light triage bands begin below 30%, 60%, and 90% health. Area healing requires three injured allies. |
| Threat | Tanks interrupt first and taunt a hostile that is not attacking them; non-tanks drop dangerous threat before continuing their profile. |
| Control | Interrupts are reactive. Long crowd control is used only when an encounter requests control. |
| Defense | Mitigation is used below 55% actor health and receives emergency priority below 35%. |
| Resources | Low-resource generators are favored below 30%; spenders still have to pass runtime cost and rune validation. |
| Combo points | Builders run below four points; finishers require at least four points on the current target. |
| Major cooldowns | Offensive cooldowns of 60 seconds or longer are held for bosses or durable targets. |
`playerbots-inspired-v4` also generates legal level-scaled talent plans for all
31 classic specializations. Each level-80 plan spends 51 points in the selected
tree and 20 in a compatible secondary tree, prefers executable active talents
and their prerequisites, and sanitizes obsolete imported prerequisite links.
Runtime resource generation is actor-authoritative without erasing the action's
cooldown/GCD, and companion melee abilities share the formation combat-reach
leeway already used by weapon swings.
CoA and Runes of Magic builds use the same semantic rules when no classic profile applies. Runes of Magic candidates include primary, secondary-general, and primary/secondary elite skills through `abilitiesForCharacter`.
## Classic specialization profiles
The leftmost available action wins after reactive rules, learned-talent gating, resource checks, maintenance checks, execute/AoE adjustments, and major-cooldown conservation. Maintenance entries are intentionally listed first; an active aura or pet removes that entry from consideration.
| Class | Specialization | Maintenance and core priority |
| --- | --- | --- |
| Warrior | Arms | Battle Stance; Bloodrage; Rend; Mortal Strike; Bladestorm; Overpower; Execute; Slam; Heroic Strike |
| Warrior | Fury | Berserker/Battle Stance; Bloodrage; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike |
| Warrior | Protection | Defensive/Battle Stance; Bloodrage; Shield Slam; Revenge; Devastate; Thunder Clap; Sunder Armor; Heroic Strike |
| Paladin | Holy | Concentration Aura; Blessing of Might; Holy Shock; Flash of Light; Holy Light; Sacred Shield; Judgement of Light |
| Paladin | Protection | Devotion Aura; Blessing of Might; Hammer of the Righteous; Shield of Righteousness; Holy Shield; Consecration; Judgement of Wisdom |
| Paladin | Retribution | Retribution Aura; Blessing of Might; Divine Storm; Crusader Strike; Judgement of Wisdom; Consecration; Exorcism; Hammer of Wrath |
| Hunter | Beast Mastery | Aspect of the Dragonhawk/Hawk; Call Pet; Kill Command; Bestial Wrath; Serpent Sting; Arcane Shot; Multi-Shot; Steady Shot |
| Hunter | Marksmanship | Aspect of the Dragonhawk/Hawk; Call Pet; Kill Shot; Chimera Shot; Aimed Shot; Serpent Sting; Arcane Shot; Steady Shot |
| Hunter | Survival | Aspect of the Dragonhawk/Hawk; Call Pet; Kill Shot; Explosive Shot; Black Arrow; Serpent Sting; Aimed Shot; Arcane Shot; Steady Shot |
| Rogue | Assassination | Envenom; Rupture; Mutilate; Eviscerate; Sinister Strike |
| Rogue | Combat | Killing Spree; Blade Flurry; Eviscerate; Sinister Strike |
| Rogue | Subtlety | Shadow Dance; Rupture; Eviscerate; Hemorrhage; Backstab |
| Priest | Discipline | Inner Fire; Penance; Power Word: Shield; Flash Heal; Greater Heal; Renew |
| Priest | Holy | Inner Fire; Circle of Healing; Prayer of Mending; Flash Heal; Greater Heal; Renew |
| Priest | Shadow | Shadowform; Inner Fire; Vampiric Touch; Devouring Plague; Shadow Word: Pain; Mind Blast; Mind Flay; Shadow Word: Death |
| Death Knight | Blood | Frost Presence (Blood Presence fallback); Heart Strike; Death Strike; Icy Touch; Plague Strike; Death Coil |
| Death Knight | Frost | Blood Presence; Icy Touch; Plague Strike; Howling Blast; Frost Strike; Obliterate |
| Death Knight | Unholy | Unholy Presence (Blood Presence fallback); Raise Dead; Plague Strike; Icy Touch; Scourge Strike; Death Coil; Death and Decay |
| Shaman | Elemental | Flametongue Weapon (Rockbiter fallback); Lightning Shield; Flame Shock; Lava Burst; Chain Lightning; Lightning Bolt |
| Shaman | Enhancement | Windfury Weapon (Rockbiter fallback); Lightning Shield; Feral Spirit; Stormstrike; Lava Lash; Earth Shock; Lightning Bolt |
| Shaman | Restoration | Water Shield; Rockbiter Weapon; Lightning Shield; Riptide; Chain Heal; Lesser Healing Wave; Healing Wave; Earth Shield |
| Mage | Arcane | Mage Armor; Arcane Barrage; Arcane Blast; Arcane Missiles |
| Mage | Fire | Molten Armor; Living Bomb; Pyroblast; Fireball; Scorch; Fire Blast |
| Mage | Frost | Frost Armor; Deep Freeze; Frostbolt; Ice Lance; Cone of Cold |
| Warlock | Affliction | Fel Armor; Felhunter; Haunt; Unstable Affliction; Corruption; Curse of Agony; Drain Soul; Shadow Bolt |
| Warlock | Demonology | Fel Armor; Felguard; Metamorphosis; Immolate; Corruption; Shadow Bolt |
| Warlock | Destruction | Fel Armor; Imp; Chaos Bolt; Conflagrate; Immolate; Incinerate; Shadow Bolt |
| Druid | Balance | Moonkin Form; Starfall; Moonfire; Insect Swarm; Starfire; Wrath |
| Druid | Feral Bear | Dire Bear/Bear Form; Mangle; Lacerate; Swipe; Maul |
| Druid | Feral Cat | Cat Form; Rip; Savage Roar; Mangle; Rake; Shred; Claw |
| Druid | Restoration | Tree of Life; Wild Growth; Swiftmend; Lifebloom; Rejuvenation; Regrowth; Healing Touch |
## Ability audit classification
Active abilities fall into one of three audit buckets:
- **Conditional:** executable but selected only by a semantic trigger, such as interrupts, dispels, resurrection, threat control, movement, crowd control, executes, defensives, and long cooldowns.
- **Unsupported:** the catalog has only `unsupported`, `scripted`, or noncombat `utility` payloads, or represents a noncombat summon such as a scouting eye, ritual object, portal, or mount. These remain player-visible when valid but are deliberately excluded from autonomous rotations.
- **Rotation defect:** an executable learned ability with combat semantics that cannot be selected in its valid state. Profile-reference tests, shared-policy scenario tests, party damage audits, and the expanded-class audit treat these as failures.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.46", "version": "0.1.56",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.46", "version": "0.1.56",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.46", "version": "0.1.56",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.46", "versionName": "0.1.56",
"versionCode": 1046 "versionCode": 1056
} }
+5 -4
View File
@@ -5,7 +5,7 @@ import { registerCombatSpatialProvider } from "../../src/game/combatSpatial";
import { clearMobRuntimeRegistry, registerMobRuntime, getMobPosition } from "../../src/game/mobRuntimeRegistry"; import { clearMobRuntimeRegistry, registerMobRuntime, getMobPosition } from "../../src/game/mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, getPartyRuntimePosition } from "../../src/game/partyRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, getPartyRuntimePosition } from "../../src/game/partyRuntimeRegistry";
import { targetingRulesForAbility } from "../../src/game/abilityRules"; import { targetingRulesForAbility } from "../../src/game/abilityRules";
import { resourceProfileForClass, abilityAtLevel, isAbilityUnlocked } from "../../src/game/abilityCatalog"; import { resourceProfileForClass, learnedAbilityForBuild } from "../../src/game/abilityCatalog";
import { deriveCharacterStats } from "../../src/game/wotlkStats"; import { deriveCharacterStats } from "../../src/game/wotlkStats";
import { subscribeCombatPresentation } from "../../src/game/combatPresentation"; import { subscribeCombatPresentation } from "../../src/game/combatPresentation";
import { arenaDampening } from "../../server/arena-rules.mjs"; import { arenaDampening } from "../../server/arena-rules.mjs";
@@ -36,9 +36,10 @@ export function initializeArenaCharacter(actor: any, now: number) {
actor.avatar = { classId: state.classId, raceId: seed.raceId ?? (actor.team === 1 ? "orc" : "human"), actor.avatar = { classId: state.classId, raceId: seed.raceId ?? (actor.team === 1 ? "orc" : "human"),
gender: (seed as any).gender ?? "male", appearance: (seed as any).appearance ?? createDefaultAppearance() }; gender: (seed as any).gender ?? "male", appearance: (seed as any).appearance ?? createDefaultAppearance() };
actor.visualEquipment = equipmentItemsForOwner(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID); actor.visualEquipment = equipmentItemsForOwner(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID);
const abilities = state.abilities.filter((ability: any) => !ability.passive && isAbilityUnlocked(ability, state.level) const abilities = state.abilities.filter((ability: any) => !ability.passive)
&& (ability.talentEntryId === undefined || (state.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0)) .map((ability: any) => learnedAbilityForBuild(ability, state.level, state.talentRanks))
.map((ability: any) => { const resolved = abilityAtLevel(ability, state.level, ability.talentEntryId === undefined ? undefined : state.talentRanks[`coa-entry-${ability.talentEntryId}`]); return { ...resolved, targeting: targetingRulesForAbility(resolved) }; }); .filter((ability: any) => ability !== null)
.map((ability: any) => ({ ...ability, targeting: targetingRulesForAbility(ability) }));
actor.characterKit = { ...kit, seed, classId: state.classId, level: state.level, actor.characterKit = { ...kit, seed, classId: state.classId, level: state.level,
resource: { ...resourceProfileForClass(state.classId), maximum: state.maxResource, initial: state.resource }, resource: { ...resourceProfileForClass(state.classId), maximum: state.maxResource, initial: state.resource },
abilities: Object.fromEntries(abilities.map((ability: any) => [ability.id, ability])), actionBindings: state.actionBindings }; abilities: Object.fromEntries(abilities.map((ability: any) => [ability.id, ability])), actionBindings: state.actionBindings };
+57 -22
View File
@@ -9,15 +9,16 @@ import {
} from "../../src/app/characterCatalog"; } from "../../src/app/characterCatalog";
import type { ContentCategoryId } from "../../src/app/contentCategories"; import type { ContentCategoryId } from "../../src/app/contentCategories";
import { import {
abilitiesForClass, abilityById,
abilityAtLevel, abilitiesForCharacter,
isAbilityUnlocked, learnedAbilityForBuild,
preloadAbilityCatalogsForClasses, preloadAbilityCatalogsForClasses,
} from "../../src/game/abilityCatalog"; } from "../../src/game/abilityCatalog";
import { aiHintsForAbility } from "../../src/game/abilityRules"; import { aiHintsForAbility } from "../../src/game/abilityRules";
import { PLAYER_AGGRO_ID } from "../../src/game/aggro"; import { PLAYER_AGGRO_ID } from "../../src/game/aggro";
import { installCombatClock } from "../../src/game/combatClock"; import { installCombatClock } from "../../src/game/combatClock";
import { subscribeCombatEvents } from "../../src/game/combatEvents"; import { subscribeCombatEvents } from "../../src/game/combatEvents";
import { subscribeCombatPresentation } from "../../src/game/combatPresentation";
import { resetCombatSpatialProvider } from "../../src/game/combatSpatial"; import { resetCombatSpatialProvider } from "../../src/game/combatSpatial";
import { useCombatStore } from "../../src/game/combatStore"; import { useCombatStore } from "../../src/game/combatStore";
import { dungeonEnemyScaling } from "../../src/game/dungeonBossScaling"; import { dungeonEnemyScaling } from "../../src/game/dungeonBossScaling";
@@ -44,7 +45,8 @@ import {
type PartyMember, type PartyMember,
} from "../../src/game/partyStore"; } from "../../src/game/partyStore";
import { specializationsForClass } from "../../src/game/specializationCatalog"; import { specializationsForClass } from "../../src/game/specializationCatalog";
import { BenchmarkController } from "../../src/game/benchmark/benchmarkController"; import { BENCHMARK_POLICY_ID, BenchmarkController } from "../../src/game/benchmark/benchmarkController";
import { COMBAT_ROTATION_VERSION } from "../../src/game/combatRotation";
import { import {
loadSelectableBenchmarkBuilds, loadSelectableBenchmarkBuilds,
selectableBenchmarkBuildCatalog, selectableBenchmarkBuildCatalog,
@@ -90,6 +92,7 @@ interface MutableMemberMetrics {
healing: number; healing: number;
rawHealing: number; rawHealing: number;
overheal: number; overheal: number;
healingReceived: number;
absorbs: number; absorbs: number;
damageTaken: number; damageTaken: number;
rawDamageTaken: number; rawDamageTaken: number;
@@ -157,6 +160,8 @@ function catalogPayload(source: PartyCatalogSource, level: number) {
const builds = selectableBenchmarkBuildCatalog(level, classIdsForSource(source)); const builds = selectableBenchmarkBuildCatalog(level, classIdsForSource(source));
return { return {
schemaVersion: 1, schemaVersion: 1,
policyId: BENCHMARK_POLICY_ID,
rotationVersion: COMBAT_ROTATION_VERSION,
level: builds[0]?.level ?? level, level: builds[0]?.level ?? level,
source, source,
builds: builds.map((build) => ({ builds: builds.map((build) => ({
@@ -235,6 +240,13 @@ function abilityMetrics(member: MutableMemberMetrics, abilityId: string | undefi
return created; return created;
} }
function abilityProducesAttributedCombatEvent(abilityId: string | undefined): boolean {
const ability = abilityId ? abilityById(abilityId) : null;
return ability?.effects.some((effect) => [
"damage", "dot", "finisher-damage", "execute", "heal", "hot", "shield", "resurrection",
].includes(effect.kind)) ?? true;
}
function currentHealth(actorId: string): readonly [number, number] { function currentHealth(actorId: string): readonly [number, number] {
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
if (actorId === PLAYER_AGGRO_ID) return [combat.health, combat.maxHealth]; if (actorId === PLAYER_AGGRO_ID) return [combat.health, combat.maxHealth];
@@ -243,14 +255,11 @@ function currentHealth(actorId: string): readonly [number, number] {
} }
function availableAbilities(build: BenchmarkBuildDefinition) { function availableAbilities(build: BenchmarkBuildDefinition) {
return abilitiesForClass(build.classId) return abilitiesForCharacter(build.classId, build.secondaryClassId)
.filter((ability) => !ability.passive && isAbilityUnlocked(ability, build.level)) .filter((ability) => !ability.passive)
.map((ability) => { .map((ability) => {
const learnedRank = ability.talentEntryId === undefined const ranked = learnedAbilityForBuild(ability, build.level, build.talentRanks);
? undefined if (!ranked) return null;
: build.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0;
if (ability.talentEntryId !== undefined && !learnedRank) return null;
const ranked = abilityAtLevel(ability, build.level, learnedRank);
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null; if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
return { return {
abilityId: ranked.id, abilityId: ranked.id,
@@ -287,6 +296,8 @@ function serializedMember(
rawHealing: metrics.rawHealing, rawHealing: metrics.rawHealing,
overheal: metrics.overheal, overheal: metrics.overheal,
hps: metrics.healing / Math.max(0.1, activeSeconds), hps: metrics.healing / Math.max(0.1, activeSeconds),
healingReceived: metrics.healingReceived,
hpsReceived: metrics.healingReceived / Math.max(0.1, activeSeconds),
absorbs: metrics.absorbs, absorbs: metrics.absorbs,
damageTaken: metrics.damageTaken, damageTaken: metrics.damageTaken,
rawDamageTaken: metrics.rawDamageTaken, rawDamageTaken: metrics.rawDamageTaken,
@@ -357,6 +368,7 @@ async function runBoss(
healing: 0, healing: 0,
rawHealing: 0, rawHealing: 0,
overheal: 0, overheal: 0,
healingReceived: 0,
absorbs: 0, absorbs: 0,
damageTaken: 0, damageTaken: 0,
rawDamageTaken: 0, rawDamageTaken: 0,
@@ -367,11 +379,24 @@ async function runBoss(
abilities: new Map(), abilities: new Map(),
})); }));
const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics])); const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics]));
const actorAlive = new Map<string, boolean>();
const bossAbilities = new Map<string, MutableAbilityMetrics>(); const bossAbilities = new Map<string, MutableAbilityMetrics>();
// Instant resource, aura, pet, and summon actions do not produce the damage,
// healing, absorb, or cast events used by the original report collector.
// Count their completed release once so Balance Lab does not label working
// actions such as Bloodrage and Feral Spirit as available-but-unused.
const stopPresentation = subscribeCombatPresentation((message) => {
if (message.kind !== "event" || message.event.phase !== "release") return;
const source = byActorId.get(message.event.sourceActorId);
if (!source || abilityProducesAttributedCombatEvent(message.event.abilityId)) return;
source.casts += 1;
abilityMetrics(source, message.event.abilityId).casts += 1;
});
const stopEvents = subscribeCombatEvents((event) => { const stopEvents = subscribeCombatEvents((event) => {
const source = byActorId.get(event.sourceActorId); const source = byActorId.get(event.sourceActorId);
const target = byActorId.get(event.targetActorId); const target = byActorId.get(event.targetActorId);
if (event.kind === "cast" && source) { if (event.kind === "cast" && source) {
if (!abilityProducesAttributedCombatEvent(event.abilityId)) return;
source.casts += 1; source.casts += 1;
abilityMetrics(source, event.abilityId).casts += 1; abilityMetrics(source, event.abilityId).casts += 1;
} else if (event.kind === "damage") { } else if (event.kind === "damage") {
@@ -396,20 +421,21 @@ async function runBoss(
ability.hits += 1; ability.hits += 1;
bossAbilities.set(key, ability); bossAbilities.set(key, ability);
} }
} else if (event.kind === "healing" && source) { } else if (event.kind === "healing") {
source.rawHealing += event.rawAmount; if (source) {
source.healing += event.effectiveAmount; source.rawHealing += event.rawAmount;
source.overheal += event.overheal; source.healing += event.effectiveAmount;
const ability = abilityMetrics(source, event.abilityId); source.overheal += event.overheal;
ability.rawHealing += event.rawAmount; const ability = abilityMetrics(source, event.abilityId);
ability.effectiveHealing += event.effectiveAmount; ability.rawHealing += event.rawAmount;
ability.overheal += event.overheal; ability.effectiveHealing += event.effectiveAmount;
ability.hits += 1; ability.overheal += event.overheal;
ability.hits += 1;
}
if (target) target.healingReceived += event.effectiveAmount;
} else if (event.kind === "absorb" && source) { } else if (event.kind === "absorb" && source) {
source.absorbs += event.effectiveAmount; source.absorbs += event.effectiveAmount;
abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount; abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount;
} else if (event.kind === "death" && target) {
target.deathEvents += 1;
} }
}); });
@@ -443,6 +469,9 @@ async function runBoss(
memberTargetIds: {}, memberTargetIds: {},
}); });
combat.synchronizeActors(); combat.synchronizeActors();
for (const metrics of memberMetrics) {
actorAlive.set(metrics.actorId, currentHealth(metrics.actorId)[0] > 0);
}
combat.registerMob(bossActorId, { combat.registerMob(bossActorId, {
name: entity.name, name: entity.name,
boss: true, boss: true,
@@ -473,6 +502,9 @@ async function runBoss(
controller.act(now, activeBuild.role, runScenario, false); controller.act(now, activeBuild.role, runScenario, false);
for (const metrics of memberMetrics) { for (const metrics of memberMetrics) {
const [health, maxHealth] = currentHealth(metrics.actorId); const [health, maxHealth] = currentHealth(metrics.actorId);
const alive = health > 0;
if ((actorAlive.get(metrics.actorId) ?? alive) && !alive) metrics.deathEvents += 1;
actorAlive.set(metrics.actorId, alive);
metrics.minimumHealthPercent = Math.min( metrics.minimumHealthPercent = Math.min(
metrics.minimumHealthPercent, metrics.minimumHealthPercent,
maxHealth > 0 ? Math.max(0, health / maxHealth) : 0, maxHealth > 0 ? Math.max(0, health / maxHealth) : 0,
@@ -525,6 +557,7 @@ async function runBoss(
|| left.abilityId.localeCompare(right.abilityId)), || left.abilityId.localeCompare(right.abilityId)),
}; };
} finally { } finally {
stopPresentation();
stopEvents(); stopEvents();
restoreClock(); restoreClock();
clearMobRuntimeRegistry(); clearMobRuntimeRegistry();
@@ -573,6 +606,8 @@ async function simulateConfig(config: BalanceLabConfig, output: string): Promise
schemaVersion: 1, schemaVersion: 1,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
engine: "live-typescript-runtime", engine: "live-typescript-runtime",
policyId: BENCHMARK_POLICY_ID,
rotationVersion: COMBAT_ROTATION_VERSION,
config: { config: {
...config, ...config,
level: builds[0]?.level ?? config.level, level: builds[0]?.level ?? config.level,
+209 -23
View File
@@ -195,6 +195,92 @@ def automatic_parties(
return configurations return configurations
def coverage_parties(
builds: Sequence[dict[str, Any]],
sources: Sequence[str],
selected_build_ids: Sequence[str] | None,
seed: int,
) -> list[dict[str, Any]]:
"""Build the minimum deterministic party set that covers every requested build."""
requested = set(selected_build_ids) if selected_build_ids is not None else None
offsets = {"wow": 100_000, "coa": 200_000, "rom": 300_000}
configurations: list[dict[str, Any]] = []
for source in sources:
source_builds = [build for build in builds if build["source"] == source]
targets = source_builds if requested is None else [
build for build in source_builds if build["id"] in requested
]
if not targets:
continue
available_by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
targets_by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
for build in source_builds:
available_by_role[build["role"]].append(build)
for build in targets:
targets_by_role[build["role"]].append(build)
if not available_by_role["tank"] or not available_by_role["healer"] or len(available_by_role["damage"]) < 3:
raise SystemExit(
f"The {source} catalog does not expose enough tank, healer, and damage builds for a 1/1/3 party."
)
party_count = max(
1,
len(targets_by_role["tank"]),
len(targets_by_role["healer"]),
math.ceil(len(targets_by_role["damage"]) / 3),
)
generator = random.Random(seed + offsets[source])
for rows in (*available_by_role.values(), *targets_by_role.values()):
rows.sort(key=lambda build: build["id"])
generator.shuffle(rows)
def filler(role: str, party_index: int, slot_index: int, used: set[str]) -> dict[str, Any]:
pool = available_by_role[role]
start = (party_index * (3 if role == "damage" else 1) + slot_index) % len(pool)
for avoid_requested in (True, False):
for offset in range(len(pool)):
candidate = pool[(start + offset) % len(pool)]
if candidate["id"] in used:
continue
if avoid_requested and requested is not None and candidate["id"] in requested:
continue
return candidate
raise SystemExit(f"Could not fill a unique {role} slot for a {source} coverage party.")
for party_index in range(party_count):
used: set[str] = set()
tank = targets_by_role["tank"][party_index] if party_index < len(targets_by_role["tank"]) else filler(
"tank", party_index, 0, used
)
used.add(tank["id"])
healer = targets_by_role["healer"][party_index] if party_index < len(targets_by_role["healer"]) else filler(
"healer", party_index, 0, used
)
used.add(healer["id"])
damage = list(targets_by_role["damage"][party_index * 3:(party_index + 1) * 3])
used.update(build["id"] for build in damage)
while len(damage) < 3:
build = filler("damage", party_index, len(damage), used)
damage.append(build)
used.add(build["id"])
party = [tank, healer, *damage]
configurations.append({
"id": f"{source}-party-{party_index + 1:03d}",
"source": source,
"party": party,
"activeSlot": 1,
})
if not configurations:
raise SystemExit("No class/spec builds matched the requested coverage selection.")
return configurations
def search_builds(query: str, builds: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
needle = normalized(query)
return [build for build in builds if needle in normalized(
f"{build['id']} {build['label']} {build['source']} {build['role']}"
)]
def search_bosses(query: str, bosses: Sequence[dict[str, Any]]) -> list[dict[str, Any]]: def search_bosses(query: str, bosses: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
needle = normalized(query) needle = normalized(query)
return [boss for boss in bosses if needle in normalized( return [boss for boss in bosses if needle in normalized(
@@ -273,6 +359,43 @@ def prompt_specific_bosses(bosses: Sequence[dict[str, Any]]) -> list[str]:
print(f"Selected bosses: {len(selected)}") print(f"Selected bosses: {len(selected)}")
def prompt_specific_builds(builds: Sequence[dict[str, Any]]) -> list[str]:
selected: dict[str, dict[str, Any]] = {}
print("\nClass/spec picker: search by class, specialization, role, source, or build ID.")
while True:
suffix = f" ({len(selected)} selected; type 'done' to finish)" if selected else ""
query = input(f"Class/spec search{suffix}: ").strip()
if query.casefold() == "done":
if selected:
return list(selected)
print("Select at least one class/spec first.")
continue
matches = search_builds(query, builds)
if not matches:
print("No class/spec builds matched that search.")
continue
if len(matches) > 50:
print(f"{len(matches)} builds matched. Use a more specific search (50 maximum per result page).")
continue
for index, build in enumerate(matches, 1):
marker = " [selected]" if build["id"] in selected else ""
print(f" {index:>2}. {console_text(build['label'])} [{build['source']}]{marker}")
raw = input("Add numbers separated by commas, or 'all': ").strip().casefold()
if raw == "all":
indexes = list(range(len(matches)))
else:
try:
indexes = [int(entry.strip()) - 1 for entry in raw.split(",") if entry.strip()]
except ValueError:
indexes = []
if not indexes or any(index < 0 or index >= len(matches) for index in indexes):
print("Choose valid result numbers.")
continue
for index in indexes:
selected[matches[index]["id"]] = matches[index]
print(f"Selected class/spec builds: {len(selected)}")
def interactive_arguments( def interactive_arguments(
args: argparse.Namespace, args: argparse.Namespace,
workspace: Path, workspace: Path,
@@ -288,7 +411,11 @@ def interactive_arguments(
if args.party_mode == "random": if args.party_mode == "random":
args.party_seed = prompt_integer("Random party seed", 1, -2_147_483_648, 2_147_483_647) args.party_seed = prompt_integer("Random party seed", 1, -2_147_483_648, 2_147_483_647)
elif args.party_mode == "automatic": elif args.party_mode == "automatic":
args.party_configurations = prompt_integer("Unique party configurations per source", 10, 1, 1_000) args.spec_selection = prompt_choice("Class/spec selection", ("random", "all", "specific"), "all")
if args.spec_selection == "random":
args.party_configurations = prompt_integer("Unique party configurations per source", 10, 1, 1_000)
elif args.spec_selection == "specific":
args.spec = prompt_specific_builds(catalog["builds"])
args.party_seed = prompt_integer("Automatic party seed", 1, -2_147_483_648, 2_147_483_647) args.party_seed = prompt_integer("Automatic party seed", 1, -2_147_483_648, 2_147_483_647)
else: else:
print("\nAvailable members:") print("\nAvailable members:")
@@ -320,7 +447,7 @@ def interactive_arguments(
def weighted_rate(rows: Iterable[dict[str, Any]], amount: str) -> float: def weighted_rate(rows: Iterable[dict[str, Any]], amount: str) -> float:
materialized = list(rows) materialized = list(rows)
seconds = sum(float(row["elapsedSeconds"]) for row in materialized) seconds = sum(float(row["elapsedSeconds"]) for row in materialized)
return sum(float(row[amount]) for row in materialized) / max(0.1, seconds) return sum(float(row.get(amount, 0)) for row in materialized) / max(0.1, seconds)
def mean(values: Iterable[float]) -> float: def mean(values: Iterable[float]) -> float:
@@ -402,9 +529,11 @@ def aggregate(raw: dict[str, Any]) -> dict[str, Any]:
"dps": weighted_rate(rows, "damage"), "dps": weighted_rate(rows, "damage"),
"bossDps": weighted_rate(rows, "bossDamage"), "bossDps": weighted_rate(rows, "bossDamage"),
"hps": weighted_rate(rows, "healing"), "hps": weighted_rate(rows, "healing"),
"hpsReceived": weighted_rate(rows, "healingReceived"),
"dtps": weighted_rate(rows, "damageTaken"), "dtps": weighted_rate(rows, "damageTaken"),
"totalDamage": sum(row["damage"] for row in rows), "totalDamage": sum(row["damage"] for row in rows),
"totalHealing": sum(row["healing"] for row in rows), "totalHealing": sum(row["healing"] for row in rows),
"totalHealingReceived": sum(row.get("healingReceived", 0) for row in rows),
"totalAbsorbs": sum(row["absorbs"] for row in rows), "totalAbsorbs": sum(row["absorbs"] for row in rows),
"deathEvents": sum(row["deathEvents"] for row in rows), "deathEvents": sum(row["deathEvents"] for row in rows),
"minimumHealthPercent": min(row["minimumHealthPercent"] for row in rows), "minimumHealthPercent": min(row["minimumHealthPercent"] for row in rows),
@@ -435,6 +564,7 @@ def aggregate(raw: dict[str, Any]) -> dict[str, Any]:
"dps": weighted_rate(rows, "damage"), "dps": weighted_rate(rows, "damage"),
"bossDps": weighted_rate(rows, "bossDamage"), "bossDps": weighted_rate(rows, "bossDamage"),
"hps": weighted_rate(rows, "healing"), "hps": weighted_rate(rows, "healing"),
"hpsReceived": weighted_rate(rows, "healingReceived"),
"dtps": weighted_rate(rows, "damageTaken"), "dtps": weighted_rate(rows, "damageTaken"),
"deathEvents": sum(row["deathEvents"] for row in rows), "deathEvents": sum(row["deathEvents"] for row in rows),
}) })
@@ -443,6 +573,8 @@ def aggregate(raw: dict[str, Any]) -> dict[str, Any]:
"schemaVersion": 1, "schemaVersion": 1,
"generatedAt": raw["generatedAt"], "generatedAt": raw["generatedAt"],
"engine": raw["engine"], "engine": raw["engine"],
"policyId": raw["policyId"],
"rotationVersion": raw["rotationVersion"],
"config": raw["config"], "config": raw["config"],
"party": raw["party"], "party": raw["party"],
"summary": { "summary": {
@@ -508,6 +640,8 @@ def markdown_report(report: dict[str, Any]) -> str:
"", "",
f"Generated: {report['generatedAt']}", f"Generated: {report['generatedAt']}",
"", "",
f"Policy: `{report['policyId']}` · rotation: `{report['rotationVersion']}`",
"",
f"Party source: **{config['source']}** · level **{config['level']}** · " f"Party source: **{config['source']}** · level **{config['level']}** · "
f"{report['summary']['bosses']} bosses · {report['summary']['runs']} simulations", f"{report['summary']['bosses']} bosses · {report['summary']['runs']} simulations",
"", "",
@@ -516,15 +650,16 @@ def markdown_report(report: dict[str, Any]) -> str:
"", "",
"## Every party member", "## Every party member",
"", "",
"| Slot | Member | Role | DPS | Boss DPS | HPS | DTPS | Role median | Flag | Death events |", "| Slot | Member | Role | DPS | Boss DPS | HPS done | HPS received | DTPS | Role median | Flag | Death events |",
"| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | ---: |", "| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: |",
] ]
for member in report["members"]: for member in report["members"]:
marker = " (active)" if member["activePlayer"] else "" marker = " (active)" if member["activePlayer"] else ""
lines.append( lines.append(
f"| {member['slot']} | {member['className']} — {member['specialization']}{marker} | " f"| {member['slot']} | {member['className']} — {member['specialization']}{marker} | "
f"{member['role']} | {format_number(member['dps'])} | {format_number(member['bossDps'])} | " f"{member['role']} | {format_number(member['dps'])} | {format_number(member['bossDps'])} | "
f"{format_number(member['hps'])} | {format_number(member['dtps'])} | " f"{format_number(member['hps'])} | {format_number(member['hpsReceived'])} | "
f"{format_number(member['dtps'])} | "
f"{format_ratio(member['damageVsRoleMedian'])} | {member['damageFlag']} | {member['deathEvents']} |" f"{format_ratio(member['damageVsRoleMedian'])} | {member['damageFlag']} | {member['deathEvents']} |"
) )
lines.extend([ lines.extend([
@@ -588,12 +723,14 @@ def html_report(report: dict[str, Any]) -> str:
"dps": format_number(row["dps"]), "dps": format_number(row["dps"]),
"bossDps": format_number(row["bossDps"]), "bossDps": format_number(row["bossDps"]),
"hps": format_number(row["hps"]), "hps": format_number(row["hps"]),
"hpsReceived": format_number(row["hpsReceived"]),
"dtps": format_number(row["dtps"]), "dtps": format_number(row["dtps"]),
"damageVsRoleMedian": format_number(row["damageVsRoleMedian"], 2), "damageVsRoleMedian": format_number(row["damageVsRoleMedian"], 2),
} for row in report["members"]] } for row in report["members"]]
member_table = html_table("members", "Every party member", members, ( member_table = html_table("members", "Every party member", members, (
("slot", "Slot"), ("member", "Member"), ("role", "Role"), ("dps", "DPS"), ("slot", "Slot"), ("member", "Member"), ("role", "Role"), ("dps", "DPS"),
("bossDps", "Boss DPS"), ("hps", "HPS"), ("dtps", "DTPS"), ("bossDps", "Boss DPS"), ("hps", "HPS done"), ("hpsReceived", "HPS received"),
("dtps", "DTPS"),
("damageVsRoleMedian", "Role median ×"), ("damageFlag", "Flag"), ("deathEvents", "Death events"), ("damageVsRoleMedian", "Role median ×"), ("damageFlag", "Flag"), ("deathEvents", "Death events"),
)) ))
boss_table = html_table("bosses", "Every boss — highest pressure first", bosses, ( boss_table = html_table("bosses", "Every boss — highest pressure first", bosses, (
@@ -614,7 +751,7 @@ input {{ width:min(420px,90%); padding:9px 12px; margin:0 0 12px; color:inherit;
th,td {{ padding:8px 10px; border-bottom:1px solid #21262d; text-align:right; white-space:nowrap; }} th {{ position:sticky; top:0; cursor:pointer; background:#161b22; }} th,td {{ padding:8px 10px; border-bottom:1px solid #21262d; text-align:right; white-space:nowrap; }} th {{ position:sticky; top:0; cursor:pointer; background:#161b22; }}
th:first-child,td:first-child,th:nth-child(2),td:nth-child(2) {{ text-align:left; }} tr:hover {{ background:#161b22; }} th:first-child,td:first-child,th:nth-child(2),td:nth-child(2) {{ text-align:left; }} tr:hover {{ background:#161b22; }}
</style></head><body><h1>Healer Man Balance Lab</h1> </style></head><body><h1>Healer Man Balance Lab</h1>
<p class="summary">{html.escape(report['generatedAt'])} · level {report['config']['level']} {html.escape(report['config']['source'])} party · {report['summary']['bosses']} bosses · {report['summary']['runs']} simulations. Click a heading to sort; filter boxes keep every row accessible.</p> <p class="summary">{html.escape(report['generatedAt'])} · policy {html.escape(report['policyId'])} · rotation {html.escape(report['rotationVersion'])} · level {report['config']['level']} {html.escape(report['config']['source'])} party · {report['summary']['bosses']} bosses · {report['summary']['runs']} simulations. Click a heading to sort; filter boxes keep every row accessible.</p>
{member_table}{boss_table} {member_table}{boss_table}
<script> <script>
function filterTable(id,q){{q=q.toLowerCase();document.querySelectorAll('#'+id+' tbody tr').forEach(r=>r.hidden=!r.textContent.toLowerCase().includes(q));}} function filterTable(id,q){{q=q.toLowerCase();document.querySelectorAll('#'+id+' tbody tr').forEach(r=>r.hidden=!r.textContent.toLowerCase().includes(q));}}
@@ -635,16 +772,20 @@ def create_reports(raw_path: Path, output_dir: Path) -> dict[str, Any]:
)) ))
write_csv(output_dir / "party_members.csv", report["members"], ( write_csv(output_dir / "party_members.csv", report["members"], (
"slot", "activePlayer", "classId", "className", "specialization", "role", "dps", "bossDps", "slot", "activePlayer", "classId", "className", "specialization", "role", "dps", "bossDps",
"hps", "dtps", "damageVsRoleMedian", "damageFlag", "totalDamage", "totalHealing", "hps", "hpsReceived", "dtps", "damageVsRoleMedian", "damageFlag", "totalDamage", "totalHealing",
"totalAbsorbs", "deathEvents", "minimumHealthPercent", "totalHealingReceived", "totalAbsorbs", "deathEvents", "minimumHealthPercent",
)) ))
write_csv(output_dir / "member_by_boss.csv", report["memberByBoss"], ( write_csv(output_dir / "member_by_boss.csv", report["memberByBoss"], (
"bossKey", "bossName", "dungeonTitle", "slot", "className", "specialization", "role", "bossKey", "bossName", "dungeonTitle", "slot", "className", "specialization", "role",
"dps", "bossDps", "hps", "dtps", "deathEvents", "dps", "bossDps", "hps", "hpsReceived", "dtps", "deathEvents",
)) ))
run_rows = [{key: value for key, value in run.items() if key not in {"members", "bossAbilities"}} for run in raw["runs"]] run_rows = [{
"policyId": raw["policyId"],
"rotationVersion": raw["rotationVersion"],
**{key: value for key, value in run.items() if key not in {"members", "bossAbilities"}},
} for run in raw["runs"]]
write_csv(output_dir / "runs.csv", run_rows, ( write_csv(output_dir / "runs.csv", run_rows, (
"bossKey", "bossName", "bossCategory", "dungeonId", "dungeonTitle", "seed", "outcome", "policyId", "rotationVersion", "bossKey", "bossName", "bossCategory", "dungeonId", "dungeonTitle", "seed", "outcome",
"elapsedSeconds", "timeToDefeatSeconds", "bossLevel", "bossMaxHealth", "bossRemainingHealth", "elapsedSeconds", "timeToDefeatSeconds", "bossLevel", "bossMaxHealth", "bossRemainingHealth",
"partyDamage", "bossDamage", "partyDps", "bossDpsTaken", "incomingDamage", "rawIncomingDamage", "partyDamage", "bossDamage", "partyDps", "bossDpsTaken", "incomingDamage", "rawIncomingDamage",
"incomingDtps", "deaths", "deathEvents", "bossAttacks", "incomingDtps", "deaths", "deathEvents", "bossAttacks",
@@ -680,10 +821,12 @@ def aggregate_class_specs(
"dps": weighted_rate(observations, "damage"), "dps": weighted_rate(observations, "damage"),
"bossDps": weighted_rate(observations, "bossDamage"), "bossDps": weighted_rate(observations, "bossDamage"),
"hps": weighted_rate(observations, "healing"), "hps": weighted_rate(observations, "healing"),
"hpsReceived": weighted_rate(observations, "healingReceived"),
"dtps": weighted_rate(observations, "damageTaken"), "dtps": weighted_rate(observations, "damageTaken"),
"totalDamage": sum(row["damage"] for row in observations), "totalDamage": sum(row["damage"] for row in observations),
"totalDamageTaken": sum(row["damageTaken"] for row in observations), "totalDamageTaken": sum(row["damageTaken"] for row in observations),
"totalHealing": sum(row["healing"] for row in observations), "totalHealing": sum(row["healing"] for row in observations),
"totalHealingReceived": sum(row.get("healingReceived", 0) for row in observations),
"totalAbsorbs": sum(row["absorbs"] for row in observations), "totalAbsorbs": sum(row["absorbs"] for row in observations),
"deathEvents": sum(row["deathEvents"] for row in observations), "deathEvents": sum(row["deathEvents"] for row in observations),
}) })
@@ -787,6 +930,8 @@ def suite_markdown(report: dict[str, Any]) -> str:
"", "",
f"Generated: {report['generatedAt']}", f"Generated: {report['generatedAt']}",
"", "",
f"Policy: `{report['policyId']}` · rotation: `{report['rotationVersion']}`",
"",
f"**{summary['partyConfigurations']} party configurations**, **{summary['bosses']} bosses**, " f"**{summary['partyConfigurations']} party configurations**, **{summary['bosses']} bosses**, "
f"and **{summary['runs']} simulations** at level **{report['level']}**.", f"and **{summary['runs']} simulations** at level **{report['level']}**.",
"", "",
@@ -795,14 +940,15 @@ def suite_markdown(report: dict[str, Any]) -> str:
"", "",
"## Every tested class/spec", "## Every tested class/spec",
"", "",
"| Source | Class/spec | Role | Parties | Runs | DPS | Boss DPS | HPS | DTPS | Total taken | Role median | Flag | Deaths |", "| Source | Class/spec | Role | Parties | Runs | DPS | Boss DPS | HPS done | HPS received | DTPS | Total taken | Role median | Flag | Deaths |",
"| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: |", "| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: |",
] ]
for row in report["classSpecs"]: for row in report["classSpecs"]:
lines.append( lines.append(
f"| {row['source']} | {row['className']} — {row['specialization']} | {row['role']} | " f"| {row['source']} | {row['className']} — {row['specialization']} | {row['role']} | "
f"{row['partyConfigurations']} | {row['runs']} | {format_number(row['dps'])} | " f"{row['partyConfigurations']} | {row['runs']} | {format_number(row['dps'])} | "
f"{format_number(row['bossDps'])} | {format_number(row['hps'])} | {format_number(row['dtps'])} | " f"{format_number(row['bossDps'])} | {format_number(row['hps'])} | "
f"{format_number(row['hpsReceived'])} | {format_number(row['dtps'])} | "
f"{format_number(row['totalDamageTaken'])} | " f"{format_number(row['totalDamageTaken'])} | "
f"{format_ratio(row['damageVsSourceRoleMedian'])} | {row['damageFlag']} | {row['deathEvents']} |" f"{format_ratio(row['damageVsSourceRoleMedian'])} | {row['damageFlag']} | {row['deathEvents']} |"
) )
@@ -863,6 +1009,7 @@ def suite_html(report: dict[str, Any]) -> str:
"dps": format_number(row["dps"]), "dps": format_number(row["dps"]),
"bossDps": format_number(row["bossDps"]), "bossDps": format_number(row["bossDps"]),
"hps": format_number(row["hps"]), "hps": format_number(row["hps"]),
"hpsReceived": format_number(row["hpsReceived"]),
"dtps": format_number(row["dtps"]), "dtps": format_number(row["dtps"]),
"totalDamageTaken": format_number(row["totalDamageTaken"]), "totalDamageTaken": format_number(row["totalDamageTaken"]),
"damageVsSourceRoleMedian": format_ratio(row["damageVsSourceRoleMedian"]), "damageVsSourceRoleMedian": format_ratio(row["damageVsSourceRoleMedian"]),
@@ -901,7 +1048,8 @@ def suite_html(report: dict[str, Any]) -> str:
class_table = html_table("class-specs", "Every tested class/spec", class_specs, ( class_table = html_table("class-specs", "Every tested class/spec", class_specs, (
("source", "Source"), ("classSpec", "Class/spec"), ("role", "Role"), ("source", "Source"), ("classSpec", "Class/spec"), ("role", "Role"),
("partyConfigurations", "Parties"), ("runs", "Runs"), ("dps", "DPS"), ("partyConfigurations", "Parties"), ("runs", "Runs"), ("dps", "DPS"),
("bossDps", "Boss DPS"), ("hps", "HPS"), ("dtps", "DTPS"), ("bossDps", "Boss DPS"), ("hps", "HPS done"), ("hpsReceived", "HPS received"),
("dtps", "DTPS"),
("totalDamageTaken", "Total taken"), ("damageVsSourceRoleMedian", "Role median"), ("totalDamageTaken", "Total taken"), ("damageVsSourceRoleMedian", "Role median"),
("damageFlag", "Flag"), ("deathEvents", "Deaths"), ("damageFlag", "Flag"), ("deathEvents", "Deaths"),
)) ))
@@ -938,7 +1086,7 @@ input {{ width:min(420px,90%); padding:9px 12px; margin:0 0 12px; color:inherit;
th,td {{ padding:8px 10px; border-bottom:1px solid #21262d; text-align:right; white-space:nowrap; }} th {{ position:sticky; top:0; cursor:pointer; background:#161b22; }} th,td {{ padding:8px 10px; border-bottom:1px solid #21262d; text-align:right; white-space:nowrap; }} th {{ position:sticky; top:0; cursor:pointer; background:#161b22; }}
th:first-child,td:first-child,th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3) {{ text-align:left; }} tr:hover {{ background:#161b22; }} th:first-child,td:first-child,th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3) {{ text-align:left; }} tr:hover {{ background:#161b22; }}
</style></head><body><h1>Healer Man Automatic Balance Suite</h1> </style></head><body><h1>Healer Man Automatic Balance Suite</h1>
<p class="summary">{html.escape(report['generatedAt'])} · level {report['level']} · {summary['partyConfigurations']} parties · {summary['bosses']} bosses · {summary['runs']} simulations. Every row is retained; click headings to sort or filter each table.</p> <p class="summary">{html.escape(report['generatedAt'])} · policy {html.escape(report['policyId'])} · rotation {html.escape(report['rotationVersion'])} · level {report['level']} · {summary['partyConfigurations']} parties · {summary['bosses']} bosses · {summary['runs']} simulations. Every row is retained; click headings to sort or filter each table.</p>
{class_table}{ability_table}{party_table}{boss_table} {class_table}{ability_table}{party_table}{boss_table}
<script> <script>
function filterTable(id,q){{q=q.toLowerCase();document.querySelectorAll('#'+id+' tbody tr').forEach(r=>r.hidden=!r.textContent.toLowerCase().includes(q));}} function filterTable(id,q){{q=q.toLowerCase();document.querySelectorAll('#'+id+' tbody tr').forEach(r=>r.hidden=!r.textContent.toLowerCase().includes(q));}}
@@ -953,13 +1101,19 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
class_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list) class_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
class_boss_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list) class_boss_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
class_ability_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list) class_ability_groups: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
policy_ids: set[str] = set()
rotation_versions: set[str] = set()
for entry in entries: for entry in entries:
raw = json.loads(Path(entry["rawPath"]).read_text(encoding="utf-8")) raw = json.loads(Path(entry["rawPath"]).read_text(encoding="utf-8"))
policy_ids.add(raw["policyId"])
rotation_versions.add(raw["rotationVersion"])
report = entry["report"] report = entry["report"]
runs = raw["runs"] runs = raw["runs"]
compact_runs.extend({ compact_runs.extend({
"partyConfigurationId": entry["id"], "partyConfigurationId": entry["id"],
"partySource": entry["source"], "partySource": entry["source"],
"policyId": raw["policyId"],
"rotationVersion": raw["rotationVersion"],
**{key: value for key, value in run.items() if key not in {"members", "bossAbilities"}}, **{key: value for key, value in run.items() if key not in {"members", "bossAbilities"}},
} for run in runs) } for run in runs)
defeated = [run for run in runs if run["outcome"] == "defeated"] defeated = [run for run in runs if run["outcome"] == "defeated"]
@@ -1048,6 +1202,7 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
"dps": weighted_rate(observations, "damage"), "dps": weighted_rate(observations, "damage"),
"bossDps": weighted_rate(observations, "bossDamage"), "bossDps": weighted_rate(observations, "bossDamage"),
"hps": weighted_rate(observations, "healing"), "hps": weighted_rate(observations, "healing"),
"hpsReceived": weighted_rate(observations, "healingReceived"),
"dtps": weighted_rate(observations, "damageTaken"), "dtps": weighted_rate(observations, "damageTaken"),
"totalDamageTaken": sum(row["damageTaken"] for row in observations), "totalDamageTaken": sum(row["damageTaken"] for row in observations),
"deathEvents": sum(row["deathEvents"] for row in observations), "deathEvents": sum(row["deathEvents"] for row in observations),
@@ -1057,10 +1212,14 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
)) ))
party_rows.sort(key=lambda row: (row["source"], row["partyConfigurationId"])) party_rows.sort(key=lambda row: (row["source"], row["partyConfigurationId"]))
boss_by_party.sort(key=lambda row: (row["partyConfigurationId"], -row["incomingDtps"], row["bossName"])) boss_by_party.sort(key=lambda row: (row["partyConfigurationId"], -row["incomingDtps"], row["bossName"]))
if len(policy_ids) != 1 or len(rotation_versions) != 1:
raise RuntimeError("Balance Lab suite mixed combat policy or rotation versions")
report = { report = {
"schemaVersion": 1, "schemaVersion": 1,
"generatedAt": datetime.now().astimezone().isoformat(), "generatedAt": datetime.now().astimezone().isoformat(),
"engine": "live-typescript-runtime", "engine": "live-typescript-runtime",
"policyId": next(iter(policy_ids)),
"rotationVersion": next(iter(rotation_versions)),
"level": level, "level": level,
"summary": { "summary": {
"partyConfigurations": len(entries), "partyConfigurations": len(entries),
@@ -1086,8 +1245,8 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
)) ))
write_csv(output_dir / "class_specs_across_parties.csv", class_specs, ( write_csv(output_dir / "class_specs_across_parties.csv", class_specs, (
"source", "buildId", "classId", "className", "specialization", "role", "partyConfigurations", "source", "buildId", "classId", "className", "specialization", "role", "partyConfigurations",
"runs", "dps", "bossDps", "hps", "dtps", "damageVsSourceRoleMedian", "damageFlag", "runs", "dps", "bossDps", "hps", "hpsReceived", "dtps", "damageVsSourceRoleMedian", "damageFlag",
"totalDamage", "totalDamageTaken", "totalHealing", "totalAbsorbs", "deathEvents", "totalDamage", "totalDamageTaken", "totalHealing", "totalHealingReceived", "totalAbsorbs", "deathEvents",
)) ))
write_csv(output_dir / "class_abilities_across_parties.csv", class_abilities, ( write_csv(output_dir / "class_abilities_across_parties.csv", class_abilities, (
"source", "buildId", "classId", "className", "specialization", "role", "abilityId", "source", "buildId", "classId", "className", "specialization", "role", "abilityId",
@@ -1109,11 +1268,11 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
)) ))
write_csv(output_dir / "class_spec_by_boss.csv", class_spec_by_boss, ( write_csv(output_dir / "class_spec_by_boss.csv", class_spec_by_boss, (
"source", "buildId", "classId", "className", "specialization", "role", "bossKey", "bossName", "source", "buildId", "classId", "className", "specialization", "role", "bossKey", "bossName",
"dungeonTitle", "partyConfigurations", "runs", "dps", "bossDps", "hps", "dtps", "dungeonTitle", "partyConfigurations", "runs", "dps", "bossDps", "hps", "hpsReceived", "dtps",
"totalDamageTaken", "deathEvents", "totalDamageTaken", "deathEvents",
)) ))
write_csv(output_dir / "suite_runs.csv", compact_runs, ( write_csv(output_dir / "suite_runs.csv", compact_runs, (
"partyConfigurationId", "partySource", "bossKey", "bossName", "bossCategory", "dungeonId", "partyConfigurationId", "partySource", "policyId", "rotationVersion", "bossKey", "bossName", "bossCategory", "dungeonId",
"dungeonTitle", "seed", "outcome", "dungeonTitle", "seed", "outcome",
"elapsedSeconds", "timeToDefeatSeconds", "bossLevel", "bossMaxHealth", "bossRemainingHealth", "elapsedSeconds", "timeToDefeatSeconds", "bossLevel", "bossMaxHealth", "bossRemainingHealth",
"partyDamage", "bossDamage", "partyDps", "bossDpsTaken", "incomingDamage", "rawIncomingDamage", "partyDamage", "bossDamage", "partyDps", "bossDpsTaken", "incomingDamage", "rawIncomingDamage",
@@ -1127,6 +1286,10 @@ def parser() -> argparse.ArgumentParser:
result.add_argument("--source", choices=("wow", "coa", "rom", "all"), help="party catalog; all requires automatic mode") result.add_argument("--source", choices=("wow", "coa", "rom", "all"), help="party catalog; all requires automatic mode")
result.add_argument("--level", type=int, default=60, help="party level (default: 60)") result.add_argument("--level", type=int, default=60, help="party level (default: 60)")
result.add_argument("--party-mode", choices=("random", "manual", "automatic"), default="random") result.add_argument("--party-mode", choices=("random", "manual", "automatic"), default="random")
result.add_argument("--spec-selection", choices=("random", "all", "specific"), default="random",
help="automatic party strategy (default: random)")
result.add_argument("--spec", action="append", default=[],
help="required build ID or class:spec selector for --spec-selection specific")
result.add_argument("--party-seed", type=int, default=1, help="random party seed") result.add_argument("--party-seed", type=int, default=1, help="random party seed")
result.add_argument("--party-configurations", type=int, default=10, help="automatic parties per selected source") result.add_argument("--party-configurations", type=int, default=10, help="automatic parties per selected source")
result.add_argument("--member", action="append", default=[], help="manual build ID or class:spec selector; repeat five times") result.add_argument("--member", action="append", default=[], help="manual build ID or class:spec selector; repeat five times")
@@ -1183,7 +1346,25 @@ def main(argv: Sequence[str] | None = None) -> int:
seeds = args.seeds or list(range(1, args.simulations_per_party + 1)) seeds = args.seeds or list(range(1, args.simulations_per_party + 1))
if args.party_mode == "automatic": if args.party_mode == "automatic":
sources = ("wow", "coa", "rom") if args.source == "all" else (args.source,) sources = ("wow", "coa", "rom") if args.source == "all" else (args.source,)
configurations = automatic_parties(builds, sources, args.party_configurations, args.party_seed) if args.spec_selection != "specific" and args.spec:
session.close()
raise SystemExit("--spec values require --spec-selection specific")
if args.spec_selection == "random":
configurations = automatic_parties(builds, sources, args.party_configurations, args.party_seed)
selected_build_ids: list[str] = []
else:
if args.spec_selection == "specific" and not args.spec:
session.close()
raise SystemExit("--spec-selection specific requires at least one --spec value")
selected_build_ids = list(dict.fromkeys(
resolve_build(selector, builds)["id"] for selector in args.spec
))
configurations = coverage_parties(
builds,
sources,
None if args.spec_selection == "all" else selected_build_ids,
args.party_seed,
)
if args.active_slot is not None: if args.active_slot is not None:
if not 1 <= args.active_slot <= 5: if not 1 <= args.active_slot <= 5:
session.close() session.close()
@@ -1223,11 +1404,16 @@ def main(argv: Sequence[str] | None = None) -> int:
if automatic: if automatic:
write_json(output_dir / "suite-config.json", { write_json(output_dir / "suite-config.json", {
"schemaVersion": 1, "schemaVersion": 1,
"policyId": catalog["policyId"],
"rotationVersion": catalog["rotationVersion"],
"mode": "automatic", "mode": "automatic",
"specSelection": args.spec_selection,
"selectedBuildIds": selected_build_ids,
"source": args.source, "source": args.source,
"level": args.level, "level": args.level,
"partySeed": args.party_seed, "partySeed": args.party_seed,
"partyConfigurationsPerSource": args.party_configurations, "partyConfigurationsPerSource": args.party_configurations if args.spec_selection == "random" else None,
"partyConfigurations": len(configurations),
"simulationsPerParty": len(seeds), "simulationsPerParty": len(seeds),
"seeds": seeds, "seeds": seeds,
"durationSeconds": args.duration, "durationSeconds": args.duration,
+2
View File
@@ -6,6 +6,7 @@ import {
loadCanonicalBenchmarkBuilds, loadCanonicalBenchmarkBuilds,
} from "../../src/game/benchmark/benchmarkBuilds"; } from "../../src/game/benchmark/benchmarkBuilds";
import { BENCHMARK_POLICY_ID } from "../../src/game/benchmark/benchmarkController"; import { BENCHMARK_POLICY_ID } from "../../src/game/benchmark/benchmarkController";
import { COMBAT_ROTATION_VERSION } from "../../src/game/combatRotation";
import { import {
renderBenchmarkMatrixMarkdown, renderBenchmarkMatrixMarkdown,
serializeBenchmarkMatrixCsv, serializeBenchmarkMatrixCsv,
@@ -154,6 +155,7 @@ async function main(): Promise<void> {
seeds: options.seeds, seeds: options.seeds,
durationScale: options.durationScale, durationScale: options.durationScale,
policyId: BENCHMARK_POLICY_ID, policyId: BENCHMARK_POLICY_ID,
rotationVersion: COMBAT_ROTATION_VERSION,
}, },
scenarios, scenarios,
builds, builds,
+50 -11
View File
@@ -25,6 +25,19 @@ const CLASS_SOURCES = Object.freeze({
druid: { classMask: 1024, skillLines: [574, 134, 573] }, druid: { classMask: 1024, skillLines: [574, 134, 573] },
}); });
const CLASS_BY_TALENT_TAB = new Map([
[161, "warrior"], [164, "warrior"], [163, "warrior"],
[382, "paladin"], [383, "paladin"], [381, "paladin"],
[361, "hunter"], [363, "hunter"], [362, "hunter"],
[182, "rogue"], [181, "rogue"], [183, "rogue"],
[201, "priest"], [202, "priest"], [203, "priest"],
[398, "death-knight"], [399, "death-knight"], [400, "death-knight"],
[261, "shaman"], [263, "shaman"], [262, "shaman"],
[81, "mage"], [41, "mage"], [61, "mage"],
[302, "warlock"], [303, "warlock"], [301, "warlock"],
[283, "druid"], [281, "druid"], [282, "druid"],
]);
// Triggered subspells can appear on a class skill line even though the player // Triggered subspells can appear on a class skill line even though the player
// never learns them directly. Their owning active spell/talent remains present. // never learns them directly. Their owning active spell/talent remains present.
const TRIGGERED_ONLY_NAMES = new Set([ const TRIGGERED_ONLY_NAMES = new Set([
@@ -202,16 +215,19 @@ function parseSpells(buffer, castTimes, durations, radii, ranges, icons) {
return spells; return spells;
} }
function talentSpellIds(buffer) { function talentNodesBySpellId(buffer) {
assertDbc(buffer, "Talent.dbc", 23); assertDbc(buffer, "Talent.dbc", 23);
const count = buffer.readUInt32LE(4); const count = buffer.readUInt32LE(4);
const recordSize = buffer.readUInt32LE(12); const recordSize = buffer.readUInt32LE(12);
const result = new Set(); const result = new Map();
for (let index = 0; index < count; index += 1) { for (let index = 0; index < count; index += 1) {
const base = 20 + index * recordSize; const base = 20 + index * recordSize;
const talentNodeId = `talent-${buffer.readUInt32LE(base)}`;
const classId = CLASS_BY_TALENT_TAB.get(buffer.readUInt32LE(base + 4));
if (!classId) continue;
for (let rank = 0; rank < 9; rank += 1) { for (let rank = 0; rank < 9; rank += 1) {
const spellId = buffer.readUInt32LE(base + (4 + rank) * 4); const spellId = buffer.readUInt32LE(base + (4 + rank) * 4);
if (spellId) result.add(spellId); if (spellId) result.set(spellId, { talentNodeId, classId });
} }
} }
return result; return result;
@@ -271,7 +287,10 @@ function slimRank(spell) {
castTimeMs: spell.castTimeMs, castTimeMs: spell.castTimeMs,
cooldownMs: Math.max(spell.recoveryTimeMs, spell.categoryRecoveryTimeMs), cooldownMs: Math.max(spell.recoveryTimeMs, spell.categoryRecoveryTimeMs),
gcdMs: spell.startRecoveryTimeMs || 1500, gcdMs: spell.startRecoveryTimeMs || 1500,
durationMs: Math.abs(spell.durationMs), // SpellDuration.dbc uses negative durations for permanent/toggled effects.
// Preserve those as zero so the runtime converts them to a non-expiring aura
// instead of a one-millisecond aura that the AI immediately recasts.
durationMs: Math.max(0, spell.durationMs),
channelDurationMs: spell.channelInterruptFlags ? Math.max(0, spell.durationMs) : 0, channelDurationMs: spell.channelInterruptFlags ? Math.max(0, spell.durationMs) : 0,
powerType: spell.powerType, powerType: spell.powerType,
powerCost: spell.powerCost, powerCost: spell.powerCost,
@@ -285,6 +304,15 @@ function slimRank(spell) {
}; };
} }
function iconPath(iconBasename) {
const candidates = [
`/assets/ui/spells/${iconBasename}.png`,
`/assets/ui/talents/icons/${iconBasename}.png`,
];
return candidates.find((candidate) => fs.existsSync(path.join(projectRoot, "public", candidate.slice(1))))
?? "/assets/ui/spells/inv_misc_questionmark.png";
}
const archive = MpqArchive.open(archivePath); const archive = MpqArchive.open(archivePath);
let result; let result;
try { try {
@@ -300,7 +328,8 @@ try {
rangeTable(extract("SpellRange")), rangeTable(extract("SpellRange")),
stringTable(extract("SpellIcon")), stringTable(extract("SpellIcon")),
); );
const talentIds = talentSpellIds(talentBuffer); const talentNodeBySpellId = talentNodesBySpellId(talentBuffer);
const talentIds = new Set(talentNodeBySpellId.keys());
const passiveTalentNames = new Set( const passiveTalentNames = new Set(
[...talentIds] [...talentIds]
.map((spellId) => spells.get(spellId)) .map((spellId) => spells.get(spellId))
@@ -309,19 +338,24 @@ try {
); );
const classes = {}; const classes = {};
for (const [classId, source] of Object.entries(CLASS_SOURCES)) { for (const [classId, source] of Object.entries(CLASS_SOURCES)) {
const selected = [...classSpellIds(skillLineBuffer, source)] const classSpellSet = classSpellIds(skillLineBuffer, source);
.map((spellId) => spells.get(spellId)) const activeTalentSpellIds = new Set(
[...talentNodeBySpellId.entries()]
.filter(([, owner]) => owner.classId === classId)
.map(([spellId]) => spellId),
);
const selected = [...spells.values()]
.filter((spell) => ( .filter((spell) => (
spell spell
&& spell.name && spell.name
&& spell.spellLevel > 0 && (classSpellSet.has(spell.id) || activeTalentSpellIds.has(spell.id))
&& (spell.spellLevel > 0 || talentIds.has(spell.id))
&& spell.spellLevel <= 80 && spell.spellLevel <= 80
&& (spell.attributes[0] & 0x40) === 0 && (spell.attributes[0] & 0x40) === 0
)); ));
const talentNames = new Set(selected.filter((spell) => talentIds.has(spell.id)).map((spell) => spell.name));
const chains = new Map(); const chains = new Map();
for (const spell of selected) { for (const spell of selected) {
if (talentNames.has(spell.name) || TRIGGERED_ONLY_NAMES.has(spell.name)) continue; if (TRIGGERED_ONLY_NAMES.has(spell.name)) continue;
const ranks = chains.get(spell.name) ?? []; const ranks = chains.get(spell.name) ?? [];
ranks.push(spell); ranks.push(spell);
chains.set(spell.name, ranks); chains.set(spell.name, ranks);
@@ -334,6 +368,9 @@ try {
|| left.id - right.id || left.id - right.id
)); ));
const first = ranks[0]; const first = ranks[0];
const talentNodeId = chainSpells
.map((spell) => talentNodeBySpellId.get(spell.id)?.talentNodeId)
.find(Boolean);
// Passive talents sometimes publish a separate, non-passive proc spell // Passive talents sometimes publish a separate, non-passive proc spell
// with the same name on the class skill line. That proc has no player // with the same name on the class skill line. That proc has no player
// activation mechanics, so retain it for the spellbook but classify it // activation mechanics, so retain it for the spellbook but classify it
@@ -349,8 +386,10 @@ try {
id: `wow335-${classId}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`, id: `wow335-${classId}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`,
name, name,
iconBasename: first.iconBasename, iconBasename: first.iconBasename,
sourceUnlockLevel: Math.min(...ranks.map((spell) => spell.spellLevel)), iconPath: iconPath(first.iconBasename),
sourceUnlockLevel: Math.max(1, Math.min(...ranks.map((spell) => spell.spellLevel))),
...(passive ? { passive: true } : {}), ...(passive ? { passive: true } : {}),
...(talentNodeId ? { talentNodeId } : {}),
ranks: ranks.map(slimRank), ranks: ranks.map(slimRank),
}; };
}).sort((left, right) => ( }).sort((left, right) => (
+43 -11
View File
@@ -52,9 +52,27 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
await balanceLab.click(); await balanceLab.click();
await page.locator('.balance-lab-screen').waitFor(); await page.locator('.balance-lab-screen').waitFor();
await page.getByText('Server runtime ready').waitFor();
assert.match(await page.locator('.balance-lab-runtime').innerText(), /Python 3\./);
assert.match(await page.locator('.balance-lab-runtime').innerText(), /0\/4 workers active/);
await page.getByLabel('Party source').selectOption('wow'); await page.getByLabel('Party source').selectOption('wow');
await page.getByLabel('Level').fill('20'); await page.getByLabel('Level coverage').selectOption('brackets');
await page.getByLabel('Parties per source').fill('1'); assert.equal(await page.getByLabel('Bracket spacing').inputValue(), '10');
assert.equal(await page.locator('.balance-lab-estimate span').filter({ hasText: 'Level jobs' }).locator('b').innerText(), '9');
await page.getByLabel('Bracket spacing').selectOption('5');
assert.equal(await page.locator('.balance-lab-estimate span').filter({ hasText: 'Level jobs' }).locator('b').innerText(), '17');
await page.getByLabel('Bracket spacing').selectOption('1');
assert.equal(await page.locator('.balance-lab-estimate span').filter({ hasText: 'Level jobs' }).locator('b').innerText(), '80');
await page.getByLabel('Bracket spacing').selectOption('10');
await capture('02-level-batch-config');
await page.getByLabel('Level coverage').selectOption('single');
await page.getByLabel('Level', { exact: true }).fill('20');
await page.getByLabel('Class/spec selection').selectOption('all');
assert(Number(await page.getByLabel('Coverage parties').inputValue()) > 1, 'all-spec coverage should derive multiple parties');
await page.getByLabel('Class/spec selection').selectOption('specific');
await page.getByLabel('Search Balance Lab class specs').fill('Paladin Holy');
await page.getByRole('checkbox', { name: /Paladin · Holy/ }).check();
assert.equal(await page.getByLabel('Coverage parties').inputValue(), '1');
await page.getByLabel('Runs per party/boss').fill('1'); await page.getByLabel('Runs per party/boss').fill('1');
await page.getByLabel('Fight limit (seconds)').fill('5'); await page.getByLabel('Fight limit (seconds)').fill('5');
await page.getByLabel('Bosses').selectOption('specific'); await page.getByLabel('Bosses').selectOption('specific');
@@ -63,19 +81,19 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
const totalSimulations = page.locator('.balance-lab-estimate span').filter({ hasText: 'Total simulations' }).locator('b'); const totalSimulations = page.locator('.balance-lab-estimate span').filter({ hasText: 'Total simulations' }).locator('b');
await totalSimulations.waitFor(); await totalSimulations.waitFor();
assert.equal(await totalSimulations.innerText(), '1'); assert.equal(await totalSimulations.innerText(), '1');
await capture('02-server-job-config'); await capture('03-server-job-config');
await page.getByRole('button', { name: 'Run on server' }).click(); await page.getByRole('button', { name: 'Run on server' }).click();
await page.locator('.balance-lab-job-list em.is-complete').waitFor({ timeout: 240_000 }); await page.locator('.balance-lab-job-list em.is-complete').waitFor({ timeout: 240_000 });
await page.getByRole('button', { name: 'Open full report' }).waitFor(); await page.getByRole('button', { name: 'Open full report' }).waitFor();
assert.equal(await page.locator('.balance-lab-artifacts > button').count(), 12); assert.equal(await page.locator('.balance-lab-artifacts > button').count(), 12);
await capture('03-server-job-complete'); await capture('04-server-job-complete');
await page.getByRole('button', { name: 'View graphs and filterable data' }).click(); await page.getByRole('button', { name: 'View graphs and filterable data' }).click();
await page.locator('.balance-results').waitFor(); await page.locator('.balance-results').waitFor();
assert.equal(await page.locator('.balance-results-chart').count(), 5); assert.equal(await page.locator('.balance-results-chart').count(), 7);
assert.match(await page.locator('.balance-results-chart').filter({ hasText: 'Boss damage to party' }).innerText(), /Eck the Ferocious/); assert.match(await page.locator('.balance-results-chart').filter({ hasText: 'Boss damage to party' }).innerText(), /Eck the Ferocious/);
await capture('04-run-analytics'); await capture('05-run-analytics');
await page.getByRole('button', { name: 'bosses', exact: true }).click(); await page.getByRole('button', { name: 'bosses', exact: true }).click();
assert.equal(await page.locator('.balance-results-table-panel tbody tr').count(), 1); assert.equal(await page.locator('.balance-results-table-panel tbody tr').count(), 1);
@@ -89,14 +107,28 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
const damageRows = await page.locator('.balance-results-table-panel tbody tr').count(); const damageRows = await page.locator('.balance-results-table-panel tbody tr').count();
assert(damageRows > 0 && damageRows < allClassRows, 'role filtering should narrow the complete class/spec table'); assert(damageRows > 0 && damageRows < allClassRows, 'role filtering should narrow the complete class/spec table');
await page.getByLabel('Class role').selectOption('all'); await page.getByLabel('Class role').selectOption('all');
await page.getByRole('button', { name: 'abilities', exact: true }).click(); const damageSpecRow = page.locator('.balance-results-table-panel tbody tr').filter({
has: page.locator('td:nth-child(3)').getByText('damage', { exact: true }),
}).first();
const damageSpecClass = await damageSpecRow.locator('td:first-child strong').innerText();
const damageSpecName = await damageSpecRow.locator('td:first-child small').innerText();
await damageSpecRow.getByRole('button', { name: `View ${damageSpecClass} ${damageSpecName} ability damage` }).click();
await page.getByRole('heading', { name: `${damageSpecClass} · ${damageSpecName} ability damage` }).waitFor();
assert.notEqual(await page.getByLabel('Filter ability class spec').inputValue(), 'all');
assert.equal(await page.getByLabel('Filter ability usage').inputValue(), 'damage');
const abilityRows = page.locator('.balance-results-abilities tbody tr'); const abilityRows = page.locator('.balance-results-abilities tbody tr');
assert(await abilityRows.count() > 0, 'ability breakdown should retain observed and available abilities'); assert(await abilityRows.count() > 0, 'spec drill-down should show abilities that dealt damage');
assert(Number((await abilityRows.first().locator('td:nth-child(12)').innerText()).replaceAll(',', '')) > 0, 'drilled ability rows should include effective damage');
const abilityHeaders = await page.locator('.balance-results-abilities thead').innerText();
assert.match(abilityHeaders, /CASTS/);
assert.match(abilityHeaders, /EFFECTIVE DAMAGE/);
assert.match(abilityHeaders, /DAMAGE SHARE/);
assert.match(abilityHeaders, /DPS/);
await page.getByLabel('Filter ability usage').selectOption('unused'); await page.getByLabel('Filter ability usage').selectOption('unused');
assert(await abilityRows.count() > 0, 'available but unused abilities should remain visible'); assert(await abilityRows.count() > 0, 'available but unused abilities should remain visible');
await page.getByLabel('Filter ability usage').selectOption('all'); await page.getByLabel('Filter ability usage').selectOption('all');
await page.getByLabel('Sort abilities').selectOption('effectiveDamage'); await page.getByLabel('Sort abilities').selectOption('effectiveDamage');
await capture('04b-ability-breakdown'); await capture('05b-ability-breakdown');
await page.getByRole('button', { name: 'parties', exact: true }).click(); await page.getByRole('button', { name: 'parties', exact: true }).click();
assert.equal(await page.locator('.balance-results-table-panel tbody tr').count(), 1); assert.equal(await page.locator('.balance-results-table-panel tbody tr').count(), 1);
await page.getByLabel('Search Balance Lab run history').fill('does-not-match'); await page.getByLabel('Search Balance Lab run history').fill('does-not-match');
@@ -111,11 +143,11 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
await page.getByRole('button', { name: 'overview', exact: true }).click(); await page.getByRole('button', { name: 'overview', exact: true }).click();
await page.getByRole('heading', { name: 'Boss damage to party' }).waitFor(); await page.getByRole('heading', { name: 'Boss damage to party' }).waitFor();
await page.getByRole('button', { name: 'Main menu' }).waitFor(); await page.getByRole('button', { name: 'Main menu' }).waitFor();
await capture('05-mobile-analytics'); await capture('06-mobile-analytics');
await page.getByRole('button', { name: 'abilities', exact: true }).click(); await page.getByRole('button', { name: 'abilities', exact: true }).click();
await page.getByText('Every class/spec ability').scrollIntoViewIfNeeded(); await page.getByText('Every class/spec ability').scrollIntoViewIfNeeded();
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
await capture('06-mobile-abilities'); await capture('07-mobile-abilities');
assert.deepEqual(pageErrors, []); assert.deepEqual(pageErrors, []);
console.log('Balance Lab GM playtest passed'); console.log('Balance Lab GM playtest passed');
} finally { } finally {
+32
View File
@@ -2,6 +2,7 @@ const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primar
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const selectedBossName = process.env.BOSS_TEST_BOSS || 'Lady Anacondra'; const selectedBossName = process.env.BOSS_TEST_BOSS || 'Lady Anacondra';
const directory = path.resolve(process.env.BOSS_TEST_ARTIFACT_DIR || path.join( const directory = path.resolve(process.env.BOSS_TEST_ARTIFACT_DIR || path.join(
'playtest-artifacts', 'playtest-artifacts',
@@ -38,6 +39,30 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
await page.screenshot({ path: path.join(directory, `${name}.png`) }); await page.screenshot({ path: path.join(directory, `${name}.png`) });
console.log(`Captured ${name}`); console.log(`Captured ${name}`);
}; };
const canvasHash = async () => {
const canvas = page.locator('canvas');
await canvas.evaluate((element) => {
const hidden = [];
let current = element;
while (current.parentElement) {
for (const sibling of current.parentElement.children) {
if (sibling === current || !(sibling instanceof HTMLElement)) continue;
hidden.push([sibling, sibling.style.visibility]);
sibling.style.visibility = 'hidden';
}
current = current.parentElement;
}
window.__restoreCanvasCapture = () => {
for (const [node, visibility] of hidden) node.style.visibility = visibility;
delete window.__restoreCanvasCapture;
};
});
try {
return crypto.createHash('sha256').update(await canvas.screenshot()).digest('hex');
} finally {
await page.evaluate(() => window.__restoreCanvasCapture?.());
}
};
try { try {
await page.goto(base, { waitUntil: 'domcontentloaded' }); await page.goto(base, { waitUntil: 'domcontentloaded' });
console.log('Production menu loaded'); console.log('Production menu loaded');
@@ -66,6 +91,13 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
return game.assetStatus === 'ready' && game.bossTest?.runtimeIds.every((id) => useCombatStore.getState().mobs[id]); return game.assetStatus === 'ready' && game.bossTest?.runtimeIds.every((id) => useCombatStore.getState().mobs[id]);
}, null, { timeout: 90000 }); }, null, { timeout: 90000 });
await page.waitForTimeout(4000); await page.waitForTimeout(4000);
const bossFrameBefore = await canvasHash();
await page.waitForTimeout(500);
assert.notEqual(
await canvasHash(),
bossFrameBefore,
'Boss Testing redraws the WebGL scene independently from its HUD and nameplates',
);
await capture('03-empty-room'); await capture('03-empty-room');
const before = await page.evaluate(async () => { const before = await page.evaluate(async () => {
const { useGameStore, useCombatStore, useShellStore } = await window.bossTestingStores(); const { useGameStore, useCombatStore, useShellStore } = await window.bossTestingStores();
+100 -7
View File
@@ -1,5 +1,6 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright'); const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
@@ -18,10 +19,46 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
const browser = await chromium.launch({ channel: 'msedge', headless: true }); const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, acceptDownloads: true }); const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, acceptDownloads: true });
const page = await context.newPage(); const page = await context.newPage();
const cameraModuleUrls = fs.readdirSync('dist/assets')
.filter((file) => /^(?:index|GameRuntime)-.*\.js$/.test(file))
.map((file) => `/assets/${file}`);
await page.addInitScript((moduleUrls) => {
window.readSpectatorCameraState = async () => {
const values = (await Promise.all(moduleUrls.map((url) => import(url)))).flatMap(Object.values);
const game = values.find((value) => value?.getState && 'cameraYaw' in value.getState() && 'activeDungeonId' in value.getState());
if (!game) throw new Error('Game camera store export was not found.');
return { yaw: game.getState().cameraYaw, pointerLocked: document.pointerLockElement !== null };
};
}, cameraModuleUrls);
page.setDefaultTimeout(90000); page.setDefaultTimeout(90000);
const errors = []; const errors = [];
page.on('pageerror', (error) => errors.push(error.message)); page.on('pageerror', (error) => errors.push(error.message));
const capture = (name) => page.screenshot({ path: path.join(directory, `${name}.png`), fullPage: true }); const capture = (name) => page.screenshot({ path: path.join(directory, `${name}.png`), fullPage: true });
const captureCanvas = async (name) => {
const canvas = page.locator('canvas');
await canvas.evaluate((element) => {
const hidden = [];
let current = element;
while (current.parentElement) {
for (const sibling of current.parentElement.children) {
if (sibling === current || !(sibling instanceof HTMLElement)) continue;
hidden.push([sibling, sibling.style.visibility]);
sibling.style.visibility = 'hidden';
}
current = current.parentElement;
}
window.__restoreCanvasCapture = () => {
for (const [node, visibility] of hidden) node.style.visibility = visibility;
delete window.__restoreCanvasCapture;
};
});
try {
const image = await canvas.screenshot(name ? { path: path.join(directory, `${name}.png`) } : undefined);
return crypto.createHash('sha256').update(image).digest('hex');
} finally {
await page.evaluate(() => window.__restoreCanvasCapture?.());
}
};
try { try {
await page.goto('http://127.0.0.1:4189', { waitUntil: 'domcontentloaded' }); await page.goto('http://127.0.0.1:4189', { waitUntil: 'domcontentloaded' });
@@ -55,21 +92,77 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
await page.getByRole('button', { name: 'Step' }).click(); await page.getByRole('button', { name: 'Step' }).click();
await page.waitForTimeout(200); await page.waitForTimeout(200);
assert.notEqual(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedTime, 'single-step advances simulation time'); assert.notEqual(await page.locator('.dungeon-sim-hud__header > time').innerText(), pausedTime, 'single-step advances simulation time');
await page.getByRole('button', { name: 'Follow camera' }).click(); const memberPicker = page.getByLabel('Spectate party member');
await page.getByRole('button', { name: 'Free-fly camera' }).waitFor(); const memberButtons = memberPicker.locator('div > button');
await capture('03-paused-free-fly'); assert.equal(await memberButtons.count(), 5, 'all five simulated party members are spectatable');
const chosenName = await memberButtons.nth(2).locator('strong').innerText();
await memberButtons.nth(2).click();
assert.equal(await memberButtons.nth(2).getAttribute('aria-pressed'), 'true');
await page.waitForTimeout(300);
await capture('03-follow-selected-member');
const beforeFollowArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
await page.keyboard.down('ArrowLeft');
await page.waitForTimeout(300);
await page.keyboard.up('ArrowLeft');
const afterFollowArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
assert.notEqual(afterFollowArrowLook.yaw, beforeFollowArrowLook.yaw, 'ArrowLeft orbits the follow camera');
await page.getByRole('button', { name: 'Free camera' }).click();
assert.equal(await page.getByRole('button', { name: 'Free camera' }).getAttribute('aria-pressed'), 'true');
const beforeFlight = await captureCanvas('03-canvas-before-flight');
await page.keyboard.down('w');
await page.waitForTimeout(700);
await page.keyboard.up('w');
await page.waitForTimeout(150);
const afterFlight = await captureCanvas();
assert.notEqual(afterFlight, beforeFlight, 'free-camera movement redraws the 3D dungeon without relying on nameplate overlays');
const beforeArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
await page.keyboard.down('ArrowRight');
await page.waitForTimeout(300);
await page.keyboard.up('ArrowRight');
const afterArrowLook = await page.evaluate(() => window.readSpectatorCameraState());
assert.notEqual(afterArrowLook.yaw, beforeArrowLook.yaw, 'ArrowRight turns the free camera');
const canvasBounds = await page.locator('canvas').boundingBox();
assert(canvasBounds, 'simulation canvas is visible');
const lookStart = { x: canvasBounds.x + canvasBounds.width * 0.5, y: canvasBounds.y + canvasBounds.height * 0.55 };
const beforeLook = await page.evaluate(() => window.readSpectatorCameraState());
await page.mouse.move(lookStart.x, lookStart.y);
await page.mouse.down({ button: 'right' });
await page.mouse.move(lookStart.x + 220, lookStart.y + 100, { steps: 12 });
await page.waitForTimeout(250);
const duringLook = await page.evaluate(() => window.readSpectatorCameraState());
assert.notEqual(duringLook.yaw, beforeLook.yaw, 'right-drag rotates the free camera');
assert.equal(duringLook.pointerLocked, false, 'right-drag does not request Pointer Lock');
await page.mouse.up({ button: 'right' });
const releasedLook = await page.evaluate(() => window.readSpectatorCameraState());
await page.mouse.move(lookStart.x - 120, lookStart.y + 50, { steps: 8 });
await page.waitForTimeout(250);
assert.equal(
(await page.evaluate(() => window.readSpectatorCameraState())).yaw,
releasedLook.yaw,
'mouse movement after right-button release does not rotate the camera',
);
await page.locator('canvas').screenshot({ path: path.join(directory, '04-canvas-after-flight-look.png') });
await capture('04-paused-free-camera-moved');
await memberButtons.nth(4).click();
assert.equal(await page.getByRole('button', { name: 'Follow member' }).getAttribute('aria-pressed'), 'true');
assert.match(await memberPicker.locator('header strong').innerText(), /\S/);
assert.notEqual(await memberPicker.locator('header strong').innerText(), chosenName);
await page.waitForTimeout(300);
await capture('05-follow-another-member');
await page.getByRole('button', { name: '4×' }).click(); await page.getByRole('button', { name: '4×' }).click();
await page.getByRole('button', { name: 'Play' }).click(); await page.getByRole('button', { name: 'Play' }).click();
await page.waitForTimeout(1200); await page.waitForTimeout(1200);
await page.getByRole('button', { name: 'Stop & save' }).click(); await page.getByRole('button', { name: 'Stop & save' }).click();
await page.getByRole('button', { name: 'Return to GM Tools' }).waitFor(); await page.getByRole('button', { name: 'Return to GM Tools' }).waitFor();
await capture('04-finished-report'); await capture('06-finished-report');
await page.getByRole('button', { name: 'Return to GM Tools' }).click(); await page.getByRole('button', { name: 'Return to GM Tools' }).click();
await page.getByRole('heading', { name: 'Dungeon route simulations' }).waitFor(); await page.getByRole('heading', { name: 'Dungeon route simulations' }).waitFor();
await page.locator('.dungeon-sim-admin__catalog select').nth(0).selectOption('wailing-caverns'); await page.locator('.dungeon-sim-admin__catalog select').nth(0).selectOption('wailing-caverns');
await page.getByText(/1 runs for this route/).waitFor(); await page.getByText(/1 runs for this route/).waitFor();
await capture('05-saved-replay'); await capture('07-saved-replay');
await page.getByRole('button', { name: 'Watch' }).first().click(); await page.getByRole('button', { name: 'Watch' }).first().click();
await page.getByText('Recorded replay').waitFor(); await page.getByText('Recorded replay').waitFor();
@@ -77,7 +170,7 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
await page.waitForTimeout(6000); await page.waitForTimeout(6000);
await page.getByRole('button', { name: 'Play' }).click(); await page.getByRole('button', { name: 'Play' }).click();
await page.waitForTimeout(800); await page.waitForTimeout(800);
await capture('06-replay-controls'); await capture('08-replay-controls');
await page.getByRole('button', { name: 'Pause' }).click(); await page.getByRole('button', { name: 'Pause' }).click();
const pausedReplayTime = await page.locator('.dungeon-sim-hud__header > time').innerText(); const pausedReplayTime = await page.locator('.dungeon-sim-hud__header > time').innerText();
await page.waitForTimeout(500); await page.waitForTimeout(500);
@@ -85,7 +178,7 @@ fs.rmSync(databaseDirectory, { recursive: true, force: true });
await page.getByRole('button', { name: '4×' }).click(); await page.getByRole('button', { name: '4×' }).click();
await page.getByRole('button', { name: 'Play' }).click(); await page.getByRole('button', { name: 'Play' }).click();
await page.waitForTimeout(600); await page.waitForTimeout(600);
await capture('07-replay-4x'); await capture('09-replay-4x');
assert.deepEqual(errors, [], `No browser page errors: ${errors.join('; ')}`); assert.deepEqual(errors, [], `No browser page errors: ${errors.join('; ')}`);
console.log('Dungeon simulation browser smoke test passed.'); console.log('Dungeon simulation browser smoke test passed.');
+32
View File
@@ -1,4 +1,5 @@
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs"); const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const { chromium } = require("C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright"); const { chromium } = require("C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright");
@@ -14,6 +15,31 @@ fs.mkdirSync(directory, { recursive: true });
const errors = []; const errors = [];
page.on("pageerror", (error) => errors.push(error.message)); page.on("pageerror", (error) => errors.push(error.message));
const canvasHash = async () => {
const canvas = page.locator("canvas");
await canvas.evaluate((element) => {
const hidden = [];
let current = element;
while (current.parentElement) {
for (const sibling of current.parentElement.children) {
if (sibling === current || !(sibling instanceof HTMLElement)) continue;
hidden.push([sibling, sibling.style.visibility]);
sibling.style.visibility = "hidden";
}
current = current.parentElement;
}
window.__restoreCanvasCapture = () => {
for (const [node, visibility] of hidden) node.style.visibility = visibility;
delete window.__restoreCanvasCapture;
};
});
try {
return crypto.createHash("sha256").update(await canvas.screenshot()).digest("hex");
} finally {
await page.evaluate(() => window.__restoreCanvasCapture?.());
}
};
const gameState = () => page.evaluate(async () => { const gameState = () => page.evaluate(async () => {
const storeEntry = performance.getEntriesByType("resource") const storeEntry = performance.getEntriesByType("resource")
.findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts"); .findLast((entry) => new URL(entry.name).pathname === "/src/game/store.ts");
@@ -74,6 +100,7 @@ fs.mkdirSync(directory, { recursive: true });
const cdp = await page.context().newCDPSession(page); const cdp = await page.context().newCDPSession(page);
const start = { x: bounds.x + bounds.width * 0.55, y: bounds.y + bounds.height * 0.55 }; const start = { x: bounds.x + bounds.width * 0.55, y: bounds.y + bounds.height * 0.55 };
const before = await gameState(); const before = await gameState();
const frameBefore = await canvasHash();
assert.equal(before.cameraLookActive, false); assert.equal(before.cameraLookActive, false);
await page.mouse.move(start.x, start.y); await page.mouse.move(start.x, start.y);
await page.mouse.down({ button: "right" }); await page.mouse.down({ button: "right" });
@@ -99,6 +126,11 @@ fs.mkdirSync(directory, { recursive: true });
await page.waitForTimeout(350); await page.waitForTimeout(350);
const moved = await gameState(); const moved = await gameState();
assert.notEqual(moved.cameraYaw, before.cameraYaw); assert.notEqual(moved.cameraYaw, before.cameraYaw);
assert.notEqual(
await canvasHash(),
frameBefore,
"regular dungeon camera input redraws the WebGL scene independently from its HUD and nameplates",
);
await page.screenshot({ path: path.join(directory, "right-drag-camera.png") }); await page.screenshot({ path: path.join(directory, "right-drag-camera.png") });
await page.mouse.up({ button: "right" }); await page.mouse.up({ button: "right" });
+240
View File
@@ -0,0 +1,240 @@
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', 'rotation-overhaul');
fs.mkdirSync(directory, { recursive: true });
const databaseDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-rotation-playtest-'));
const partySeed = 'rotation-boss-test-0';
const bossName = 'Lady Anacondra';
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir: databaseDirectory, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(4191, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } });
const page = await context.newPage();
page.setDefaultTimeout(90_000);
const errors = [];
page.on('pageerror', (error) => errors.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 runtimeStores = async () => {
const values = (await Promise.all(urls.map((url) => import(url)))).flatMap(Object.values);
const stores = values.filter((value) => value && typeof value.getState === 'function');
const partyStore = stores.find((store) => {
const state = store.getState();
return Array.isArray(state?.members) && typeof state?.initializeParty === 'function'
&& typeof state?.setCommand === 'function';
});
const combatStore = stores.find((store) => {
const state = store.getState();
return state?.mobs && state?.actors && Array.isArray(state?.combatEvents)
&& typeof state?.synchronizeActors === 'function';
});
const gameStore = stores.find((store) => {
const state = store.getState();
return 'bossTest' in (state ?? {}) && 'activeDungeonId' in (state ?? {});
});
if (!partyStore || !combatStore || !gameStore) throw new Error('Required runtime stores were not found.');
return { partyStore, combatStore, gameStore };
};
window.configureRotationBossParty = async (seed) => {
const { partyStore, combatStore } = await runtimeStores();
const combat = combatStore.getState();
partyStore.getState().initializeParty(seed, combat.level, combat.health, 'damage', 5, 'wow');
partyStore.getState().setCommand('stop');
combatStore.getState().synchronizeActors();
combatStore.getState().applyDefaultPartyBuffs();
combatStore.getState().applyPrecombatPreparation();
};
window.startRotationBossParty = async () => {
const { partyStore } = await runtimeStores();
partyStore.getState().setCommand('attack');
};
window.readRotationPlaytestState = async () => {
const { partyStore, combatStore, gameStore } = await runtimeStores();
const party = partyStore.getState();
const combat = combatStore.getState();
const game = gameStore.getState();
const runtimeIds = game.bossTest?.runtimeIds ?? [];
const boss = runtimeIds.map((id) => combat.mobs[id]).find(Boolean)
?? Object.values(combat.mobs).find((mob) => mob.boss);
const currentActorIds = new Set(['__player__', ...party.members.map((member) => member.id)]);
return {
activeDungeonId: game.activeDungeonId,
boss: boss ? {
id: boss.id,
name: boss.name,
health: boss.health,
maxHealth: boss.maxHealth,
engaged: boss.engaged,
dead: boss.dead,
} : null,
party: party.members.map((member) => ({
id: member.id,
name: member.name,
classId: member.classId,
specialization: member.specialization,
role: member.role,
health: member.health,
maxHealth: member.maxHealth,
status: member.status,
})),
auras: combat.auras.filter((aura) => currentActorIds.has(aura.targetId)).map((aura) => ({
name: aura.definition.name,
exclusiveGroup: aura.definition.exclusiveGroup ?? null,
sourceId: aura.sourceId,
targetId: aura.targetId,
expiresAt: aura.expiresAt,
})),
summons: combat.summons.filter((summon) => currentActorIds.has(summon.ownerActorId)).map((summon) => ({
name: summon.name,
kind: summon.kind,
ownerActorId: summon.ownerActorId,
sourceAbilityId: summon.sourceAbilityId,
expiresAt: summon.expiresAt,
})),
events: combat.combatEvents.map((event) => ({
id: event.id,
kind: event.kind,
sourceActorId: event.sourceActorId,
targetActorId: event.targetActorId,
abilityId: event.abilityId,
effectiveAmount: event.effectiveAmount,
})),
};
};
}, moduleUrls);
try {
await page.goto('http://127.0.0.1:4191', { waitUntil: 'domcontentloaded' });
await page.getByLabel('Username').fill('Phenom');
await page.getByLabel('Password').fill('rotation-overhaul-boss-run');
await page.getByRole('button', { name: /Create account/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByLabel('Specialization').getByRole('button', { name: /Shadow/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('Rotationwatcher');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue/ }).first().click();
await page.getByRole('button', { name: /Boss Testing/ }).click();
await page.getByRole('heading', { name: 'Boss Testing' }).waitFor();
await page.getByLabel('Search bosses').fill(bossName);
const bossPicker = page.locator('.boss-testing-picker select');
await bossPicker.selectOption({ index: 1 });
await page.getByRole('button', { name: 'Test selected boss' }).click();
await page.locator('canvas').waitFor();
await page.waitForTimeout(3_000);
await page.evaluate((seed) => window.configureRotationBossParty(seed), partySeed);
const initial = await page.evaluate(() => window.readRotationPlaytestState());
assert.equal(initial.boss?.name, bossName, 'the requested boss is active in Boss Testing');
assert.equal(initial.party.length, 4, 'the player has a complete four-companion party');
assert(initial.party.some((member) => member.role === 'tank'), 'party includes a tank');
assert(initial.party.some((member) => member.role === 'healer'), 'party includes a healer');
const damage = initial.party.filter((member) => member.role === 'damage');
const ranged = damage.filter((member) => (
['hunter', 'mage', 'priest', 'warlock'].includes(member.classId)
|| (member.classId === 'druid' && member.specialization === 'Balance')
|| (member.classId === 'shaman' && member.specialization === 'Elemental')
));
const melee = damage.filter((member) => !ranged.some((candidate) => candidate.id === member.id));
assert(ranged.length > 0, `party includes ranged damage: ${JSON.stringify(damage)}`);
assert(melee.length > 0, `party includes melee damage: ${JSON.stringify(damage)}`);
const paladin = initial.party.find((member) => member.classId === 'paladin');
assert(paladin, `party includes the expected paladin setup check: ${JSON.stringify(initial.party)}`);
assert(initial.auras.some((aura) => (
aura.targetId === paladin.id && aura.exclusiveGroup === 'paladin:seal'
)), `paladin seal is active before the attack command: ${JSON.stringify(initial.auras)}`);
await page.evaluate(() => window.startRotationBossParty());
const eventKeys = new Set(initial.events.map((event) => `${event.id}:${event.sourceActorId}:${event.abilityId}`));
const observedEvents = [];
const collect = (state) => {
for (const event of state.events) {
const key = `${event.id}:${event.sourceActorId}:${event.abilityId}`;
if (eventKeys.has(key)) continue;
eventKeys.add(key);
observedEvents.push(event);
}
};
let engaged = null;
const engagementDeadline = Date.now() + 30_000;
while (Date.now() < engagementDeadline) {
const state = await page.evaluate(() => window.readRotationPlaytestState());
collect(state);
if (state.boss?.engaged && !state.boss.dead && state.boss.health < state.boss.maxHealth) {
engaged = state;
break;
}
await sleep(50);
}
assert(engaged, 'the companion party engages Lady Anacondra');
await page.screenshot({ path: path.join(directory, 'boss-engaged.png'), fullPage: true });
const canvasImage = await page.locator('canvas').screenshot({ path: path.join(directory, 'boss-engaged-canvas.png') });
assert(canvasImage.length > 10_000, 'boss screenshot contains a rendered WebGL scene');
let completed = null;
const completionDeadline = Date.now() + 90_000;
while (Date.now() < completionDeadline) {
const state = await page.evaluate(() => window.readRotationPlaytestState());
collect(state);
if (state.boss?.dead || state.boss?.health === 0) {
completed = state;
break;
}
await sleep(50);
}
assert(completed, 'the companion party defeats Lady Anacondra');
await page.waitForTimeout(250);
await page.screenshot({ path: path.join(directory, 'boss-defeated.png'), fullPage: true });
const partyIds = new Set(initial.party.map((member) => member.id));
const partyEvents = observedEvents.filter((event) => partyIds.has(event.sourceActorId));
for (const member of [...initial.party.filter((candidate) => candidate.role === 'tank'), ...melee, ...ranged]) {
assert(partyEvents.some((event) => event.sourceActorId === member.id && event.kind === 'damage'
&& event.abilityId && event.abilityId !== 'party-auto-attack'), `${member.name} uses a damaging ability`);
}
for (const healer of initial.party.filter((member) => member.role === 'healer')) {
assert(partyEvents.some((event) => event.sourceActorId === healer.id
&& ['damage', 'healing'].includes(event.kind) && event.abilityId), `${healer.name} takes an active combat action`);
}
assert.deepEqual(errors, [], `No browser page errors: ${errors.join('; ')}`);
const results = {
dungeon: 'Wailing Caverns — Boss Testing',
boss: bossName,
partySeed,
engagedBoss: engaged.boss,
defeatedBoss: completed.boss,
party: initial.party,
precombatAuras: initial.auras,
precombatSummons: initial.summons,
observedAbilityEvents: partyEvents.filter((event) => event.abilityId !== 'party-auto-attack'),
browserErrors: errors,
};
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify(results, null, 2)}\n`);
console.log(JSON.stringify(results, null, 2));
console.log('Rotation-overhaul dungeon boss playtest passed.');
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+132 -13
View File
@@ -1,4 +1,4 @@
"""Visual, fail-closed release manager for Healer Man. """Visual release manager for Healer Man with explicit safety gates.
The GUI intentionally leaves the TrueNAS portion as copyable shell commands: The GUI intentionally leaves the TrueNAS portion as copyable shell commands:
the app must be stopped and started from the TrueNAS Apps UI around that block. the app must be stopped and started from the TrueNAS Apps UI around that block.
@@ -18,6 +18,7 @@ import sys
import tempfile import tempfile
import threading import threading
import webbrowser import webbrowser
from collections import deque
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable, Iterable, Sequence from typing import Callable, Iterable, Sequence
@@ -59,6 +60,17 @@ FORBIDDEN_SUFFIXES = {
ALLOWED_GENERATED_FILES = { ALLOWED_GENERATED_FILES = {
"public/data/healer-man-loot.sqlite", "public/data/healer-man-loot.sqlite",
} }
OVERRIDABLE_TEST_LABELS = frozenset(
{
"Content tests",
"Server tests",
"Application tests",
"Shelved-mode application tests",
}
)
ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
FAILURE_OUTPUT_LINE_LIMIT = 160
FAILURE_OUTPUT_CHARACTER_LIMIT = 20_000
class ReleaseError(RuntimeError): class ReleaseError(RuntimeError):
@@ -69,6 +81,23 @@ class ReleaseCancelled(ReleaseError):
"""The user cancelled an active release.""" """The user cancelled an active release."""
class ReleaseCommandError(ReleaseError):
"""A release subprocess failed and retained its output for review."""
def __init__(
self,
label: str,
command: Sequence[str],
return_code: int,
output: str,
) -> None:
super().__init__(f"{label} failed with exit code {return_code}.")
self.label = label
self.command = tuple(command)
self.return_code = return_code
self.output = output
@dataclass(frozen=True) @dataclass(frozen=True)
class ReleaseVersion: class ReleaseVersion:
version_name: str version_name: str
@@ -88,6 +117,36 @@ def full_release_checks(npm: str) -> tuple[tuple[list[str], str], ...]:
) )
def can_override_release_check(label: str) -> bool:
"""Return whether the interactive release flow may continue after a failure."""
return label in OVERRIDABLE_TEST_LABELS
def format_failed_test_warning(failure: ReleaseCommandError) -> str:
"""Build the detailed warning shown before overriding a failed test command."""
clean_output = ANSI_ESCAPE_PATTERN.sub("", failure.output).strip()
output_lines = clean_output.splitlines()
omitted_lines = max(0, len(output_lines) - FAILURE_OUTPUT_LINE_LIMIT)
if omitted_lines:
output_lines = output_lines[-FAILURE_OUTPUT_LINE_LIMIT:]
recent_output = "\n".join(output_lines)
if len(recent_output) > FAILURE_OUTPUT_CHARACTER_LIMIT:
recent_output = recent_output[-FAILURE_OUTPUT_CHARACTER_LIMIT:]
recent_output = "… earlier output omitted …\n" + recent_output
elif omitted_lines:
recent_output = f"… {omitted_lines} earlier line(s) omitted …\n" + recent_output
if not recent_output:
recent_output = "(The command produced no output.)"
command = subprocess.list2cmdline(list(failure.command))
return (
f"{failure.label} failed with exit code {failure.return_code}.\n\n"
f"Command:\n{command}\n\n"
f"Recent output:\n{recent_output}\n\n"
"Continuing can publish a release with known test failures. "
"Only continue if you reviewed the failure and accept that risk."
)
def parse_semver(value: str) -> tuple[int, int, int]: def parse_semver(value: str) -> tuple[int, int, int]:
match = SEMVER_PATTERN.fullmatch(value.strip()) match = SEMVER_PATTERN.fullmatch(value.strip())
if not match: if not match:
@@ -1116,7 +1175,25 @@ def launch_gui(*, smoke_test: bool = False) -> int:
) )
for command, label in full_release_checks(npm): for command, label in full_release_checks(npm):
self._run_command(command, label) try:
self._run_command(command, label)
except ReleaseCommandError as failure:
if not can_override_release_check(label):
raise
approved = self._request_approval(
format_failed_test_warning(failure),
title=f"Warning: {label} failed",
heading="Tests failed — continue the release?",
approve_label="Continue Anyway",
cancel_label="Stop Release",
)
if not approved:
raise ReleaseCancelled(
f"Release stopped because {label} failed."
) from failure
self._log_event(
f"⚠ Continuing after failed {label}; override approved by user."
)
self._run_command(["git", "add", "-A"], "Stage release") self._run_command(["git", "add", "-A"], "Stage release")
staged = True staged = True
@@ -1267,31 +1344,66 @@ def launch_gui(*, smoke_test: bool = False) -> int:
) )
self.current_process = process self.current_process = process
assert process.stdout is not None assert process.stdout is not None
output_lines: deque[str] = deque(maxlen=FAILURE_OUTPUT_LINE_LIMIT)
try: try:
for line in process.stdout: for line in process.stdout:
self._log_event(line.rstrip("\n")) rendered = line.rstrip("\n")
output_lines.append(rendered)
self._log_event(rendered)
return_code = process.wait() return_code = process.wait()
finally: finally:
self.current_process = None self.current_process = None
if self.cancel_event.is_set(): if self.cancel_event.is_set():
raise ReleaseCancelled("Release cancelled.") raise ReleaseCancelled("Release cancelled.")
if return_code != 0: if return_code != 0:
raise ReleaseError(f"{label} failed with exit code {return_code}.") raise ReleaseCommandError(
label,
args,
return_code,
"\n".join(output_lines),
)
self._log_event(f"✓ {label} passed") self._log_event(f"✓ {label} passed")
def _request_approval(self, review: str) -> bool: def _request_approval(
self,
review: str,
*,
title: str = "Review staged release",
heading: str = "Inspect every staged path before publishing",
approve_label: str = "Commit, Push, and Publish",
cancel_label: str = "Cancel release",
) -> bool:
response: dict[str, bool] = {} response: dict[str, bool] = {}
ready = threading.Event() ready = threading.Event()
self.events.put(("approval", review, response, ready)) self.events.put(
(
"approval",
review,
title,
heading,
approve_label,
cancel_label,
response,
ready,
)
)
while not ready.wait(0.2): while not ready.wait(0.2):
if self.cancel_event.is_set(): if self.cancel_event.is_set():
return False return False
return response.get("approved", False) return response.get("approved", False)
def _show_staged_review(self, review: str) -> bool: def _show_approval(
self,
review: str,
*,
title: str,
heading: str,
approve_label: str,
cancel_label: str,
) -> bool:
decision = {"approved": False} decision = {"approved": False}
dialog = tk.Toplevel(self.root) dialog = tk.Toplevel(self.root)
dialog.title("Review staged release") dialog.title(title)
dialog.geometry("820x640") dialog.geometry("820x640")
dialog.minsize(640, 420) dialog.minsize(640, 420)
dialog.configure(bg=self.BG) dialog.configure(bg=self.BG)
@@ -1301,7 +1413,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
dialog.columnconfigure(0, weight=1) dialog.columnconfigure(0, weight=1)
ttk.Label( ttk.Label(
dialog, dialog,
text="Inspect every staged path before publishing", text=heading,
style="Title.TLabel", style="Title.TLabel",
).grid(row=0, column=0, sticky="w", padx=18, pady=(16, 10)) ).grid(row=0, column=0, sticky="w", padx=18, pady=(16, 10))
review_text = scrolledtext.ScrolledText( review_text = scrolledtext.ScrolledText(
@@ -1326,12 +1438,12 @@ def launch_gui(*, smoke_test: bool = False) -> int:
dialog.grab_release() dialog.grab_release()
dialog.destroy() dialog.destroy()
ttk.Button(buttons, text="Cancel release", command=lambda: finish(False)).pack( ttk.Button(buttons, text=cancel_label, command=lambda: finish(False)).pack(
side="left" side="left"
) )
ttk.Button( ttk.Button(
buttons, buttons,
text="Commit, Push, and Publish", text=approve_label,
style="Accent.TButton", style="Accent.TButton",
command=lambda: finish(True), command=lambda: finish(True),
).pack(side="right") ).pack(side="right")
@@ -1375,8 +1487,15 @@ def launch_gui(*, smoke_test: bool = False) -> int:
if kind == "log": if kind == "log":
self._append_log(str(event[1])) self._append_log(str(event[1]))
elif kind == "approval": elif kind == "approval":
review, response, ready = event[1], event[2], event[3] review, title, heading, approve_label, cancel_label = event[1:6]
approved = self._show_staged_review(str(review)) response, ready = event[6], event[7]
approved = self._show_approval(
str(review),
title=str(title),
heading=str(heading),
approve_label=str(approve_label),
cancel_label=str(cancel_label),
)
assert isinstance(response, dict) assert isinstance(response, dict)
response["approved"] = approved response["approved"] = approved
assert isinstance(ready, threading.Event) assert isinstance(ready, threading.Event)
+33
View File
@@ -54,6 +54,39 @@ class SafetyTests(unittest.TestCase):
self.assertLess(publish_index, commands.index(["npm", "run", "build"])) self.assertLess(publish_index, commands.index(["npm", "run", "build"]))
self.assertLess(publish_index, commands.index(["npm", "run", "android:sync"])) self.assertLess(publish_index, commands.index(["npm", "run", "android:sync"]))
def test_only_test_failures_may_be_overridden(self):
checks = release_manager.full_release_checks("npm")
overridable = {
label for _command, label in checks
if release_manager.can_override_release_check(label)
}
self.assertEqual(
overridable,
{
"Content tests",
"Server tests",
"Application tests",
"Shelved-mode application tests",
},
)
self.assertFalse(release_manager.can_override_release_check("Production build"))
self.assertFalse(release_manager.can_override_release_check("Push main"))
def test_failed_test_warning_includes_command_and_specific_output(self):
failure = release_manager.ReleaseCommandError(
"Application tests",
["npm", "test"],
1,
"\x1b[31mFAIL\x1b[39m src/game/example.test.ts\n"
"Error: expected 2 to be 3",
)
warning = release_manager.format_failed_test_warning(failure)
self.assertIn("Application tests failed with exit code 1", warning)
self.assertIn("npm test", warning)
self.assertIn("FAIL src/game/example.test.ts", warning)
self.assertIn("Error: expected 2 to be 3", warning)
self.assertNotIn("\x1b", warning)
class ReleaseCandidateTests(unittest.TestCase): class ReleaseCandidateTests(unittest.TestCase):
def test_checks_untracked_files_without_changing_the_real_index(self): def test_checks_untracked_files_without_changing_the_real_index(self):
+44 -3
View File
@@ -4,6 +4,7 @@ from scripts.balance_lab import (
aggregate_class_abilities, aggregate_class_abilities,
aggregate_class_specs, aggregate_class_specs,
automatic_parties, automatic_parties,
coverage_parties,
random_party, random_party,
search_bosses, search_bosses,
tuning_label, tuning_label,
@@ -57,6 +58,44 @@ class BalanceLabControllerTests(unittest.TestCase):
"tank", "healer", "damage", "damage", "damage", "tank", "healer", "damage", "damage", "damage",
]) ])
def test_coverage_parties_include_every_build_with_minimum_unique_parties(self) -> None:
builds = [
{"id": "tank-a", "source": "wow", "role": "tank"},
{"id": "tank-b", "source": "wow", "role": "tank"},
{"id": "healer-a", "source": "wow", "role": "healer"},
{"id": "healer-b", "source": "wow", "role": "healer"},
*({"id": f"damage-{index}", "source": "wow", "role": "damage"} for index in range(7)),
]
first = coverage_parties(builds, ("wow",), None, 19)
second = coverage_parties(builds, ("wow",), None, 19)
self.assertEqual(first, second)
self.assertEqual(len(first), 3)
covered = {member["id"] for configuration in first for member in configuration["party"]}
self.assertEqual(covered, {build["id"] for build in builds})
signatures = {tuple(member["id"] for member in configuration["party"]) for configuration in first}
self.assertEqual(len(signatures), 3)
for configuration in first:
self.assertEqual([member["role"] for member in configuration["party"]], [
"tank", "healer", "damage", "damage", "damage",
])
self.assertEqual(len({member["id"] for member in configuration["party"]}), 5)
def test_specific_coverage_uses_fillers_but_guarantees_selected_builds(self) -> None:
builds = [
{"id": "tank-a", "source": "wow", "role": "tank"},
{"id": "tank-b", "source": "wow", "role": "tank"},
{"id": "healer-a", "source": "wow", "role": "healer"},
{"id": "healer-b", "source": "wow", "role": "healer"},
*({"id": f"damage-{index}", "source": "wow", "role": "damage"} for index in range(5)),
]
selected = ["tank-a", "tank-b", "damage-4"]
configurations = coverage_parties(builds, ("wow",), selected, 7)
self.assertEqual(len(configurations), 2)
observed = [member["id"] for configuration in configurations for member in configuration["party"]]
covered = set(observed)
self.assertTrue(set(selected).issubset(covered))
self.assertTrue(all(observed.count(build_id) == 1 for build_id in selected))
def test_boss_search_matches_name_dungeon_key_and_source(self) -> None: def test_boss_search_matches_name_dungeon_key_and_source(self) -> None:
bosses = [ bosses = [
{"key": "gundrak/eck", "name": "Eck the Ferocious", "dungeonTitle": "Gundrak", "category": "wow"}, {"key": "gundrak/eck", "name": "Eck the Ferocious", "dungeonTitle": "Gundrak", "category": "wow"},
@@ -66,23 +105,25 @@ class BalanceLabControllerTests(unittest.TestCase):
self.assertEqual([boss["key"] for boss in search_bosses("forsaken", bosses)], ["abbey/duke"]) self.assertEqual([boss["key"] for boss in search_bosses("forsaken", bosses)], ["abbey/duke"])
self.assertEqual([boss["key"] for boss in search_bosses("rom", bosses)], ["abbey/duke"]) self.assertEqual([boss["key"] for boss in search_bosses("rom", bosses)], ["abbey/duke"])
def test_class_spec_aggregate_keeps_rate_and_absolute_damage_taken(self) -> None: def test_class_spec_aggregate_keeps_damage_and_healing_received_metrics(self) -> None:
key = ("wow", "priest-holy", "priest", "Priest", "Holy", "healer") key = ("wow", "priest-holy", "priest", "Priest", "Holy", "healer")
rows = aggregate_class_specs({key: [ rows = aggregate_class_specs({key: [
{ {
"partyConfigurationId": "wow-party-001", "elapsedSeconds": 10, "partyConfigurationId": "wow-party-001", "elapsedSeconds": 10,
"damage": 200, "bossDamage": 180, "healing": 500, "damage": 200, "bossDamage": 180, "healing": 500, "healingReceived": 300,
"damageTaken": 120, "absorbs": 25, "deathEvents": 0, "damageTaken": 120, "absorbs": 25, "deathEvents": 0,
}, },
{ {
"partyConfigurationId": "wow-party-002", "elapsedSeconds": 20, "partyConfigurationId": "wow-party-002", "elapsedSeconds": 20,
"damage": 400, "bossDamage": 360, "healing": 900, "damage": 400, "bossDamage": 360, "healing": 900, "healingReceived": 600,
"damageTaken": 330, "absorbs": 50, "deathEvents": 1, "damageTaken": 330, "absorbs": 50, "deathEvents": 1,
}, },
]}) ]})
self.assertEqual(len(rows), 1) self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["dtps"], 15) self.assertEqual(rows[0]["dtps"], 15)
self.assertEqual(rows[0]["totalDamageTaken"], 450) self.assertEqual(rows[0]["totalDamageTaken"], 450)
self.assertEqual(rows[0]["hpsReceived"], 30)
self.assertEqual(rows[0]["totalHealingReceived"], 900)
def test_ability_aggregate_keeps_used_and_available_but_unused_rows(self) -> None: def test_ability_aggregate_keeps_used_and_available_but_unused_rows(self) -> None:
class_key = ("wow", "mage-fire", "mage", "Mage", "Fire", "damage") class_key = ("wow", "mage-fire", "mage", "Mage", "Fire", "damage")
+179 -49
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process"; import { spawn, spawnSync } from "node:child_process";
import { import {
createReadStream, createReadStream,
existsSync, existsSync,
@@ -15,7 +15,9 @@ import { GameDatabaseError } from "./database.mjs";
const SOURCES = new Set(["wow", "coa", "rom", "all"]); const SOURCES = new Set(["wow", "coa", "rom", "all"]);
const BOSS_CATEGORIES = new Set(["all", "wow", "rom"]); const BOSS_CATEGORIES = new Set(["all", "wow", "rom"]);
const SPEC_SELECTIONS = new Set(["random", "all", "specific"]);
const JOB_STATUSES = new Set(["queued", "running", "complete", "failed"]); const JOB_STATUSES = new Set(["queued", "running", "complete", "failed"]);
const MAX_BATCH_JOBS = 80;
const JOB_METADATA_NAME = "job.json"; const JOB_METADATA_NAME = "job.json";
const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const ARTIFACTS = new Map([ const ARTIFACTS = new Map([
@@ -43,12 +45,16 @@ function integer(value, name, minimum, maximum) {
export function normalizeBalanceLabJobSpec(input) { export function normalizeBalanceLabJobSpec(input) {
const source = String(input?.source ?? "all").toLowerCase(); const source = String(input?.source ?? "all").toLowerCase();
const bossCategory = String(input?.bossCategory ?? "all").toLowerCase(); const bossCategory = String(input?.bossCategory ?? "all").toLowerCase();
const specSelection = String(input?.specSelection ?? "random").toLowerCase();
if (!SOURCES.has(source)) { if (!SOURCES.has(source)) {
throw new GameDatabaseError("Party source must be wow, coa, rom, or all.", 400, "invalid_balance_lab_job"); throw new GameDatabaseError("Party source must be wow, coa, rom, or all.", 400, "invalid_balance_lab_job");
} }
if (!BOSS_CATEGORIES.has(bossCategory)) { if (!BOSS_CATEGORIES.has(bossCategory)) {
throw new GameDatabaseError("Boss category must be all, wow, or rom.", 400, "invalid_balance_lab_job"); throw new GameDatabaseError("Boss category must be all, wow, or rom.", 400, "invalid_balance_lab_job");
} }
if (!SPEC_SELECTIONS.has(specSelection)) {
throw new GameDatabaseError("Class/spec selection must be random, all, or specific.", 400, "invalid_balance_lab_job");
}
const rawBossKeys = Array.isArray(input?.bossKeys) ? input.bossKeys : []; const rawBossKeys = Array.isArray(input?.bossKeys) ? input.bossKeys : [];
if (rawBossKeys.length > 500) { if (rawBossKeys.length > 500) {
throw new GameDatabaseError("Select no more than 500 bosses.", 400, "invalid_balance_lab_job"); throw new GameDatabaseError("Select no more than 500 bosses.", 400, "invalid_balance_lab_job");
@@ -57,8 +63,21 @@ export function normalizeBalanceLabJobSpec(input) {
if (bossKeys.some((key) => key.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(key))) { if (bossKeys.some((key) => key.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(key))) {
throw new GameDatabaseError("A selected boss key is invalid.", 400, "invalid_balance_lab_job"); throw new GameDatabaseError("A selected boss key is invalid.", 400, "invalid_balance_lab_job");
} }
const rawBuildIds = Array.isArray(input?.selectedBuildIds) ? input.selectedBuildIds : [];
if (rawBuildIds.length > 200) {
throw new GameDatabaseError("Select no more than 200 class/spec builds.", 400, "invalid_balance_lab_job");
}
const selectedBuildIds = [...new Set(rawBuildIds.map((value) => String(value).trim()).filter(Boolean))];
if (selectedBuildIds.some((id) => id.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(id))) {
throw new GameDatabaseError("A selected class/spec build ID is invalid.", 400, "invalid_balance_lab_job");
}
if (specSelection === "specific" && selectedBuildIds.length === 0) {
throw new GameDatabaseError("Select at least one class/spec build.", 400, "invalid_balance_lab_job");
}
const spec = Object.freeze({ const spec = Object.freeze({
source, source,
specSelection,
selectedBuildIds: Object.freeze(specSelection === "specific" ? selectedBuildIds : []),
level: integer(input?.level ?? 60, "Level", 1, 80), level: integer(input?.level ?? 60, "Level", 1, 80),
partyConfigurations: integer(input?.partyConfigurations ?? 10, "Party configurations", 1, 100), partyConfigurations: integer(input?.partyConfigurations ?? 10, "Party configurations", 1, 100),
simulationsPerParty: integer(input?.simulationsPerParty ?? 3, "Simulations per party", 1, 100), simulationsPerParty: integer(input?.simulationsPerParty ?? 3, "Simulations per party", 1, 100),
@@ -69,7 +88,12 @@ export function normalizeBalanceLabJobSpec(input) {
}); });
const sourceCount = spec.source === "all" ? 3 : 1; const sourceCount = spec.source === "all" ? 3 : 1;
const bossEstimate = Math.max(1, spec.bossKeys.length || (spec.bossCategory === "all" ? 500 : 300)); const bossEstimate = Math.max(1, spec.bossKeys.length || (spec.bossCategory === "all" ? 500 : 300));
const estimatedRuns = sourceCount * spec.partyConfigurations * spec.simulationsPerParty * bossEstimate; const partyEstimate = spec.specSelection === "random"
? sourceCount * spec.partyConfigurations
: spec.specSelection === "specific"
? Math.max(spec.partyConfigurations, spec.selectedBuildIds.length)
: Math.max(spec.partyConfigurations, spec.source === "all" ? 100 : 50);
const estimatedRuns = partyEstimate * spec.simulationsPerParty * bossEstimate;
if (estimatedRuns > 250_000) { if (estimatedRuns > 250_000) {
throw new GameDatabaseError( throw new GameDatabaseError(
"That job is too large for one server request. Reduce parties, simulations, or selected bosses.", "That job is too large for one server request. Reduce parties, simulations, or selected bosses.",
@@ -80,6 +104,18 @@ export function normalizeBalanceLabJobSpec(input) {
return spec; return spec;
} }
export function normalizeBalanceLabJobBatch(input) {
const rawSpecs = Array.isArray(input?.specs) ? input.specs : [];
if (rawSpecs.length < 1 || rawSpecs.length > MAX_BATCH_JOBS) {
throw new GameDatabaseError(
`A Balance Lab batch must contain 1 through ${MAX_BATCH_JOBS} level jobs.`,
400,
"invalid_balance_lab_batch",
);
}
return Object.freeze(rawSpecs.map((spec) => normalizeBalanceLabJobSpec(spec)));
}
export function balanceLabCommand(spec, outputDir, options = {}) { export function balanceLabCommand(spec, outputDir, options = {}) {
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url))); const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
const executable = String(options.pythonExecutable const executable = String(options.pythonExecutable
@@ -90,6 +126,7 @@ export function balanceLabCommand(spec, outputDir, options = {}) {
"--source", spec.source, "--source", spec.source,
"--level", String(spec.level), "--level", String(spec.level),
"--party-mode", "automatic", "--party-mode", "automatic",
"--spec-selection", spec.specSelection,
"--party-seed", String(spec.partySeed), "--party-seed", String(spec.partySeed),
"--party-configurations", String(spec.partyConfigurations), "--party-configurations", String(spec.partyConfigurations),
"--simulations-per-party", String(spec.simulationsPerParty), "--simulations-per-party", String(spec.simulationsPerParty),
@@ -97,10 +134,41 @@ export function balanceLabCommand(spec, outputDir, options = {}) {
"--boss-category", spec.bossCategory, "--boss-category", spec.bossCategory,
"--output-dir", outputDir, "--output-dir", outputDir,
]; ];
for (const buildId of spec.selectedBuildIds) args.push("--spec", buildId);
for (const bossKey of spec.bossKeys) args.push("--boss", bossKey); for (const bossKey of spec.bossKeys) args.push("--boss", bossKey);
return { executable, args, cwd: projectRoot }; return { executable, args, cwd: projectRoot };
} }
export function inspectBalanceLabRuntime(executable, run = spawnSync) {
const command = String(executable || (process.platform === "win32" ? "python" : "python3"));
try {
const result = run(command, ["--version"], {
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
});
const version = `${result?.stdout ?? ""} ${result?.stderr ?? ""}`.trim();
if (result?.status === 0 && /^Python 3\./i.test(version)) {
return { available: true, executable: command, version, error: null };
}
return {
available: false,
executable: command,
version: version || null,
error: version && /^Python 2\./i.test(version)
? "Balance Lab requires Python 3; this server resolved Python 2."
: `The server could not execute ${command} as Python 3.`,
};
} catch (error) {
return {
available: false,
executable: command,
version: null,
error: `The server could not execute ${command}: ${error?.message ?? error}`,
};
}
}
function publicJob(job) { function publicJob(job) {
return { return {
id: job.id, id: job.id,
@@ -137,6 +205,12 @@ function reportSummary(outputDir) {
} }
} }
function expectedPartyConfigurations(spec) {
return spec.specSelection === "random"
? spec.partyConfigurations * (spec.source === "all" ? 3 : 1)
: spec.partyConfigurations;
}
function lineCollector(onLine) { function lineCollector(onLine) {
let pending = ""; let pending = "";
return { return {
@@ -157,22 +231,35 @@ export function createBalanceLabJobService(options = {}) {
const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url))); const projectRoot = path.resolve(options.projectRoot ?? fileURLToPath(new URL("..", import.meta.url)));
const dataDir = path.resolve(options.dataDir ?? process.env.DATA_DIR ?? "data/runtime"); const dataDir = path.resolve(options.dataDir ?? process.env.DATA_DIR ?? "data/runtime");
const jobsDir = path.join(dataDir, "balance-lab", "jobs"); const jobsDir = path.join(dataDir, "balance-lab", "jobs");
const configuredConcurrency = Number(options.maxConcurrent ?? process.env.BALANCE_LAB_MAX_CONCURRENT ?? 1); const configuredConcurrency = Number(options.maxConcurrent ?? process.env.BALANCE_LAB_MAX_CONCURRENT ?? 4);
const maxConcurrent = Number.isFinite(configuredConcurrency) && configuredConcurrency >= 1 const maxConcurrent = Number.isFinite(configuredConcurrency) && configuredConcurrency >= 1
? Math.min(4, Math.floor(configuredConcurrency)) ? Math.min(16, Math.floor(configuredConcurrency))
: 1; : 4;
const configuredQueueLimit = Number(options.maxQueued ?? process.env.BALANCE_LAB_MAX_QUEUED ?? 8); const configuredQueueLimit = Number(options.maxQueued ?? process.env.BALANCE_LAB_MAX_QUEUED ?? 100);
const maxQueued = Number.isFinite(configuredQueueLimit) && configuredQueueLimit >= 1 const maxQueued = Number.isFinite(configuredQueueLimit) && configuredQueueLimit >= 1
? Math.min(50, Math.floor(configuredQueueLimit)) ? Math.min(200, Math.floor(configuredQueueLimit))
: 8; : 100;
const launch = options.spawnProcess ?? spawn; const launch = options.spawnProcess ?? spawn;
const pythonExecutable = String(options.pythonExecutable
?? process.env.BALANCE_LAB_PYTHON
?? (process.platform === "win32" ? "python" : "python3"));
const runtimeProbe = options.runtimeProbe
?? (options.spawnProcess
? () => ({ available: true, executable: pythonExecutable, version: "test process", error: null })
: () => inspectBalanceLabRuntime(pythonExecutable));
const jobs = new Map(); const jobs = new Map();
const queue = []; const queue = [];
const children = new Map(); const children = new Map();
let running = 0; let running = 0;
let closed = false; let closed = false;
let inspectedRuntime = null;
mkdirSync(jobsDir, { recursive: true }); mkdirSync(jobsDir, { recursive: true });
function currentRuntime() {
inspectedRuntime ??= runtimeProbe();
return inspectedRuntime;
}
function persistJob(job) { function persistJob(job) {
const record = publicJob(job); const record = publicJob(job);
writeFileSync(path.join(job.outputDir, JOB_METADATA_NAME), `${JSON.stringify(record, null, 2)}\n`, "utf8"); writeFileSync(path.join(job.outputDir, JOB_METADATA_NAME), `${JSON.stringify(record, null, 2)}\n`, "utf8");
@@ -199,8 +286,12 @@ export function createBalanceLabJobService(options = {}) {
requestedBy: "unknown (legacy run)", requestedBy: "unknown (legacy run)",
spec: { spec: {
source: legacyConfig.source, source: legacyConfig.source,
specSelection: legacyConfig.specSelection ?? "random",
selectedBuildIds: legacyConfig.selectedBuildIds ?? [],
level: legacyConfig.level, level: legacyConfig.level,
partyConfigurations: legacyConfig.partyConfigurationsPerSource, partyConfigurations: legacyConfig.specSelection && legacyConfig.specSelection !== "random"
? legacyConfig.partyConfigurations
: legacyConfig.partyConfigurationsPerSource,
simulationsPerParty: legacyConfig.simulationsPerParty, simulationsPerParty: legacyConfig.simulationsPerParty,
durationSeconds: legacyConfig.durationSeconds, durationSeconds: legacyConfig.durationSeconds,
partySeed: legacyConfig.partySeed, partySeed: legacyConfig.partySeed,
@@ -215,7 +306,8 @@ export function createBalanceLabJobService(options = {}) {
const hasReport = existsSync(reportPath); const hasReport = existsSync(reportPath);
const storedStatus = JOB_STATUSES.has(stored.status) ? stored.status : "failed"; const storedStatus = JOB_STATUSES.has(stored.status) ? stored.status : "failed";
const interrupted = !hasReport && (storedStatus === "queued" || storedStatus === "running"); const interrupted = !hasReport && (storedStatus === "queued" || storedStatus === "running");
const totalParties = spec.partyConfigurations * (spec.source === "all" ? 3 : 1); const restoredSummary = hasReport ? reportSummary(outputDir) : null;
const totalParties = restoredSummary?.partyConfigurations || expectedPartyConfigurations(spec);
const createdAt = typeof stored.createdAt === "string" const createdAt = typeof stored.createdAt === "string"
? stored.createdAt ? stored.createdAt
: statSync(outputDir).birthtime.toISOString(); : statSync(outputDir).birthtime.toISOString();
@@ -245,7 +337,7 @@ export function createBalanceLabJobService(options = {}) {
logTail: Array.isArray(stored.logTail) logTail: Array.isArray(stored.logTail)
? stored.logTail.filter((line) => typeof line === "string").slice(-80) ? stored.logTail.filter((line) => typeof line === "string").slice(-80)
: [], : [],
summary: hasReport ? reportSummary(outputDir) : null, summary: restoredSummary,
outputDir, outputDir,
}; };
jobs.set(job.id, job); jobs.set(job.id, job);
@@ -288,7 +380,7 @@ export function createBalanceLabJobService(options = {}) {
persistJob(job); persistJob(job);
const command = balanceLabCommand(job.spec, job.outputDir, { const command = balanceLabCommand(job.spec, job.outputDir, {
projectRoot, projectRoot,
pythonExecutable: options.pythonExecutable, pythonExecutable,
}); });
const child = launch(command.executable, command.args, { const child = launch(command.executable, command.args, {
cwd: command.cwd, cwd: command.cwd,
@@ -341,55 +433,93 @@ export function createBalanceLabJobService(options = {}) {
return job; return job;
} }
function requireAvailableRuntime() {
if (closed) throw new GameDatabaseError("Balance Lab runner is shutting down.", 503, "balance_lab_unavailable");
const runtime = currentRuntime();
if (!runtime.available) {
throw new GameDatabaseError(
`${runtime.error} Install Python 3 in the server container and set BALANCE_LAB_PYTHON=python3, then restart the app.`,
503,
"balance_lab_runtime_unavailable",
);
}
}
function requireQueueCapacity(count) {
const pending = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length;
if (pending + count > maxQueued) {
throw new GameDatabaseError(
`The Balance Lab server queue is full for this request; it has room for ${Math.max(0, maxQueued - pending)} more jobs. Wait for running jobs to finish or submit a smaller level batch.`,
429,
"balance_lab_queue_full",
);
}
}
function queueJob(spec, account) {
const id = randomUUID();
const outputDir = path.join(jobsDir, id);
mkdirSync(outputDir, { recursive: true });
const totalParties = expectedPartyConfigurations(spec);
const job = {
id,
status: "queued",
createdAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
requestedBy: account.username,
spec,
progress: {
party: 0,
partiesCompleted: 0,
partiesTotal: totalParties,
simulationsCompleted: 0,
simulationsTotal: 0,
},
artifacts: [],
error: null,
logTail: [],
summary: null,
outputDir,
};
jobs.set(id, job);
persistJob(job);
queue.push(job);
return job;
}
return { return {
start(input, account) { start(input, account) {
if (closed) throw new GameDatabaseError("Balance Lab runner is shutting down.", 503, "balance_lab_unavailable");
const pending = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length;
if (pending >= maxQueued) {
throw new GameDatabaseError(
"The Balance Lab server queue is full. Wait for a running job to finish.",
429,
"balance_lab_queue_full",
);
}
const spec = normalizeBalanceLabJobSpec(input); const spec = normalizeBalanceLabJobSpec(input);
const id = randomUUID(); requireAvailableRuntime();
const outputDir = path.join(jobsDir, id); requireQueueCapacity(1);
mkdirSync(outputDir, { recursive: true }); const job = queueJob(spec, account);
const totalParties = spec.partyConfigurations * (spec.source === "all" ? 3 : 1);
const job = {
id,
status: "queued",
createdAt: new Date().toISOString(),
startedAt: null,
completedAt: null,
requestedBy: account.username,
spec,
progress: {
party: 0,
partiesCompleted: 0,
partiesTotal: totalParties,
simulationsCompleted: 0,
simulationsTotal: 0,
},
artifacts: [],
error: null,
logTail: [],
summary: null,
outputDir,
};
jobs.set(id, job);
persistJob(job);
queue.push(job);
pump(); pump();
return publicJob(job); return publicJob(job);
}, },
startMany(input, account) {
const specs = normalizeBalanceLabJobBatch(input);
requireAvailableRuntime();
requireQueueCapacity(specs.length);
const batch = specs.map((spec) => queueJob(spec, account));
pump();
return batch.map(publicJob);
},
list() { list() {
return [...jobs.values()] return [...jobs.values()]
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)) .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
.slice(0, 100) .slice(0, 100)
.map(publicJob); .map(publicJob);
}, },
runtime() {
return {
...currentRuntime(),
maxConcurrent,
running,
queued: queue.length,
maxQueued,
};
},
get(id) { get(id) {
return publicJob(requireJob(id)); return publicJob(requireJob(id));
}, },
+126
View File
@@ -8,12 +8,16 @@ import test from "node:test";
import { import {
balanceLabCommand, balanceLabCommand,
createBalanceLabJobService, createBalanceLabJobService,
inspectBalanceLabRuntime,
normalizeBalanceLabJobBatch,
normalizeBalanceLabJobSpec, normalizeBalanceLabJobSpec,
} from "./balance-lab-jobs.mjs"; } from "./balance-lab-jobs.mjs";
test("normalizes and bounds server Balance Lab requests", () => { test("normalizes and bounds server Balance Lab requests", () => {
const spec = normalizeBalanceLabJobSpec({ const spec = normalizeBalanceLabJobSpec({
source: "ALL", source: "ALL",
specSelection: "specific",
selectedBuildIds: ["balance-wow-priest-healer-holy-level-40", "balance-wow-priest-healer-holy-level-40"],
level: 40, level: 40,
partyConfigurations: 2, partyConfigurations: 2,
simulationsPerParty: 3, simulationsPerParty: 3,
@@ -23,6 +27,8 @@ test("normalizes and bounds server Balance Lab requests", () => {
bossKeys: ["gundrak/entry-29932", "gundrak/entry-29932"], bossKeys: ["gundrak/entry-29932", "gundrak/entry-29932"],
}); });
assert.equal(spec.source, "all"); assert.equal(spec.source, "all");
assert.equal(spec.specSelection, "specific");
assert.deepEqual(spec.selectedBuildIds, ["balance-wow-priest-healer-holy-level-40"]);
assert.deepEqual(spec.bossKeys, ["gundrak/entry-29932"]); assert.deepEqual(spec.bossKeys, ["gundrak/entry-29932"]);
assert.throws( assert.throws(
() => normalizeBalanceLabJobSpec({ source: "wow", level: 81 }), () => normalizeBalanceLabJobSpec({ source: "wow", level: 81 }),
@@ -32,12 +38,31 @@ test("normalizes and bounds server Balance Lab requests", () => {
() => normalizeBalanceLabJobSpec({ source: "wow", bossKeys: ["boss; shutdown"] }), () => normalizeBalanceLabJobSpec({ source: "wow", bossKeys: ["boss; shutdown"] }),
/boss key is invalid/, /boss key is invalid/,
); );
assert.throws(
() => normalizeBalanceLabJobSpec({ source: "wow", specSelection: "specific", selectedBuildIds: [] }),
/Select at least one class\/spec build/,
);
});
test("normalizes complete level batches before any jobs are queued", () => {
const batch = normalizeBalanceLabJobBatch({
specs: [10, 20, 30].map((level) => ({
source: "wow", level, partyConfigurations: 1, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
})),
});
assert.deepEqual(batch.map((spec) => spec.level), [10, 20, 30]);
assert.throws(() => normalizeBalanceLabJobBatch({ specs: [] }), /must contain 1 through 80/);
assert.throws(() => normalizeBalanceLabJobBatch({
specs: [{ source: "wow", level: 10 }, { source: "wow", level: 81 }],
}), /Level must be a whole number/);
}); });
test("builds an argument-array command without a shell", () => { test("builds an argument-array command without a shell", () => {
const spec = normalizeBalanceLabJobSpec({ const spec = normalizeBalanceLabJobSpec({
source: "rom", level: 60, partyConfigurations: 4, simulationsPerParty: 2, source: "rom", level: 60, partyConfigurations: 4, simulationsPerParty: 2,
durationSeconds: 30, partySeed: 9, bossCategory: "rom", bossKeys: ["abbey/boss-1"], durationSeconds: 30, partySeed: 9, bossCategory: "rom", bossKeys: ["abbey/boss-1"],
specSelection: "specific", selectedBuildIds: ["balance-rom-mage-damage-primary-level-60"],
}); });
const command = balanceLabCommand(spec, "C:/reports/job", { const command = balanceLabCommand(spec, "C:/reports/job", {
projectRoot: "C:/app", projectRoot: "C:/app",
@@ -46,10 +71,45 @@ test("builds an argument-array command without a shell", () => {
assert.equal(command.executable, "python-custom"); assert.equal(command.executable, "python-custom");
assert.equal(command.args.includes("--party-mode"), true); assert.equal(command.args.includes("--party-mode"), true);
assert.equal(command.args.includes("automatic"), true); assert.equal(command.args.includes("automatic"), true);
assert.equal(command.args.includes("specific"), true);
assert.equal(command.args.includes("balance-rom-mage-damage-primary-level-60"), true);
assert.equal(command.args.includes("abbey/boss-1"), true); assert.equal(command.args.includes("abbey/boss-1"), true);
assert.equal(command.cwd, path.resolve("C:/app")); assert.equal(command.cwd, path.resolve("C:/app"));
}); });
test("detects available and missing Python 3 runtimes", () => {
const available = inspectBalanceLabRuntime("python3", () => ({
status: 0, stdout: "Python 3.12.8\n", stderr: "",
}));
assert.equal(available.available, true);
assert.equal(available.version, "Python 3.12.8");
const missing = inspectBalanceLabRuntime("python3", () => ({
status: 127, stdout: "", stderr: "python3: not found",
}));
assert.equal(missing.available, false);
assert.match(missing.error, /could not execute python3/i);
});
test("rejects a job before queueing when the server runtime is unavailable", () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-runtime-"));
try {
const service = createBalanceLabJobService({
dataDir: directory,
projectRoot: directory,
runtimeProbe: () => ({ available: false, executable: "python3", version: null, error: "python3 is missing." }),
});
assert.throws(() => service.start({
source: "wow", level: 40, partyConfigurations: 1, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
}, { username: "Phenom" }), /Install Python 3 in the server container/);
assert.equal(service.list().length, 0);
assert.equal(service.runtime().available, false);
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("queues a job, reports progress, and exposes only allow-listed artifacts", async () => { test("queues a job, reports progress, and exposes only allow-listed artifacts", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-job-")); const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-job-"));
try { try {
@@ -248,3 +308,69 @@ test("bounds the number of pending server jobs", () => {
rmSync(directory, { recursive: true, force: true }); rmSync(directory, { recursive: true, force: true });
} }
}); });
test("runs separate level jobs concurrently and reports worker utilization", () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-parallel-"));
try {
let launches = 0;
const spawnProcess = () => {
launches += 1;
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = () => child.emit("close", null);
return child;
};
const service = createBalanceLabJobService({
dataDir: directory,
projectRoot: directory,
spawnProcess,
pythonExecutable: "python-test",
maxConcurrent: 3,
maxQueued: 10,
});
const requestForLevel = (level) => ({
source: "wow", level, partyConfigurations: 1, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
});
const jobs = service.startMany({ specs: [10, 20, 30].map(requestForLevel) }, { username: "Phenom" });
assert.equal(launches, 3);
assert.deepEqual(jobs.map((job) => job.status), ["running", "running", "running"]);
assert.deepEqual(jobs.map((job) => job.spec.level), [10, 20, 30]);
assert.deepEqual(service.runtime(), {
available: true,
executable: "python-test",
version: "test process",
error: null,
maxConcurrent: 3,
running: 3,
queued: 0,
maxQueued: 10,
});
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("rejects an oversized batch without queueing a partial sweep", () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-batch-capacity-"));
try {
const service = createBalanceLabJobService({
dataDir: directory,
projectRoot: directory,
spawnProcess: () => { throw new Error("an over-capacity batch must not launch"); },
pythonExecutable: "python-test",
maxQueued: 2,
});
const specs = [10, 20, 30].map((level) => ({
source: "wow", level, partyConfigurations: 1, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
}));
assert.throws(() => service.startMany({ specs }, { username: "Phenom" }), /server queue is full/);
assert.equal(service.list().length, 0);
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
+1 -1
View File
@@ -44,7 +44,7 @@ async function derivePassword(password, salt) {
return Buffer.from(await scrypt(password, salt, 64)); return Buffer.from(await scrypt(password, salt, 64));
} }
const GM_USERNAME_KEYS = new Set(["phenom"]); const GM_USERNAME_KEYS = new Set(["phenom", "codex_dev_gm"]);
function accountRoles(username) { function accountRoles(username) {
return GM_USERNAME_KEYS.has(String(username ?? "").trim().toLowerCase()) ? ["gm"] : []; return GM_USERNAME_KEYS.has(String(username ?? "").trim().toLowerCase()) ? ["gm"] : [];
+4 -1
View File
@@ -56,12 +56,15 @@ test("persists opaque cloud roster data with increasing revisions", () => withDa
assert.equal(database.getCloudSave(registered.account.id).data.characters[0].name, "Mendbetter"); assert.equal(database.getCloudSave(registered.account.id).data.characters[0].name, "Mendbetter");
})); }));
test("derives GM authority from the hardcoded Phenom account", () => withDatabase(async (database) => { test("derives GM authority from the hardcoded developer accounts", () => withDatabase(async (database) => {
const gm = await database.register("Phenom", "storm-admin"); const gm = await database.register("Phenom", "storm-admin");
const developerGm = await database.register("codex_dev_gm", "developer-admin");
const player = await database.register("Ordinary", "storm-player"); const player = await database.register("Ordinary", "storm-player");
assert.deepEqual(gm.account.roles, ["gm"]); assert.deepEqual(gm.account.roles, ["gm"]);
assert.deepEqual((await database.login("pHeNoM", "storm-admin")).account.roles, ["gm"]); assert.deepEqual((await database.login("pHeNoM", "storm-admin")).account.roles, ["gm"]);
assert.deepEqual(developerGm.account.roles, ["gm"]);
assert.equal(database.isGameMaster(database.authenticate(gm.token)), true); assert.equal(database.isGameMaster(database.authenticate(gm.token)), true);
assert.equal(database.isGameMaster(database.authenticate(developerGm.token)), true);
assert.equal(database.isGameMaster(database.authenticate(player.token)), false); assert.equal(database.isGameMaster(database.authenticate(player.token)), false);
assert.equal(database.isGameMaster({ ...player.account, roles: ["gm"] }), false); assert.equal(database.isGameMaster({ ...player.account, roles: ["gm"] }), false);
})); }));
+18
View File
@@ -506,6 +506,24 @@ export function createGameServer(options = {}) {
json(response, 200, { jobs: balanceLabJobs.list() }); json(response, 200, { jobs: balanceLabJobs.list() });
return; return;
} }
if (url.pathname === "/api/gm/balance-lab/runtime" && request.method === "GET") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
json(response, 200, { runtime: balanceLabJobs.runtime() });
return;
}
if (url.pathname === "/api/gm/balance-lab/jobs/batch" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
const jobs = balanceLabJobs.startMany(await readJson(request, 4 * 1024 * 1024), account);
console.info(`Balance Lab batch of ${jobs.length} jobs queued by ${account.username}`);
json(response, 202, { jobs });
return;
}
if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "POST") { if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "POST") {
const account = gameDatabase.authenticate(bearerToken(request)); const account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) { if (!gameDatabase.isGameMaster(account)) {
+14
View File
@@ -248,6 +248,13 @@ test("serves the game and authenticated cloud-save API", async () => {
}); });
assert.equal(balanceLabJobs.status, 200); assert.equal(balanceLabJobs.status, 200);
assert.deepEqual((await balanceLabJobs.json()).jobs, []); assert.deepEqual((await balanceLabJobs.json()).jobs, []);
const balanceLabRuntime = await fetch(`${origin}/api/gm/balance-lab/runtime`, {
headers: { Authorization: `Bearer ${gmAuth.token}` },
});
assert.equal(balanceLabRuntime.status, 200);
const balanceLabRuntimePayload = await balanceLabRuntime.json();
assert.equal(balanceLabRuntimePayload.runtime.available, true);
assert.equal(balanceLabRuntimePayload.runtime.maxConcurrent, 4);
const invalidBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, { const invalidBalanceLab = await fetch(`${origin}/api/gm/balance-lab/jobs`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` }, headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
@@ -255,6 +262,13 @@ test("serves the game and authenticated cloud-save API", async () => {
}); });
assert.equal(invalidBalanceLab.status, 400); assert.equal(invalidBalanceLab.status, 400);
assert.equal((await invalidBalanceLab.json()).code, "invalid_balance_lab_job"); assert.equal((await invalidBalanceLab.json()).code, "invalid_balance_lab_job");
const invalidBalanceLabBatch = await fetch(`${origin}/api/gm/balance-lab/jobs/batch`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${gmAuth.token}` },
body: JSON.stringify({ specs: [{ source: "wow", level: 10 }, { source: "wow", level: 81 }] }),
});
assert.equal(invalidBalanceLabBatch.status, 400);
assert.equal((await invalidBalanceLabBatch.json()).code, "invalid_balance_lab_job");
const current = await (await fetch(`${origin}/api/manastorm-config`)).json(); const current = await (await fetch(`${origin}/api/manastorm-config`)).json();
const published = await fetch(`${origin}/api/gm/manastorm-config`, { const published = await fetch(`${origin}/api/gm/manastorm-config`, {
method: "PUT", method: "PUT",
+2 -1
View File
@@ -44,7 +44,8 @@ function InputBridge() {
useEffect(() => installInput({ useEffect(() => installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars, desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""), inputSuspended: () => useDungeonSimulationStore.getState().sessionMode !== "play"
|| ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
getBindings: (device) => { getBindings: (device) => {
const preferences = useUiLayoutStore.getState().preferences; const preferences = useUiLayoutStore.getState().preferences;
const bindings = profileInputBindings(preferences, device); const bindings = profileInputBindings(preferences, device);
+2 -2
View File
@@ -24,7 +24,7 @@ import {
normalizeManastormProgress, normalizeManastormProgress,
type ManastormProgress, type ManastormProgress,
} from "../game/manastormProgress"; } from "../game/manastormProgress";
import { abilitiesForClass } from "../game/abilityCatalog"; import { abilitiesForClass, abilityTalentNodeId } from "../game/abilityCatalog";
import { import {
normalizeSpecializationId, normalizeSpecializationId,
specializationById, specializationById,
@@ -576,7 +576,7 @@ export function updateCharacterSpecialization(
} }
const talentAbilityIds = resetTalents const talentAbilityIds = resetTalents
? new Set(abilitiesForClass(character.classId) ? new Set(abilitiesForClass(character.classId)
.filter((ability) => ability.talentEntryId !== undefined || ability.source === "coa-tree") .filter((ability) => abilityTalentNodeId(ability) !== null || ability.source === "coa-tree")
.map((ability) => ability.id)) .map((ability) => ability.id))
: new Set<string>(); : new Set<string>();
const actionBindings = resetTalents const actionBindings = resetTalents
+35
View File
@@ -2,10 +2,24 @@ import { onlineApiOrigin, requestOnlineJson } from "./onlineAccountClient";
export type BalanceLabPartySource = "wow" | "coa" | "rom" | "all"; export type BalanceLabPartySource = "wow" | "coa" | "rom" | "all";
export type BalanceLabBossCategory = "all" | "wow" | "rom"; export type BalanceLabBossCategory = "all" | "wow" | "rom";
export type BalanceLabSpecSelection = "random" | "all" | "specific";
export type BalanceLabJobStatus = "queued" | "running" | "complete" | "failed"; export type BalanceLabJobStatus = "queued" | "running" | "complete" | "failed";
export interface BalanceLabRuntimeStatus {
readonly available: boolean;
readonly executable: string;
readonly version: string | null;
readonly error: string | null;
readonly maxConcurrent: number;
readonly running: number;
readonly queued: number;
readonly maxQueued: number;
}
export interface BalanceLabJobSpec { export interface BalanceLabJobSpec {
readonly source: BalanceLabPartySource; readonly source: BalanceLabPartySource;
readonly specSelection?: BalanceLabSpecSelection;
readonly selectedBuildIds?: readonly string[];
readonly level: number; readonly level: number;
readonly partyConfigurations: number; readonly partyConfigurations: number;
readonly simulationsPerParty: number; readonly simulationsPerParty: number;
@@ -77,10 +91,12 @@ export interface BalanceLabClassSpecResult {
readonly dps: number; readonly dps: number;
readonly bossDps: number; readonly bossDps: number;
readonly hps: number; readonly hps: number;
readonly hpsReceived?: number;
readonly dtps: number; readonly dtps: number;
readonly totalDamage: number; readonly totalDamage: number;
readonly totalDamageTaken: number; readonly totalDamageTaken: number;
readonly totalHealing: number; readonly totalHealing: number;
readonly totalHealingReceived?: number;
readonly totalAbsorbs: number; readonly totalAbsorbs: number;
readonly deathEvents: number; readonly deathEvents: number;
readonly damageVsSourceRoleMedian: number | null; readonly damageVsSourceRoleMedian: number | null;
@@ -155,6 +171,14 @@ export async function listBalanceLabJobs(token: string): Promise<readonly Balanc
)).jobs; )).jobs;
} }
export async function getBalanceLabRuntime(token: string): Promise<BalanceLabRuntimeStatus> {
return (await requestOnlineJson<{ runtime: BalanceLabRuntimeStatus }>(
"/api/gm/balance-lab/runtime",
{},
token,
)).runtime;
}
export async function startBalanceLabJob(spec: BalanceLabJobSpec, token: string): Promise<BalanceLabJob> { export async function startBalanceLabJob(spec: BalanceLabJobSpec, token: string): Promise<BalanceLabJob> {
return (await requestOnlineJson<{ job: BalanceLabJob }>( return (await requestOnlineJson<{ job: BalanceLabJob }>(
"/api/gm/balance-lab/jobs", "/api/gm/balance-lab/jobs",
@@ -163,6 +187,17 @@ export async function startBalanceLabJob(spec: BalanceLabJobSpec, token: string)
)).job; )).job;
} }
export async function startBalanceLabJobs(
specs: readonly BalanceLabJobSpec[],
token: string,
): Promise<readonly BalanceLabJob[]> {
return (await requestOnlineJson<{ jobs: BalanceLabJob[] }>(
"/api/gm/balance-lab/jobs/batch",
{ method: "POST", body: JSON.stringify({ specs }) },
token,
)).jobs;
}
export async function getBalanceLabJob(id: string, token: string): Promise<BalanceLabJob> { export async function getBalanceLabJob(id: string, token: string): Promise<BalanceLabJob> {
return (await requestOnlineJson<{ job: BalanceLabJob }>( return (await requestOnlineJson<{ job: BalanceLabJob }>(
`/api/gm/balance-lab/jobs/${encodeURIComponent(id)}`, `/api/gm/balance-lab/jobs/${encodeURIComponent(id)}`,
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { balanceLabBracketLevels } from "./balanceLabSchedules";
describe("Balance Lab level schedules", () => {
it("includes level 1 and every ten-level bracket through 80", () => {
expect(balanceLabBracketLevels(10)).toEqual([1, 10, 20, 30, 40, 50, 60, 70, 80]);
});
it("supports every five levels without duplicating level 1", () => {
expect(balanceLabBracketLevels(5)).toEqual([
1, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80,
]);
});
it("supports a job for every level from 1 through 80", () => {
const levels = balanceLabBracketLevels(1);
expect(levels).toHaveLength(80);
expect(levels[0]).toBe(1);
expect(levels.at(-1)).toBe(80);
});
});
+6
View File
@@ -0,0 +1,6 @@
export type BalanceLabBracketStep = 1 | 5 | 10;
export function balanceLabBracketLevels(step: BalanceLabBracketStep): readonly number[] {
if (step === 1) return Array.from({ length: 80 }, (_, index) => index + 1);
return [1, ...Array.from({ length: Math.floor(80 / step) }, (_, index) => (index + 1) * step)];
}
+3
View File
@@ -45,6 +45,7 @@ export function PartyBridge() {
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory); usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
combat.synchronizeActors(); combat.synchronizeActors();
combat.applyDefaultPartyBuffs(); combat.applyDefaultPartyBuffs();
combat.applyPrecombatPreparation();
resetPartyRuntimeClock(); resetPartyRuntimeClock();
}, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole, simulationMode, simulationSeed]); }, [character?.id, gameMode, manastormPartySize, onlineActivity?.id, playerRole, simulationMode, simulationSeed]);
@@ -55,6 +56,7 @@ export function PartyBridge() {
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory); usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
combat.synchronizeActors(); combat.synchronizeActors();
combat.applyDefaultPartyBuffs(); combat.applyDefaultPartyBuffs();
combat.applyPrecombatPreparation();
}, [level]); }, [level]);
useEffect(() => { useEffect(() => {
@@ -65,6 +67,7 @@ export function PartyBridge() {
usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory); usePartyStore.getState().syncEquipment(combat.equipment, combat.inventory);
combat.synchronizeActors(); combat.synchronizeActors();
combat.applyDefaultPartyBuffs(); combat.applyDefaultPartyBuffs();
combat.applyPrecombatPreparation();
resetPartyRuntimeClock(); resetPartyRuntimeClock();
}, [resetRevision]); }, [resetRevision]);
+1 -1
View File
@@ -34,7 +34,7 @@ describe("baseline ability catalog", () => {
for (const ability of abilities) { for (const ability of abilities) {
expect(ability.classId).toBe(characterClass.id); expect(ability.classId).toBe(characterClass.id);
expect(ability.dbcSpellId).toBeGreaterThan(0); expect(ability.dbcSpellId).toBeGreaterThan(0);
expect(ability.icon).toMatch(/^\/assets\/ui\/spells\/[a-z0-9_()' -]+\.png$/); expect(ability.icon).toMatch(/^\/assets\/ui\/(?:spells|talents\/icons)\/[a-z0-9_()' -]+\.png$/);
const iconPath = path.join(process.cwd(), "public", ability.icon.slice(1)); const iconPath = path.join(process.cwd(), "public", ability.icon.slice(1));
expect(existsSync(iconPath), `${ability.name} icon should exist at ${iconPath}`).toBe(true); expect(existsSync(iconPath), `${ability.name} icon should exist at ${iconPath}`).toBe(true);
expect(statSync(iconPath).size, `${ability.name} icon should not be empty`).toBeGreaterThan(0); expect(statSync(iconPath).size, `${ability.name} icon should not be empty`).toBeGreaterThan(0);
+56 -4
View File
@@ -70,6 +70,13 @@ export interface AbilityAiHints {
| "resurrection" | "resurrection"
| "control" | "control"
| "debuff" | "debuff"
| "buff"
| "resource"
| "summon"
| "pet"
| "movement"
| "threat"
| "area-damage"
)[]; )[];
readonly priority?: number; readonly priority?: number;
} }
@@ -293,6 +300,9 @@ export interface AbilityDefinition {
/** The original client level before Healer Man's starter-kit remapping. */ /** The original client level before Healer Man's starter-kit remapping. */
readonly sourceUnlockLevel?: number; readonly sourceUnlockLevel?: number;
readonly ranks?: readonly AbilityRankDefinition[]; readonly ranks?: readonly AbilityRankDefinition[];
/** Runtime talent node that must be learned before this ability is available. */
readonly talentNodeId?: string;
/** Legacy CoA source identifier; `abilityTalentNodeId` normalizes it. */
readonly talentEntryId?: number; readonly talentEntryId?: number;
} }
@@ -953,10 +963,51 @@ export function abilityRankForLevel(
?? ability.ranks[Math.min(ability.ranks.length, normalized) - 1] ?? ability.ranks[Math.min(ability.ranks.length, normalized) - 1]
?? ability.ranks[0]; ?? ability.ranks[0];
} }
return [...ability.ranks] const eligible = [...ability.ranks]
.filter((rank) => rank.level <= playerLevel) .filter((rank) => rank.level <= playerLevel)
.sort((left, right) => right.rank - left.rank)[0] .sort((left, right) => right.rank - left.rank);
?? ability.ranks[0]; const highestRank = eligible[0]?.rank;
if (highestRank === undefined) return ability.ranks[0];
const candidates = eligible.filter((rank) => rank.rank === highestRank);
if (candidates.length === 1) return candidates[0];
// Several Wrath spell-name chains contain both the player-facing rank and a
// same-rank triggered child (for example Lightning Shield's retaliation).
// Preserve the structural effect family of the chain's root spell so a
// triggered damage child cannot replace the executable aura at later ranks.
const effectStructure = (effects: readonly AbilityEffect[]): string => effects.map((effect) => (
effect.kind === "apply-aura"
? [effect.kind, effect.sourceEffectType, effect.sourceAuraType].join(":")
: effect.kind
)).join("|");
const rootStructure = effectStructure(ability.effects);
return candidates.find((rank) => effectStructure(rank.effects) === rootStructure) ?? candidates[0];
}
export function abilityTalentNodeId(ability: AbilityDefinition): string | null {
return ability.talentNodeId
?? (ability.talentEntryId === undefined ? null : `coa-entry-${ability.talentEntryId}`);
}
export function learnedAbilityRank(
ability: AbilityDefinition,
talentRanks: Readonly<Record<string, number>>,
): number | undefined {
const nodeId = abilityTalentNodeId(ability);
return nodeId ? Math.max(0, Math.trunc(talentRanks[nodeId] ?? 0)) : undefined;
}
export function learnedAbilityForBuild(
ability: AbilityDefinition,
playerLevel: number,
talentRanks: Readonly<Record<string, number>>,
): AbilityDefinition | null {
if (!isAbilityUnlocked(ability, playerLevel)) return null;
const nodeId = abilityTalentNodeId(ability);
const learnedRank = learnedAbilityRank(ability, talentRanks);
if (nodeId && !learnedRank) return null;
// CoA tree entries use the allocated talent rank as the spell rank. Classic
// active talents continue to learn their later spell ranks by character level.
return abilityAtLevel(ability, playerLevel, ability.talentEntryId === undefined ? undefined : learnedRank);
} }
export function abilityAtLevel( export function abilityAtLevel(
@@ -1040,7 +1091,7 @@ export function defaultActionBarForClass(classId: ClassId): readonly string[] {
.map(({ ability }) => ability) .map(({ ability }) => ability)
.filter((ability) => ( .filter((ability) => (
!ability.passive !ability.passive
&& ability.source !== "coa-tree" && !abilityTalentNodeId(ability)
&& !ability.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted") && !ability.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")
)) ))
.map((ability) => ability.id); .map((ability) => ability.id);
@@ -1065,6 +1116,7 @@ export function defaultActionBarForCharacter(classId: ClassId, secondaryClassId?
.filter((ability) => ( .filter((ability) => (
!ability.passive !ability.passive
&& ability.unlockLevel <= level && ability.unlockLevel <= level
&& !abilityTalentNodeId(ability)
&& !ability.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted") && !ability.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")
)) ))
.sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name)) .sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name))
+28 -1
View File
@@ -19,6 +19,10 @@ const MOVEMENT_CAST_ABILITIES = new Set(["scorch", "steady shot", "spiritwalker'
const EMERGENCY_HEAL_ABILITIES = new Set([ const EMERGENCY_HEAL_ABILITIES = new Set([
"flash heal", "flash of light", "swiftmend", "lay on hands", "nature's swiftness", "flash heal", "flash of light", "swiftmend", "lay on hands", "nature's swiftness",
]); ]);
const AREA_HEAL_ABILITIES = new Set([
"chain heal", "circle of healing", "divine hymn", "holy nova", "prayer of healing",
"prayer of mending", "tranquility", "wild growth",
]);
const RUNE_COSTS_BY_SPELL_ID: Readonly<Record<number, AbilityRuneCost>> = Object.freeze({ const RUNE_COSTS_BY_SPELL_ID: Readonly<Record<number, AbilityRuneCost>> = Object.freeze({
49909: { frost: 1 }, 45524: { frost: 1 }, 49184: { frost: 1 }, 47528: { frost: 1 }, 49909: { frost: 1 }, 45524: { frost: 1 }, 49184: { frost: 1 }, 47528: { frost: 1 },
@@ -97,8 +101,31 @@ export function aiHintsForAbility(ability: AbilityDefinition): AbilityAiHints {
categories.add(effect.status === "interrupt" ? "interrupt" : "control"); categories.add(effect.status === "interrupt" ? "interrupt" : "control");
} }
if (effect.kind === "dot" || (effect.kind === "apply-aura" && effect.aura.disposition === "debuff")) categories.add("debuff"); if (effect.kind === "dot" || (effect.kind === "apply-aura" && effect.aura.disposition === "debuff")) categories.add("debuff");
if (effect.kind === "apply-aura" && effect.aura.disposition === "buff") categories.add("buff");
if (effect.kind === "apply-aura" && (
effect.aura.absorbs?.length
|| effect.aura.modifiers?.some((modifier) => modifier.kind === "damage"
&& modifier.direction === "taken" && modifier.value < 0)
)) categories.add("mitigation");
if (effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "threat"
))) categories.add("threat");
if (effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => modifier.kind === "resource")) {
categories.add("resource");
}
if (effect.kind === "resource" || effect.kind === "rune") categories.add("resource");
if (effect.kind === "summon" || effect.kind === "totem") categories.add("summon");
if (effect.kind === "pet") categories.add("pet");
if (effect.kind === "movement" || effect.kind === "pull") categories.add("movement");
if (effect.kind === "threat") categories.add("threat");
} }
if (EMERGENCY_HEAL_ABILITIES.has(name)) categories.add("emergency-heal"); if (EMERGENCY_HEAL_ABILITIES.has(name)) categories.add("emergency-heal");
if ((ability.radius ?? 0) > 0 && categories.has("heal")) categories.add("area-heal"); if (((ability.radius ?? 0) > 0 || AREA_HEAL_ABILITIES.has(name)) && categories.has("heal")) categories.add("area-heal");
const targeting = targetingRulesForAbility(ability);
if (categories.has("damage") && (
targeting.shape !== "unit"
|| (targeting.maximumTargets ?? 1) > 1
|| (targeting.radius ?? ability.radius ?? 0) > 0
)) categories.add("area-damage");
return Object.freeze({ categories: Object.freeze([...categories]) }); return Object.freeze({ categories: Object.freeze([...categories]) });
} }
+6 -6
View File
@@ -1,6 +1,6 @@
import type { CharacterProfile } from "../app/types"; import type { CharacterProfile } from "../app/types";
import { import {
abilitiesForCharacter, abilityAtLevel, defaultActionBarForCharacter, resourceProfileForClass, abilitiesForCharacter, abilityTalentNodeId, defaultActionBarForCharacter, learnedAbilityForBuild, resourceProfileForClass,
type AbilityDefinition, type ClassResourceProfile, type AbilityDefinition, type ClassResourceProfile,
} from "./abilityCatalog"; } from "./abilityCatalog";
import { normalizeActionBindings } from "./actionBindings"; import { normalizeActionBindings } from "./actionBindings";
@@ -11,13 +11,13 @@ import { activeEquipmentStatsForClass, PLAYER_EQUIPMENT_OWNER_ID } from "./equip
export function arenaCharacterAbilities(character: CharacterProfile, level = character.level): readonly AbilityDefinition[] { export function arenaCharacterAbilities(character: CharacterProfile, level = character.level): readonly AbilityDefinition[] {
return abilitiesForCharacter(character.classId, character.secondaryClassId) return abilitiesForCharacter(character.classId, character.secondaryClassId)
.filter((ability) => !ability.passive) .filter((ability) => !ability.passive)
.map((ability) => abilityAtLevel(ability, level, ability.talentEntryId === undefined .map((ability) => learnedAbilityForBuild(ability, level, character.talentRanks))
? undefined : character.talentRanks[`coa-entry-${ability.talentEntryId}`])); .filter((ability): ability is AbilityDefinition => ability !== null);
} }
export function arenaAbilityIsLearned(ability: AbilityDefinition, character: CharacterProfile, level = character.level): boolean { export function arenaAbilityIsLearned(ability: AbilityDefinition, character: CharacterProfile, level = character.level): boolean {
return ability.unlockLevel <= level && (ability.source !== "coa-tree" || ability.talentEntryId === undefined return ability.unlockLevel <= level && (!abilityTalentNodeId(ability)
|| (character.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0) > 0); || learnedAbilityForBuild(ability, level, character.talentRanks) !== null);
} }
export function createArenaCharacterKit(character: CharacterProfile, level = character.level): ArenaCharacterKit { export function createArenaCharacterKit(character: CharacterProfile, level = character.level): ArenaCharacterKit {
@@ -28,7 +28,7 @@ export function createArenaCharacterKit(character: CharacterProfile, level = cha
const all = abilitiesForCharacter(character.classId, character.secondaryClassId); const all = abilitiesForCharacter(character.classId, character.secondaryClassId);
const actionBindings = normalizeActionBindings(character.actionBindings, [ const actionBindings = normalizeActionBindings(character.actionBindings, [
...defaultActionBarForCharacter(character.classId, character.secondaryClassId, level), ...defaultActionBarForCharacter(character.classId, character.secondaryClassId, level),
...all.filter((ability) => ability.source === "coa-tree" && !ability.passive).map((ability) => ability.id), ...all.filter((ability) => abilityTalentNodeId(ability) && !ability.passive).map((ability) => ability.id),
]); ]);
return { return {
seed: { ...character, level, settings: { ...character.settings, autoTargetNearest: false } }, seed: { ...character, level, settings: { ...character.settings, autoTargetNearest: false } },
+2
View File
@@ -6,6 +6,7 @@ import baseStatsData from "./generated/wow335BaseStats.json";
import { import {
creatureRuntimeBaseStats, creatureRuntimeBaseStats,
creatureTemplateReference, creatureTemplateReference,
standardWowDungeonCreatureHealth,
} from "./azerothCoreReference"; } from "./azerothCoreReference";
interface PlayerClassRow { interface PlayerClassRow {
@@ -110,6 +111,7 @@ describe("pinned AzerothCore reference", () => {
}); });
it("derives representative normal, elite, and boss stats at multiple levels", () => { it("derives representative normal, elite, and boss stats at multiple levels", () => {
expect(standardWowDungeonCreatureHealth(5)).toBe(306);
expect(creatureRuntimeBaseStats(385, 5)).toMatchObject({ expect(creatureRuntimeBaseStats(385, 5)).toMatchObject({
health: 102, health: 102,
armor: 111, armor: 111,
+15 -1
View File
@@ -113,6 +113,21 @@ function classStatsAt(level: number, unitClass: number): CreatureClassLevelRefer
return REFERENCE.creature.classLevelStats[`${safe}:1`] ?? null; return REFERENCE.creature.classLevelStats[`${safe}:1`] ?? null;
} }
/**
* Returns the health of a standard same-level WoW dungeon elite. The pinned
* AzerothCore class-level table supplies the level curve while the x3 modifier
* matches the ordinary elite templates used by opening dungeons such as the
* Stockade. Expansion bands follow normal WoW character progression so this
* baseline can also be used by creatures imported from another game.
*/
export function standardWowDungeonCreatureHealth(level: number): number {
const safe = safeLevel(level);
const stats = classStatsAt(safe, 1);
if (!stats) return Math.max(1, 72 + safe * 22);
const expansion = safe > 70 ? 2 : safe > 60 ? 1 : 0;
return Math.max(1, Math.ceil(stats.baseHealth[expansion] * 3));
}
export function creatureTemplateReference(entry: number): CreatureTemplateReference | null { export function creatureTemplateReference(entry: number): CreatureTemplateReference | null {
if (!Number.isInteger(entry) || entry <= 0) return null; if (!Number.isInteger(entry) || entry <= 0) return null;
return REFERENCE.creature.templates[String(entry)] ?? null; return REFERENCE.creature.templates[String(entry)] ?? null;
@@ -165,4 +180,3 @@ export function creatureRuntimeBaseStats(
schoolImmunities: template.schoolImmunities, schoolImmunities: template.schoolImmunities,
}); });
} }
+3 -2
View File
@@ -12,6 +12,7 @@ import {
} from "../../app/characterCatalog"; } from "../../app/characterCatalog";
import { import {
abilitiesForClass, abilitiesForClass,
abilityTalentNodeId,
preloadAbilityCatalogsForClasses, preloadAbilityCatalogsForClasses,
type AbilityAiHints, type AbilityAiHints,
type AbilityDefinition, type AbilityDefinition,
@@ -205,8 +206,8 @@ function isClassTree(tree: TalentUiTree): boolean {
function coaNodeAbilities(classId: CoaClassId): ReadonlyMap<string, readonly AbilityDefinition[]> { function coaNodeAbilities(classId: CoaClassId): ReadonlyMap<string, readonly AbilityDefinition[]> {
const mutable = new Map<string, AbilityDefinition[]>(); const mutable = new Map<string, AbilityDefinition[]>();
for (const ability of abilitiesForClass(classId)) { for (const ability of abilitiesForClass(classId)) {
if (ability.talentEntryId === undefined) continue; const nodeId = abilityTalentNodeId(ability);
const nodeId = `coa-entry-${ability.talentEntryId}`; if (!nodeId) continue;
const existing = mutable.get(nodeId); const existing = mutable.get(nodeId);
if (existing) existing.push(ability); if (existing) existing.push(ability);
else mutable.set(nodeId, [ability]); else mutable.set(nodeId, [ability]);
+77 -11
View File
@@ -1,6 +1,6 @@
import { import {
abilityAtLevel, learnedAbilityForBuild,
isAbilityUnlocked, resourceProfileForClass,
type AbilityDefinition, type AbilityDefinition,
} from "../abilityCatalog"; } from "../abilityCatalog";
import { aiHintsForAbility, canAbilityCastWhileMoving, targetingRulesForAbility } from "../abilityRules"; import { aiHintsForAbility, canAbilityCastWhileMoving, targetingRulesForAbility } from "../abilityRules";
@@ -15,8 +15,9 @@ import type { PartyState } from "../partyStore";
import { usePartyStore } from "../partyStore"; import { usePartyStore } from "../partyStore";
import type { PartyRole } from "../partyRoles"; import type { PartyRole } from "../partyRoles";
import type { BenchmarkScenarioDefinition } from "./benchmarkTypes"; import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
import { COMBAT_ROTATION_VERSION, evaluateCombatRotation } from "../combatRotation";
export const BENCHMARK_POLICY_ID = "baseline-v3"; export const BENCHMARK_POLICY_ID = `rotation-v4:${COMBAT_ROTATION_VERSION}`;
export interface BenchmarkPolicyDecision { export interface BenchmarkPolicyDecision {
readonly made: boolean; readonly made: boolean;
@@ -68,12 +69,9 @@ function friendlyTargets(combat: CombatState, party: PartyState): readonly Frien
} }
function learnedAbility(ability: AbilityDefinition, combat: CombatState): AbilityDefinition | null { function learnedAbility(ability: AbilityDefinition, combat: CombatState): AbilityDefinition | null {
if (ability.passive || !isAbilityUnlocked(ability, combat.level)) return null; if (ability.passive) return null;
const learnedRank = ability.talentEntryId === undefined const ranked = learnedAbilityForBuild(ability, combat.level, combat.talentRanks);
? undefined if (!ranked) return null;
: combat.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0;
if (ability.talentEntryId !== undefined && !learnedRank) return null;
const ranked = abilityAtLevel(ability, combat.level, learnedRank);
if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null; if (ranked.effects.every((effect) => effect.kind === "unsupported" || effect.kind === "scripted")) return null;
return ranked; return ranked;
} }
@@ -195,13 +193,81 @@ export class BenchmarkController {
) return { made: false, cast: false, resourceBlocked: false, invalidFailures: 0 }; ) return { made: false, cast: false, resourceBlocked: false, invalidFailures: 0 };
const targets = friendlyTargets(combat, party); const targets = friendlyTargets(combat, party);
const candidates = combat.abilities const availableAbilities = combat.abilities
.map((ability) => learnedAbility(ability, combat)) .map((ability) => learnedAbility(ability, combat))
.filter((ability): ability is AbilityDefinition => ability !== null) .filter((ability): ability is AbilityDefinition => ability !== null)
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability)) .filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability));
const candidates = availableAbilities
.map((ability) => scoreAbility(ability, role, scenario, combat, targets)) .map((ability) => scoreAbility(ability, role, scenario, combat, targets))
.filter((candidate): candidate is ScoredAbility => candidate !== null) .filter((candidate): candidate is ScoredAbility => candidate !== null)
.sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id)); .sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id));
const hostileTarget = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] : null;
const sharedDecision = evaluateCombatRotation({
now,
classId: combat.classId,
secondaryClassId: combat.secondaryClassId,
specializationId: combat.specializationId,
role,
level: combat.level,
health: combat.health,
maxHealth: combat.maxHealth,
selfId: PLAYER_AGGRO_ID,
resources: combat.resourcePools,
maximumResources: combat.maxResourcePools,
primaryResource: resourceProfileForClass(combat.classId).type,
comboPoints: combat.comboPoints,
comboTargetId: combat.comboTargetId,
runes: combat.deathKnightRunes,
abilities: availableAbilities,
knownDamagingAbilityNames: new Set(availableAbilities.filter((ability) => {
const categories = aiHintsForAbility(ability).categories;
return categories.includes("damage") || categories.includes("execute");
}).map((ability) => ability.name)),
hostileTarget,
friendlyTargets: [
{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, fallen: combat.health <= 0 },
...party.members.map((member) => ({
id: member.id,
health: member.health,
maxHealth: member.maxHealth,
fallen: member.health <= 0,
})),
],
hostileCount: scenario.hostileTargets,
targetIsLoose: role === "tank" && Boolean(hostileTarget?.targetActorId && hostileTarget.targetActorId !== PLAYER_AGGRO_ID),
hasDangerousThreat: role !== "tank" && hostileTarget?.targetActorId === PLAYER_AGGRO_ID,
moving,
maintainedAbilityIds: new Set([
...combat.effects.filter((effect) => effect.sourceActorId === PLAYER_AGGRO_ID && effect.remainingTicks > 0)
.map((effect) => effect.sourceAbilityId),
...combat.auras.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID
&& (aura.expiresAt === null || aura.expiresAt > now))
.flatMap((aura) => availableAbilities
.filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.id === aura.definition.id))
.map((ability) => ability.id)),
]),
activeAuraExclusiveGroups: new Set(combat.auras
.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID && (aura.expiresAt === null || aura.expiresAt > now))
.flatMap((aura) => aura.definition.exclusiveGroup ? [aura.definition.exclusiveGroup] : [])),
activeSummonAbilityIds: new Set(combat.summons
.filter((summon) => summon.ownerActorId === PLAYER_AGGRO_ID && summon.expiresAt > now)
.map((summon) => summon.sourceAbilityId)),
hasActivePet: combat.summons.some((summon) => {
if (summon.ownerActorId !== PLAYER_AGGRO_ID || summon.expiresAt <= now) return false;
const source = availableAbilities.find((ability) => ability.id === summon.sourceAbilityId);
return source?.effects.some((effect) => effect.kind === "pet"
&& ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false;
}),
});
if (sharedDecision) {
const friendlyTarget = sharedDecision.targetId
? targets.find((target) => target.id === sharedDecision.targetId) ?? null
: null;
const existing = candidates.findIndex((candidate) => candidate.ability.id === sharedDecision.ability.id);
if (existing >= 0) candidates.splice(existing, 1);
candidates.unshift({ ability: sharedDecision.ability, score: Number.POSITIVE_INFINITY, friendlyTarget });
}
// Rage healers must begin weapon attacks to fund their heals before allies are injured. // Rage healers must begin weapon attacks to fund their heals before allies are injured.
if (role === "healer" && combat.selectedTargetId if (role === "healer" && combat.selectedTargetId
+2
View File
@@ -12,6 +12,7 @@ import type {
BenchmarkScenarioDefinition, BenchmarkScenarioDefinition,
} from "./benchmarkTypes"; } from "./benchmarkTypes";
import { BENCHMARK_POLICY_ID } from "./benchmarkController"; import { BENCHMARK_POLICY_ID } from "./benchmarkController";
import { COMBAT_ROTATION_VERSION } from "../combatRotation";
interface MutableAbilityMetrics { interface MutableAbilityMetrics {
castsStarted: number; castsStarted: number;
@@ -260,6 +261,7 @@ export class BenchmarkRecorder {
schemaVersion: 1, schemaVersion: 1,
runId: `${this.#build.id}__${this.#scenario.id}-v${this.#scenario.version}__seed-${this.#seed}`, runId: `${this.#build.id}__${this.#scenario.id}-v${this.#scenario.version}__seed-${this.#seed}`,
policyId: BENCHMARK_POLICY_ID, policyId: BENCHMARK_POLICY_ID,
rotationVersion: COMBAT_ROTATION_VERSION,
seed: this.#seed, seed: this.#seed,
build: this.#build, build: this.#build,
scenario: this.#scenario, scenario: this.#scenario,
@@ -98,6 +98,7 @@ function matrix(): BenchmarkMatrixReport {
seeds: [1, 2, 3, 4], seeds: [1, 2, 3, 4],
durationScale: 1, durationScale: 1,
policyId: "baseline-v1", policyId: "baseline-v1",
rotationVersion: "playerbots-inspired-v1",
}, },
scenarios: [scenario], scenarios: [scenario],
builds: [build], builds: [build],
@@ -150,6 +151,7 @@ describe("benchmark reporting", () => {
const csv = serializeBenchmarkMatrixCsv(matrix()); const csv = serializeBenchmarkMatrixCsv(matrix());
expect(csv).toContain("scenario_id,scenario_version,scenario_name"); expect(csv).toContain("scenario_id,scenario_version,scenario_name");
expect(csv).toContain("policy_id,rotation_version,scenario_id");
expect(csv).toContain('mage-burst,wow,mage,,damage,60,"spell, ""burst"""'); expect(csv).toContain('mage-burst,wow,mage,,damage,60,"spell, ""burst"""');
expect(csv).toContain(",dps,3,0,12,20,28\n"); expect(csv).toContain(",dps,3,0,12,20,28\n");
expect(csv.endsWith("\n")).toBe(true); expect(csv.endsWith("\n")).toBe(true);
@@ -164,6 +166,7 @@ describe("benchmark reporting", () => {
expect(markdown).toContain("DPS: 20 [12–28]"); expect(markdown).toContain("DPS: 20 [12–28]");
expect(markdown).toContain("1 invalid run(s) are excluded"); expect(markdown).toContain("1 invalid run(s) are excluded");
expect(markdown).toContain("does not calculate a composite score"); expect(markdown).toContain("does not calculate a composite score");
expect(markdown).toContain("rotation: `playerbots-inspired-v1`");
expect(markdown.endsWith("\n")).toBe(true); expect(markdown.endsWith("\n")).toBe(true);
expect(markdown.endsWith("\n\n")).toBe(false); expect(markdown.endsWith("\n\n")).toBe(false);
}); });
+5 -1
View File
@@ -318,6 +318,7 @@ export function serializeBenchmarkMatrixCsv(input: BenchmarkReportInput): string
"seeds", "seeds",
"duration_scale", "duration_scale",
"policy_id", "policy_id",
"rotation_version",
"scenario_id", "scenario_id",
"scenario_version", "scenario_version",
"scenario_name", "scenario_name",
@@ -354,6 +355,7 @@ export function serializeBenchmarkMatrixCsv(input: BenchmarkReportInput): string
summary.cohort.seeds.join(";"), summary.cohort.seeds.join(";"),
summary.cohort.durationScale, summary.cohort.durationScale,
summary.cohort.policyId, summary.cohort.policyId,
summary.cohort.rotationVersion ?? null,
row.scenarioId, row.scenarioId,
row.scenarioVersion, row.scenarioVersion,
row.scenarioName, row.scenarioName,
@@ -430,7 +432,9 @@ export function renderBenchmarkMatrixMarkdown(input: BenchmarkReportInput): stri
`Generated ${summary.generatedAt} with app ${summary.appVersion}. ` `Generated ${summary.generatedAt} with app ${summary.appVersion}. `
+ `Cohort: level ${summary.cohort.level}, gear \`${summary.cohort.gearPresetId}\`, ` + `Cohort: level ${summary.cohort.level}, gear \`${summary.cohort.gearPresetId}\`, `
+ `${summary.cohort.seeds.length} seed(s), duration scale ${summary.cohort.durationScale}.`, + `${summary.cohort.seeds.length} seed(s), duration scale ${summary.cohort.durationScale}.`,
`Policy: \`${summary.cohort.policyId}\`.`, `Policy: \`${summary.cohort.policyId}\`${summary.cohort.rotationVersion
? `; rotation: \`${summary.cohort.rotationVersion}\``
: ""}.`,
"", "",
`${summary.validRunCount}/${summary.runCount} runs were valid; ` `${summary.validRunCount}/${summary.runCount} runs were valid; `
+ `${summary.invalidRunCount} invalid run(s) are excluded from distributions. ` + `${summary.invalidRunCount} invalid run(s) are excluded from distributions. `
+6
View File
@@ -23,6 +23,7 @@ import type {
BenchmarkRunReport, BenchmarkRunReport,
BenchmarkScenarioDefinition, BenchmarkScenarioDefinition,
} from "./benchmarkTypes"; } from "./benchmarkTypes";
import { specializationsForClass } from "../specializationCatalog";
const BENCHMARK_CLOCK_ORIGIN = 1_000_000; const BENCHMARK_CLOCK_ORIGIN = 1_000_000;
const BENCHMARK_MOB_HEALTH = 1_000_000_000; const BENCHMARK_MOB_HEALTH = 1_000_000_000;
@@ -274,9 +275,12 @@ export async function runCombatBenchmark(
let completed = true; let completed = true;
try { try {
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
const specializationId = specializationsForClass(build.classId)
.find((specialization) => specialization.displayName === build.orientation)?.id;
combat.initializeCharacter({ combat.initializeCharacter({
classId: build.classId, classId: build.classId,
secondaryClassId: build.secondaryClassId, secondaryClassId: build.secondaryClassId,
specializationId,
level: build.level, level: build.level,
talentRanks: build.talentRanks, talentRanks: build.talentRanks,
inventory: build.inventory, inventory: build.inventory,
@@ -296,6 +300,8 @@ export async function runCombatBenchmark(
const positions = partyPositions(scenario.partyLayout, party.members.length); const positions = partyPositions(scenario.partyLayout, party.members.length);
party.members.forEach((member, index) => registerPartyRuntimePosition(member.id, positions[index] ?? [0, 0, 0])); party.members.forEach((member, index) => registerPartyRuntimePosition(member.id, positions[index] ?? [0, 0, 0]));
useCombatStore.getState().synchronizeActors(); useCombatStore.getState().synchronizeActors();
useCombatStore.getState().applyDefaultPartyBuffs(now);
useCombatStore.getState().applyPrecombatPreparation(now);
const referenceTank = build.role === "tank" const referenceTank = build.role === "tank"
? { maxHealth: initializedCombat.maxHealth } ? { maxHealth: initializedCombat.maxHealth }
+2
View File
@@ -126,6 +126,7 @@ export interface BenchmarkRunReport {
readonly schemaVersion: 1; readonly schemaVersion: 1;
readonly runId: string; readonly runId: string;
readonly policyId: string; readonly policyId: string;
readonly rotationVersion?: string;
readonly seed: number; readonly seed: number;
readonly build: BenchmarkBuildDefinition; readonly build: BenchmarkBuildDefinition;
readonly scenario: BenchmarkScenarioDefinition; readonly scenario: BenchmarkScenarioDefinition;
@@ -149,6 +150,7 @@ export interface BenchmarkMatrixReport {
readonly seeds: readonly number[]; readonly seeds: readonly number[];
readonly durationScale: number; readonly durationScale: number;
readonly policyId: string; readonly policyId: string;
readonly rotationVersion?: string;
}; };
readonly scenarios: readonly BenchmarkScenarioDefinition[]; readonly scenarios: readonly BenchmarkScenarioDefinition[];
readonly builds: readonly BenchmarkBuildDefinition[]; readonly builds: readonly BenchmarkBuildDefinition[];
@@ -62,6 +62,8 @@ export async function runDungeonBossBenchmark(
combat.setPlayerPosition([-2, 0, 0]); combat.setPlayerPosition([-2, 0, 0]);
usePartyStore.getState().initializeParty(seed, build.level, useCombatStore.getState().maxHealth, build.role, 5, build.source); usePartyStore.getState().initializeParty(seed, build.level, useCombatStore.getState().maxHealth, build.role, 5, build.source);
combat.synchronizeActors(); combat.synchronizeActors();
combat.applyDefaultPartyBuffs(now);
combat.applyPrecombatPreparation(now);
combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry, combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry,
...scaling, attacks: entity.combat?.attacks, xpReward: 0 }); ...scaling, attacks: entity.combat?.attacks, xpReward: 0 });
setMobPosition(bossId, [0, 0, 0]); setMobPosition(bossId, [0, 0, 0]);
+2 -2
View File
@@ -76,7 +76,7 @@ describe("Boss Testing", () => {
expect(scale(1, 10).maxHealth).toBe(nativeHealth * bossDifficultyMultipliers(10).health); expect(scale(1, 10).maxHealth).toBe(nativeHealth * bossDifficultyMultipliers(10).health);
const campaign = dungeonEnemyScaling(maraudon, maraudon.entities[celebras.entityId], celebras.spawn.id, const campaign = dungeonEnemyScaling(maraudon, maraudon.entities[celebras.entityId], celebras.spawn.id,
1, maraudon.defaultDifficultyId, 1); 1, maraudon.defaultDifficultyId, 1);
expect(campaign.maxHealth).toBe(900); expect(campaign.maxHealth).toBe(1050);
expect(scale(1).damageMultiplier).toBe(campaign.damageMultiplier); expect(scale(1).damageMultiplier).toBe(campaign.damageMultiplier);
expect(entity.combat?.attacks).toEqual(maraudon.entities[celebras.entityId].combat?.attacks); expect(entity.combat?.attacks).toEqual(maraudon.entities[celebras.entityId].combat?.attacks);
}); });
@@ -113,7 +113,7 @@ describe("Boss Testing", () => {
* (entity.combat?.healthMultiplier ?? 1), * (entity.combat?.healthMultiplier ?? 1),
540, 540,
)); ));
expect(campaign.maxHealth).toBe(Math.max(testing.maxHealth, 900 * 1.8)); expect(campaign.maxHealth).toBe(Math.max(testing.maxHealth, 1050 * 1.9));
}); });
it("recognizes actual boss deaths and awards no progression or collectible rewards", () => { it("recognizes actual boss deaths and awards no progression or collectible rewards", () => {
+2 -2
View File
@@ -31,8 +31,8 @@ describe("default difficulty CoA healer rotations", () => {
for (const seed of ["class-balance-1", "class-balance-2", "class-balance-3"]) { for (const seed of ["class-balance-1", "class-balance-2", "class-balance-3"]) {
const result = await runDungeonBossBenchmark(build, boss.id, seed, true); const result = await runDungeonBossBenchmark(build, boss.id, seed, true);
samples.push({ level, classId: build.classId, orientation: build.orientation, seed, result }); samples.push({ level, classId: build.classId, orientation: build.orientation, seed, result });
if (!result.playerAlive || result.allyHealing <= 0 || result.deaths > 0) { if (!result.playerAlive || result.healing <= 0 || result.deaths > 0) {
failures.push(`${build.id}:${seed}: ${result.outcome}, ${result.deaths} deaths, ${result.allyHealing} ally healing`); failures.push(`${build.id}:${seed}: ${result.outcome}, ${result.deaths} deaths, ${result.healing} effective healing`);
} }
} }
} }
+10 -3
View File
@@ -9,7 +9,10 @@ import { deriveCharacterStats } from "./wotlkStats";
export function companionResourceMaximum(member: PartyMember, id: string): number { export function companionResourceMaximum(member: PartyMember, id: string): number {
return id === member.resourceType ? member.maxResource : id === "mana" && !isRomClassId(member.classId) return id === member.resourceType ? member.maxResource : id === "mana" && !isRomClassId(member.classId)
? deriveCharacterStats(baseClassFor(member.classId), member.level, member.gearStats, member.raceId).mana : 100; ? deriveCharacterStats(baseClassFor(member.classId), member.level, member.gearStats, member.raceId).mana
: id === "nature-power" ? 10
: id === "psi" ? 6
: 100;
} }
/** /**
@@ -49,8 +52,8 @@ export function companionActorFromMember(
cast: actor?.cast ?? member.activeCast, cast: actor?.cast ?? member.activeCast,
controlledUntil: actor?.controlledUntil ?? member.controlledUntil, controlledUntil: actor?.controlledUntil ?? member.controlledUntil,
schoolLockedUntil: actor?.schoolLockedUntil ?? member.schoolLockedUntil, schoolLockedUntil: actor?.schoolLockedUntil ?? member.schoolLockedUntil,
comboPoints: actor?.comboPoints ?? 0, comboPoints: actor?.comboPoints ?? member.comboPoints,
comboTargetId: actor?.comboTargetId ?? null, comboTargetId: actor?.comboTargetId ?? member.comboTargetId,
runes: actor?.runes ?? member.runes, runes: actor?.runes ?? member.runes,
abilityIds: actor?.abilityIds, abilityIds: actor?.abilityIds,
alive: (actor?.health ?? member.health) > 0, alive: (actor?.health ?? member.health) > 0,
@@ -71,6 +74,8 @@ export function memberFromCompanionActor(member: PartyMember, actor?: CombatActo
maxResource: primary?.maximum ?? member.maxResource, maxResource: primary?.maximum ?? member.maxResource,
resourcePools, resourcePools,
lastResourceSpentAt, lastResourceSpentAt,
comboPoints: actor.comboPoints,
comboTargetId: actor.comboTargetId,
cooldowns: actor.cooldowns, cooldowns: actor.cooldowns,
globalCooldownEndsAt: actor.globalCooldownEndsAt, globalCooldownEndsAt: actor.globalCooldownEndsAt,
activeCast: actor.cast, activeCast: actor.cast,
@@ -106,6 +111,8 @@ export function companionActorWithMemberRuntime(
cast: member.activeCast ? { ...member.activeCast, targetId: member.activeCastTargetId } : null, cast: member.activeCast ? { ...member.activeCast, targetId: member.activeCastTargetId } : null,
controlledUntil: member.controlledUntil, controlledUntil: member.controlledUntil,
schoolLockedUntil: member.schoolLockedUntil, schoolLockedUntil: member.schoolLockedUntil,
comboPoints: member.comboPoints,
comboTargetId: member.comboTargetId,
runes: member.runes, runes: member.runes,
alive: member.health > 0, alive: member.health > 0,
}); });
+56 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import "./abilityCatalog"; import "./abilityCatalog";
import { import {
characterCombatAnimationForPresentation,
clipsForAnimationIds, clipsForAnimationIds,
parseWoWAnimationName, parseWoWAnimationName,
selectCharacterBaseClip, selectCharacterBaseClip,
@@ -8,6 +9,7 @@ import {
selectCharacterVerticalClip, selectCharacterVerticalClip,
selectWoWAnimationVariation, selectWoWAnimationVariation,
} from "./combatAnimation"; } from "./combatAnimation";
import type { CombatPresentationEvent } from "./combatPresentation";
const clips = [ const clips = [
{ name: "Stand (ID 0 variation 0)" }, { name: "Stand (ID 0 variation 0)" },
@@ -32,6 +34,28 @@ const clips = [
{ name: "HoldBow (ID 109 variation 0)" }, { name: "HoldBow (ID 109 variation 0)" },
]; ];
function presentationEvent(
overrides: Partial<CombatPresentationEvent> = {},
): CombatPresentationEvent {
return {
id: 1,
sessionId: 1,
occurredAt: 1_000,
phase: "release",
actorGroup: "party",
sourceActorId: "party-1",
targetActorId: "mob-1",
abilityId: "party-auto-attack",
name: "Auto Attack",
source: "fallback",
school: "physical",
delivery: "melee",
origin: [0, 0, 0],
targetPosition: [1, 0, 0],
...overrides,
};
}
describe("WoW character animation selection", () => { describe("WoW character animation selection", () => {
it("parses AnimationData IDs and preserves labels and variations", () => { it("parses AnimationData IDs and preserves labels and variations", () => {
expect(parseWoWAnimationName("AttackUnarmed (ID 16 variation 1)")).toEqual({ expect(parseWoWAnimationName("AttackUnarmed (ID 16 variation 1)")).toEqual({
@@ -108,13 +132,44 @@ describe("WoW character animation selection", () => {
it("maps melee, wound, and terminal death events to native clips", () => { it("maps melee, wound, and terminal death events to native clips", () => {
expect(selectCharacterEventClip(clips, "warrior", { revision: 1, kind: "attack" })) expect(selectCharacterEventClip(clips, "warrior", { revision: 1, kind: "attack" }))
.toMatchObject({ clip: clips[4], loop: false }); .toMatchObject({ clip: clips[3], loop: false });
expect(selectCharacterEventClip(clips, "warrior", { revision: 2, kind: "wound" })) expect(selectCharacterEventClip(clips, "warrior", { revision: 2, kind: "wound" }))
.toMatchObject({ clip: clips[7], loop: false }); .toMatchObject({ clip: clips[7], loop: false });
expect(selectCharacterEventClip(clips, "warrior", { revision: 3, kind: "death" })) expect(selectCharacterEventClip(clips, "warrior", { revision: 3, kind: "death" }))
.toMatchObject({ clip: clips[8], terminal: true }); .toMatchObject({ clip: clips[8], terminal: true });
}); });
it("reserves special melee clips for abilities instead of ordinary weapon swings", () => {
expect(selectCharacterEventClip(clips, "paladin", { revision: 1, kind: "attack" }))
.toMatchObject({ clip: clips[3], loop: false });
});
it("drives party animation only from actual combat presentation phases", () => {
expect(characterCombatAnimationForPresentation(presentationEvent(), 7)).toEqual({
revision: 7,
kind: "attack",
});
expect(characterCombatAnimationForPresentation(presentationEvent({
phase: "cast-start",
abilityId: "paladin-holy-light",
}), 8)).toEqual({
revision: 8,
kind: "ability-start",
abilityId: "paladin-holy-light",
});
expect(characterCombatAnimationForPresentation(presentationEvent({
abilityId: "warrior-mortal-strike",
}), 9)).toEqual({
revision: 9,
kind: "ability-release",
abilityId: "warrior-mortal-strike",
});
expect(characterCombatAnimationForPresentation(presentationEvent({ phase: "impact" }), 10))
.toBeNull();
expect(characterCombatAnimationForPresentation(presentationEvent({ actorGroup: "enemy" }), 11))
.toBeNull();
});
it("uses native RuneWaker spell, bow, and melee clips for RoM characters", () => { it("uses native RuneWaker spell, bow, and melee clips for RoM characters", () => {
const runewakerClips = [ const runewakerClips = [
{ name: "Action - 1h_charge_up" }, { name: "Action - 1h_charge_up" },
+21 -2
View File
@@ -1,6 +1,7 @@
import { baseClassFor, type BaseClassId, type ClassId } from "../app/characterCatalog"; import { baseClassFor, type BaseClassId, type ClassId } from "../app/characterCatalog";
import type { AbilityDefinition } from "./abilityCatalog"; import type { AbilityDefinition } from "./abilityCatalog";
import { abilityAnimationById } from "./abilityAnimationLookup"; import { abilityAnimationById } from "./abilityAnimationLookup";
import type { CombatPresentationEvent } from "./combatPresentation";
import type { CharacterVerticalMotion } from "./playerJump"; import type { CharacterVerticalMotion } from "./playerJump";
export type CharacterCombatAnimationKind = export type CharacterCombatAnimationKind =
@@ -53,8 +54,8 @@ const MELEE_CLASSES = new Set<ClassId>([
]); ]);
const CLASS_ATTACK_IDS: Readonly<Record<BaseClassId, readonly number[]>> = { const CLASS_ATTACK_IDS: Readonly<Record<BaseClassId, readonly number[]>> = {
warrior: [57, 17, 58, 18, 16, 118], warrior: [17, 18, 16, 118, 57, 58],
paladin: [57, 17, 59, 16, 118], paladin: [17, 18, 16, 118, 59, 57],
hunter: [46, 47, 49, 107, 16], hunter: [46, 47, 49, 107, 16],
rogue: [85, 88, 87, 117, 57, 17, 95, 16], rogue: [85, 88, 87, 117, 57, 17, 95, 16],
priest: [53, 54, 32, 2, 16], priest: [53, 54, 32, 2, 16],
@@ -65,6 +66,24 @@ const CLASS_ATTACK_IDS: Readonly<Record<BaseClassId, readonly number[]>> = {
druid: [53, 54, 32, 118, 16], druid: [53, 54, 32, 118, 16],
}; };
/** Convert authoritative party combat presentation into one render event. */
export function characterCombatAnimationForPresentation(
event: CombatPresentationEvent,
revision: number,
): CharacterCombatAnimationEvent | null {
if (event.actorGroup !== "party") return null;
if (event.phase === "cast-start") {
return { revision, kind: "ability-start", abilityId: event.abilityId };
}
if (event.phase === "cast-cancel") {
return { revision, kind: "ability-cancel", abilityId: event.abilityId };
}
if (event.phase !== "release") return null;
return event.abilityId === "party-auto-attack"
? { revision, kind: "attack" }
: { revision, kind: "ability-release", abilityId: event.abilityId };
}
/** Parse the names emitted by wow.export without depending on Three.js. */ /** Parse the names emitted by wow.export without depending on Three.js. */
export function parseWoWAnimationName(name: string): ParsedWoWAnimationName { export function parseWoWAnimationName(name: string): ParsedWoWAnimationName {
const match = name.match(/^\s*(.*?)\s*\(ID\s+(\d+)\s+variation\s+(\d+)\)\s*$/i); const match = name.match(/^\s*(.*?)\s*\(ID\s+(\d+)\s+variation\s+(\d+)\)\s*$/i);
+71 -1
View File
@@ -2,9 +2,12 @@ import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro"; import { PLAYER_AGGRO_ID } from "./aggro";
import { applyAura, type AuraDefinition } from "./combatAuras"; import { applyAura, type AuraDefinition } from "./combatAuras";
import { useCombatStore } from "./combatStore"; import { useCombatStore } from "./combatStore";
import { abilityById } from "./abilityCatalog";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
function registerTarget(id = "target", distance = 2): void { function registerTarget(id = "target", distance = 2): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 }); useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
@@ -99,18 +102,85 @@ describe("named aura combat runtime", () => {
))).toHaveLength(1); ))).toHaveLength(1);
}); });
it("enters the dungeon with spec maintenance and permanent pets already prepared", () => {
const now = 30_000;
useCombatStore.getState().initializeCharacter({
classId: "hunter",
specializationId: "hunter:beast-mastery",
role: "damage",
level: 80,
inventory: [],
equipment: {},
});
const warlock = createConfiguredPartyMember({
name: "Demo",
classId: "warlock",
specialization: "Demonology",
specializationId: "warlock:demonology",
talentTreeId: "warlock-demonology",
talentRanks: runtimePartyTalentRanksForLevel("warlock", "Demonology", 80),
role: "damage",
}, 80, 0, "precombat-preparation");
usePartyStore.setState({ members: [warlock], playerRole: "damage" });
useCombatStore.getState().synchronizeActors();
const playerResource = useCombatStore.getState().resource;
const companionResource = warlock.resource;
useCombatStore.getState().applyPrecombatPreparation(now);
const prepared = useCombatStore.getState();
expect(prepared.auras).toContainEqual(expect.objectContaining({
targetId: PLAYER_AGGRO_ID,
definition: expect.objectContaining({ exclusiveGroup: "hunter:aspect" }),
}));
expect(prepared.auras).toContainEqual(expect.objectContaining({
targetId: warlock.id,
definition: expect.objectContaining({ exclusiveGroup: "warlock:armor" }),
}));
expect(prepared.summons.some((summon) => summon.ownerActorId === PLAYER_AGGRO_ID
&& abilityById(summon.sourceAbilityId)?.name === "Call Pet")).toBe(true);
expect(prepared.summons.some((summon) => summon.ownerActorId === warlock.id
&& abilityById(summon.sourceAbilityId)?.name === "Summon Felguard")).toBe(true);
expect(prepared.resource).toBe(playerResource);
expect(prepared.globalCooldownEndsAt).toBe(0);
expect(usePartyStore.getState().members[0]).toMatchObject({
resource: companionResource,
globalCooldownEndsAt: 0,
cooldowns: {},
});
const summonIds = prepared.summons.map((summon) => summon.id);
useCombatStore.getState().applyPrecombatPreparation(now + 1_000);
expect(useCombatStore.getState().summons.map((summon) => summon.id)).toEqual(summonIds);
registerTarget("pet-target");
useCombatStore.getState().damageMob("pet-target", 1, now + 1_100);
const beforePetAttack = useCombatStore.getState().mobs["pet-target"].health;
useCombatStore.getState().tick(0.1, now + 2_100);
expect(useCombatStore.getState().mobs["pet-target"].health).toBeLessThan(beforePetAttack);
});
it("consumes a named absorb before player health", () => { it("consumes a named absorb before player health", () => {
const now = Date.now(); const now = Date.now();
const events: StructuredCombatEvent[] = [];
const stop = subscribeCombatEvents((event) => events.push(event));
expect(useCombatStore.getState().castAbility( expect(useCombatStore.getState().castAbility(
"wow335-priest-power-word-shield", "wow335-priest-power-word-shield",
undefined, undefined,
now, now,
).ok).toBe(true); ).ok).toBe(true);
stop();
const beforeHealth = useCombatStore.getState().health; const beforeHealth = useCombatStore.getState().health;
const beforeAbsorb = useCombatStore.getState().auras const beforeAbsorb = useCombatStore.getState().auras
.flatMap((aura) => aura.absorbs) .flatMap((aura) => aura.absorbs)
.reduce((total, absorb) => total + absorb.remaining, 0); .reduce((total, absorb) => total + absorb.remaining, 0);
expect(beforeAbsorb).toBeGreaterThan(0); expect(beforeAbsorb).toBeGreaterThan(0);
expect(events).toContainEqual(expect.objectContaining({
kind: "absorb",
sourceActorId: PLAYER_AGGRO_ID,
targetActorId: PLAYER_AGGRO_ID,
abilityId: "wow335-priest-power-word-shield",
effectiveAmount: beforeAbsorb,
}));
expect(useCombatStore.getState().auras.find((aura) => aura.absorbs.length)?.definition.icon).toBe( expect(useCombatStore.getState().auras.find((aura) => aura.absorbs.length)?.definition.icon).toBe(
useCombatStore.getState().abilities.find((ability) => ( useCombatStore.getState().abilities.find((ability) => (
ability.id === "wow335-priest-power-word-shield" ability.id === "wow335-priest-power-word-shield"
+581
View File
@@ -0,0 +1,581 @@
import { describe, expect, it } from "vitest";
import { BASE_CLASS_IDS } from "../app/characterCatalog";
import {
abilitiesForCharacter,
abilitiesForClass,
learnedAbilityForBuild,
resourceProfileForClass,
type AbilityDefinition,
type ResourceType,
} from "./abilityCatalog";
import {
abilityIsPrecombatPreparation,
abilityHasRotationSemantics,
CLASSIC_ROTATION_PROFILES,
COMBAT_ROTATION_VERSION,
evaluateCombatRotation,
rotationProfileFor,
type RotationContext,
} from "./combatRotation";
import { createDeathKnightRunes } from "./combatActors";
import { aiHintsForAbility } from "./abilityRules";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { partyTalentPrimaryTreeId } from "./partyTalents";
import { specializationsForClass } from "./specializationCatalog";
import { talentNodeById } from "./talentCatalog";
const SIGNATURES = Object.freeze({
warrior: "Mortal Strike",
paladin: "Divine Storm",
hunter: "Explosive Shot",
rogue: "Killing Spree",
priest: "Penance",
"death-knight": "Frost Strike",
shaman: "Feral Spirit",
mage: "Arcane Barrage",
warlock: "Haunt",
druid: "Starfall",
} as const);
function context(overrides: Partial<RotationContext> = {}): RotationContext {
const classId = overrides.classId ?? "rogue";
const primaryResource = resourceProfileForClass(classId).type;
return {
now: 1_000,
classId,
specializationId: classId === "rogue" ? "rogue:combat" : null,
role: "damage",
level: 80,
health: 1_000,
maxHealth: 1_000,
resources: { [primaryResource]: 100 },
maximumResources: { [primaryResource]: 100 },
primaryResource,
comboPoints: 0,
comboTargetId: null,
runes: classId === "death-knight" ? createDeathKnightRunes() : [],
abilities: [],
hostileTarget: { id: "target", health: 1_000, maxHealth: 1_000 },
hostileCount: 1,
...overrides,
};
}
function ability(classId: RotationContext["classId"], name: string): AbilityDefinition {
const result = abilitiesForClass(classId).find((candidate) => candidate.name === name);
expect(result, `${classId}:${name}`).toBeDefined();
return result!;
}
function semanticAbility(
id: string,
effects: AbilityDefinition["effects"],
options: Partial<AbilityDefinition> = {},
): AbilityDefinition {
return {
id,
classId: "rom-warrior",
dbcSpellId: 0,
name: id,
unlockLevel: 1,
icon: "",
sigil: "AI",
description: id,
target: "hostile",
range: { min: 0, max: 30 },
castMode: "instant",
castTimeMs: 0,
cooldownMs: 0,
gcdMs: 1_500,
cost: { resource: "rage", amount: 0 },
effects,
...options,
};
}
describe("shared combat rotations", () => {
it("registers exactly one resolvable profile for every classic specialization", () => {
const classicSpecs = BASE_CLASS_IDS.flatMap((classId) => specializationsForClass(classId));
expect(classicSpecs).toHaveLength(31);
expect(CLASSIC_ROTATION_PROFILES).toHaveLength(31);
expect(new Set(CLASSIC_ROTATION_PROFILES.map((entry) => entry.specializationId)).size).toBe(31);
for (const specialization of classicSpecs) {
const rotation = rotationProfileFor(specialization.classId, specialization.id);
expect(rotation?.specializationId, specialization.id).toBe(specialization.id);
for (const name of rotation?.abilityPriority ?? []) {
expect(abilitiesForClass(specialization.classId).some((candidate) => candidate.name === name),
`${specialization.id}:${name}`).toBe(true);
}
}
expect(COMBAT_ROTATION_VERSION).toMatch(/^playerbots-inspired-/);
});
it("prepares only long-lived maintenance effects and permanent pets before combat", () => {
const longBuff = semanticAbility("long-buff", [{
kind: "apply-aura",
aura: { id: "long-buff", name: "Long buff", disposition: "buff", durationMs: 60_000,
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 10 }] },
recipient: "caster",
sourceEffectType: 6,
sourceAuraType: 22,
miscValue: 0,
triggerSpellId: 0,
}], { target: "self" });
const shortBuff = semanticAbility("short-buff", [{
kind: "apply-aura",
aura: { id: "short-buff", name: "Short buff", disposition: "buff", durationMs: 30_000,
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 10 }] },
recipient: "caster",
sourceEffectType: 6,
sourceAuraType: 22,
miscValue: 0,
triggerSpellId: 0,
}], { target: "self" });
const pet = semanticAbility("call-pet", [{ kind: "pet", action: "call", creatureId: 1 }], { target: "self" });
const temporaryPet = semanticAbility("temporary-pet", [{ kind: "pet", action: "summon", creatureId: 2 }], {
target: "self",
cooldownMs: 120_000,
});
expect(abilityIsPrecombatPreparation(longBuff)).toBe(true);
expect(abilityIsPrecombatPreparation(shortBuff)).toBe(false);
expect(abilityIsPrecombatPreparation(pet)).toBe(true);
expect(abilityIsPrecombatPreparation(temporaryPet)).toBe(false);
});
it("gates representative signature abilities from every class by their exact talent node", () => {
for (const [classId, name] of Object.entries(SIGNATURES)) {
const signature = ability(classId as RotationContext["classId"], name);
expect(signature.talentNodeId, `${classId}:${name}`).toMatch(/^talent-\d+$/);
expect(learnedAbilityForBuild(signature, 80, {}), `${classId}:${name}`).toBeNull();
expect(learnedAbilityForBuild(signature, 80, { [signature.talentNodeId!]: 1 }), `${classId}:${name}`).not.toBeNull();
expect(signature.effects.length).toBeGreaterThan(0);
expect(signature.effects.some((effect) => !["unsupported", "scripted", "utility"].includes(effect.kind))).toBe(true);
expect(signature.ranks?.length, `${classId}:${name} ranks`).toBeGreaterThan(0);
expect(signature.ranks?.every((rank) => rank.cost.amount >= 0 && rank.cooldownMs >= 0 && rank.effects.length > 0)).toBe(true);
expect(signature.cooldownMs).toBeGreaterThanOrEqual(0);
expect(signature.cost.amount).toBeGreaterThanOrEqual(0);
}
});
it("learns every primary-tree action named by its level-80 rotation", () => {
for (const classId of BASE_CLASS_IDS) {
for (const specialization of specializationsForClass(classId)) {
const profile = rotationProfileFor(classId, specialization.id)!;
const primaryTreeId = partyTalentPrimaryTreeId(classId, specialization.displayName);
const ranks = runtimePartyTalentRanksForLevel(classId, specialization.displayName, 80);
const primaryActions = profile.abilityPriority
.map((name) => ability(classId, name))
.filter((candidate) => candidate.talentNodeId
&& talentNodeById(candidate.talentNodeId)?.treeId === primaryTreeId);
for (const candidate of primaryActions) {
expect(
learnedAbilityForBuild(candidate, 80, ranks),
`${specialization.id}:${candidate.name}`,
).not.toBeNull();
}
}
}
});
it("keeps specialization talent actions isolated", () => {
const furyTalents = runtimePartyTalentRanksForLevel("warrior", "Fury", 80);
const armsTalents = runtimePartyTalentRanksForLevel("warrior", "Arms", 80);
const bloodthirst = ability("warrior", "Bloodthirst");
const mortalStrike = ability("warrior", "Mortal Strike");
expect(learnedAbilityForBuild(bloodthirst, 80, furyTalents)).not.toBeNull();
expect(learnedAbilityForBuild(mortalStrike, 80, furyTalents)).toBeNull();
expect(learnedAbilityForBuild(mortalStrike, 80, armsTalents)).not.toBeNull();
expect(learnedAbilityForBuild(bloodthirst, 80, armsTalents)).toBeNull();
});
it("uses combo builders before finishers and actual combo points for the decision", () => {
const builder = ability("rogue", "Sinister Strike");
const finisher = ability("rogue", "Eviscerate");
const building = evaluateCombatRotation(context({ abilities: [finisher, builder] }));
expect(building?.ability.id).toBe(builder.id);
const finishing = evaluateCombatRotation(context({
abilities: [builder, finisher],
comboPoints: 5,
comboTargetId: "target",
}));
expect(finishing?.ability.id).toBe(finisher.id);
});
it("reacts before throughput and conserves long offensive cooldowns for durable targets", () => {
const kick = ability("rogue", "Kick");
const strike = ability("rogue", "Sinister Strike");
expect(evaluateCombatRotation(context({
abilities: [strike, kick],
hostileTarget: {
id: "target",
health: 1_000,
maxHealth: 1_000,
activeCast: { interruptible: true },
},
}))?.ability.id).toBe(kick.id);
const bestialWrath = ability("hunter", "Bestial Wrath");
const arcaneShot = ability("hunter", "Arcane Shot");
const hunter = context({
classId: "hunter",
specializationId: "hunter:beast-mastery",
primaryResource: "mana",
resources: { mana: 100 },
maximumResources: { mana: 100 },
abilities: [bestialWrath, arcaneShot],
});
expect(evaluateCombatRotation(hunter)?.ability.id).toBe(arcaneShot.id);
expect(evaluateCombatRotation({ ...hunter, hostileTarget: { id: "boss", health: 10_000, maxHealth: 10_000, boss: true } })?.ability.id)
.toBe(bestialWrath.id);
});
it("uses healer triage bands and area healing for three injured allies", () => {
const flash = ability("priest", "Flash Heal");
const prayer = ability("priest", "Prayer of Healing");
const hostileLeech = semanticAbility("hostile-leech", [
{ kind: "damage", coefficient: 1 },
{ kind: "heal", coefficient: 1, recipient: "caster" },
]);
const decision = evaluateCombatRotation(context({
classId: "priest",
specializationId: "priest:holy",
role: "healer",
primaryResource: "mana",
resources: { mana: 1_000 },
maximumResources: { mana: 1_000 },
selfId: "self",
abilities: [flash, prayer, hostileLeech],
friendlyTargets: [
{ id: "a", health: 250, maxHealth: 1_000 },
{ id: "b", health: 500, maxHealth: 1_000 },
{ id: "c", health: 700, maxHealth: 1_000 },
],
}));
expect(decision?.ability.id).toBe(prayer.id);
expect(decision?.targetId).toBe("a");
});
it("includes RoM secondary general and elite skills in shared-policy candidates", () => {
const primaryOnly = abilitiesForCharacter("rom-warrior", null);
const dualClass = abilitiesForCharacter("rom-warrior", "rom-scout");
expect(dualClass.length).toBeGreaterThan(primaryOnly.length);
expect(dualClass.some((candidate) => candidate.secondaryClassId === "rom-scout")).toBe(true);
const secondaryGeneral = dualClass.find((candidate) => candidate.classId === "rom-scout"
&& !candidate.secondaryClassId && !candidate.passive && abilityHasRotationSemantics(candidate)
&& aiHintsForAbility(candidate).categories.some((category) => ["damage", "summon", "buff"].includes(category)));
const elite = dualClass.find((candidate) => candidate.secondaryClassId === "rom-scout"
&& !candidate.passive && abilityHasRotationSemantics(candidate)
&& aiHintsForAbility(candidate).categories.some((category) => ["damage", "summon", "buff"].includes(category)));
expect(secondaryGeneral).toBeDefined();
expect(elite).toBeDefined();
const dualContext = context({
classId: "rom-warrior",
secondaryClassId: "rom-scout",
specializationId: null,
primaryResource: "rage",
resources: { rage: 100, focus: 100 },
maximumResources: { rage: 100, focus: 100 },
hostileTarget: { id: "boss", health: 20_000, maxHealth: 20_000, boss: true },
});
expect(evaluateCombatRotation({ ...dualContext, abilities: [secondaryGeneral!] })?.ability.id).toBe(secondaryGeneral!.id);
expect(evaluateCombatRotation({ ...dualContext, abilities: [elite!] })?.ability.id).toBe(elite!.id);
});
it("handles execute, cleave/AoE thresholds, and maintained effects", () => {
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
const execute = semanticAbility("execute", [{ kind: "execute", coefficient: 1 }]);
const area = semanticAbility("area", [{ kind: "damage", coefficient: 1 }], { radius: 8 });
const debuff = semanticAbility("debuff", [{
kind: "apply-aura",
aura: { id: "test-debuff", name: "Test", disposition: "debuff", durationMs: 10_000,
modifiers: [{ kind: "damage", direction: "taken", operation: "percent", value: 0.1 }] },
sourceEffectType: 6,
sourceAuraType: 4,
miscValue: 0,
triggerSpellId: 0,
}]);
const base = context({ classId: "rom-warrior", specializationId: null, primaryResource: "rage",
resources: { rage: 100 }, maximumResources: { rage: 100 } });
expect(evaluateCombatRotation({ ...base, abilities: [strike, execute] })?.ability.id).toBe("strike");
expect(evaluateCombatRotation({ ...base, abilities: [strike, execute],
hostileTarget: { id: "target", health: 199, maxHealth: 1_000 } })?.ability.id).toBe("execute");
expect(evaluateCombatRotation({ ...base, abilities: [strike, area], hostileCount: 1 })?.ability.id).toBe("strike");
expect(evaluateCombatRotation({ ...base, abilities: [strike, area], hostileCount: 2 })?.ability.id).toBe("area");
expect(evaluateCombatRotation({ ...base, abilities: [strike, debuff] })?.ability.id).toBe("debuff");
expect(evaluateCombatRotation({ ...base, abilities: [strike, debuff], maintainedAbilityIds: new Set([debuff.id]) })?.ability.id)
.toBe("strike");
});
it("requires modeled combat state for reactive attacks and named execute-range spells", () => {
const revenge = ability("warrior", "Revenge");
const warrior = context({
classId: "warrior",
specializationId: "warrior:protection",
role: "tank",
primaryResource: "rage",
resources: { rage: 100 },
maximumResources: { rage: 100 },
abilities: [revenge],
});
expect(evaluateCombatRotation(warrior)).toBeNull();
expect(evaluateCombatRotation({
...warrior,
availableConditionalAbilityNames: new Set(["Revenge"]),
})?.ability.id).toBe(revenge.id);
const drainSoul = ability("warlock", "Drain Soul");
const shadowBolt = ability("warlock", "Shadow Bolt");
const warlock = context({
classId: "warlock",
specializationId: "warlock:affliction",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [drainSoul, shadowBolt],
});
expect(evaluateCombatRotation(warlock)?.ability.id).toBe(shadowBolt.id);
expect(evaluateCombatRotation({
...warlock,
hostileTarget: { id: "target", health: 250, maxHealth: 1_000 },
})?.ability.id).toBe(drainSoul.id);
});
it("keeps available specialization actions ahead of off-profile throughput", () => {
const fireball = ability("mage", "Fireball");
const arcaneMissiles = ability("mage", "Arcane Missiles");
const mage = context({
classId: "mage",
specializationId: "mage:fire",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [arcaneMissiles, fireball],
});
expect(evaluateCombatRotation(mage)?.ability.id).toBe(fireball.id);
const bearMangle = ability("druid", "Mangle (Bear)");
const rake = ability("druid", "Rake");
const moonfire = ability("druid", "Moonfire");
const bear = context({
classId: "druid",
specializationId: "druid:feral-bear",
role: "tank",
primaryResource: "mana",
resources: { mana: 10_000, rage: 100, energy: 100 },
maximumResources: { mana: 10_000, rage: 100, energy: 100 },
abilities: [rake, bearMangle],
});
expect(evaluateCombatRotation(bear)?.ability.id).toBe(bearMangle.id);
expect(evaluateCombatRotation({
...bear,
abilities: [rake, moonfire],
knownDamagingAbilityNames: new Set([rake.name, moonfire.name, bearMangle.name]),
})).toBeNull();
});
it("reserves off-profile utility for reactive needs instead of spending rotation GCDs", () => {
const demoralizingShout = ability("warrior", "Demoralizing Shout");
const pummel = ability("warrior", "Pummel");
const mortalStrike = ability("warrior", "Mortal Strike");
const arms = context({
classId: "warrior",
specializationId: "warrior:arms",
primaryResource: "rage",
resources: { rage: 100 },
maximumResources: { rage: 100 },
abilities: [demoralizingShout, pummel],
knownDamagingAbilityNames: new Set([mortalStrike.name]),
});
expect(evaluateCombatRotation(arms)).toBeNull();
expect(evaluateCombatRotation({
...arms,
hostileTarget: {
id: "caster",
health: 1_000,
maxHealth: 1_000,
activeCast: { interruptible: true },
},
})?.ability.id).toBe(pummel.id);
});
it("never falls a full-health friendly maintenance heal through to a hostile target", () => {
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
const prayer = semanticAbility("prayer", [
{ kind: "heal", coefficient: 1 },
{
kind: "apply-aura",
aura: {
id: "prayer-hot",
name: "Prayer",
disposition: "buff",
durationMs: 21_000,
procs: [{ id: "prayer-proc", triggers: "periodic", chance: 1,
action: { kind: "heal", amount: 10, target: "aura-target" } }],
},
sourceEffectType: 6,
sourceAuraType: 23,
miscValue: 0,
triggerSpellId: 1,
},
], { target: "friendly" });
const base = context({ classId: "venomancer", specializationId: null, abilities: [prayer, strike] });
expect(evaluateCombatRotation(base)?.ability.id).toBe(strike.id);
expect(evaluateCombatRotation({ ...base, friendlyTargets: [{ id: "ally", health: 50, maxHealth: 100 }] }))
.toMatchObject({ ability: { id: prayer.id }, targetId: "ally" });
});
it("handles movement, threat recovery, defenses, resources, pets, and DK runes", () => {
const fireball = ability("mage", "Fireball");
const fireBlast = ability("mage", "Fire Blast");
const mage = context({ classId: "mage", specializationId: "mage:fire", primaryResource: "mana",
resources: { mana: 100 }, maximumResources: { mana: 100 }, abilities: [fireball, fireBlast] });
expect(evaluateCombatRotation(mage)?.ability.id).toBe(fireball.id);
expect(evaluateCombatRotation({ ...mage, moving: true })?.ability.id).toBe(fireBlast.id);
const fade = ability("priest", "Fade");
const mindBlast = ability("priest", "Mind Blast");
const priest = context({ classId: "priest", specializationId: "priest:shadow", primaryResource: "mana",
resources: { mana: 100 }, maximumResources: { mana: 100 }, abilities: [fade, mindBlast] });
expect(evaluateCombatRotation(priest)?.ability.id).toBe(mindBlast.id);
expect(evaluateCombatRotation({ ...priest, hasDangerousThreat: true })?.ability.id).toBe(fade.id);
const shield = semanticAbility("shield", [{ kind: "shield", coefficient: 1, durationMs: 5_000 }], { target: "self" });
const fallback = semanticAbility("fallback", [{ kind: "damage", coefficient: 1 }]);
const generator = semanticAbility("generator", [{ kind: "resource", amount: 30, resource: "rage" }], { target: "self" });
const summon = semanticAbility("summon", [{ kind: "summon", creatureId: 1, coefficient: 0.3, durationMs: 30_000 }],
{ target: "self" });
const semantic = context({ classId: "rom-warrior", specializationId: null, primaryResource: "rage",
resources: { rage: 10 }, maximumResources: { rage: 100 }, abilities: [fallback, shield, generator, summon],
activeSummonAbilityIds: new Set([summon.id]) });
expect(evaluateCombatRotation(semantic)?.ability.id).toBe(generator.id);
expect(evaluateCombatRotation({ ...semantic, resources: { rage: 100 }, health: 300,
activeSummonAbilityIds: new Set([summon.id]) })?.ability.id).toBe(shield.id);
expect(evaluateCombatRotation({ ...semantic, resources: { rage: 100 }, health: 1_000,
activeSummonAbilityIds: new Set([summon.id]) })?.ability.id).toBe(fallback.id);
const icyTouch = ability("death-knight", "Icy Touch");
const deathCoil = ability("death-knight", "Death Coil");
const dk = context({ classId: "death-knight", specializationId: "death-knight:unholy",
primaryResource: "runic-power", resources: { "runic-power": 100 }, maximumResources: { "runic-power": 100 },
abilities: [icyTouch, deathCoil], runes: createDeathKnightRunes(0) });
expect(evaluateCombatRotation(dk)?.ability.id).toBe(icyTouch.id);
expect(evaluateCombatRotation({ ...dk, runes: dk.runes.map((rune) => (
rune.type === "frost" ? { ...rune, readyAt: 10_000 } : rune
)) })?.ability.id).toBe(deathCoil.id);
});
it("uses Rockbiter as the low-level Shaman maintenance fallback", () => {
const rockbiter = learnedAbilityForBuild(ability("shaman", "Rockbiter Weapon"), 1, {})!;
const lightningBolt = learnedAbilityForBuild(ability("shaman", "Lightning Bolt"), 1, {})!;
const shaman = context({
classId: "shaman",
specializationId: "shaman:elemental",
level: 1,
primaryResource: "mana",
resources: { mana: 100 },
maximumResources: { mana: 100 },
abilities: [rockbiter, lightningBolt],
maintainedAbilityIds: new Set(),
activeAuraExclusiveGroups: new Set(),
});
expect(evaluateCombatRotation(shaman)?.ability.id).toBe(rockbiter.id);
expect(evaluateCombatRotation({ ...shaman, maintainedAbilityIds: new Set([rockbiter.id]) })?.ability.id)
.toBe(lightningBolt.id);
});
it("maintains Earth Shield on a valid full-health ally", () => {
const earthShield = ability("shaman", "Earth Shield");
const shaman = context({
classId: "shaman",
specializationId: "shaman:restoration",
role: "healer",
selfId: "shaman",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [earthShield],
friendlyTargets: [{ id: "tank", health: 1_000, maxHealth: 1_000 }],
maintainedAbilityIds: new Set(),
activeAuraExclusiveGroups: new Set(),
});
expect(evaluateCombatRotation(shaman)).toMatchObject({
ability: { id: earthShield.id },
targetId: "tank",
});
expect(evaluateCombatRotation({ ...shaman, friendlyTargets: [] })).toBeNull();
});
it("uses Feral Spirit as an Enhancement major cooldown on a durable target", () => {
const feralSpirit = ability("shaman", "Feral Spirit");
const shaman = context({
classId: "shaman",
specializationId: "shaman:enhancement",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [feralSpirit],
hostileTarget: { id: "boss", health: 100_000, maxHealth: 100_000, boss: true },
activeSummonAbilityIds: new Set(),
});
expect(evaluateCombatRotation(shaman)?.ability.id).toBe(feralSpirit.id);
});
it("enters a learned Death Knight presence before attacking", () => {
const bloodPresence = learnedAbilityForBuild(ability("death-knight", "Blood Presence"), 1, {})!;
const icyTouch = learnedAbilityForBuild(ability("death-knight", "Icy Touch"), 1, {})!;
expect(bloodPresence.effects.some((effect) => effect.kind === "heal")).toBe(false);
const deathKnight = context({
classId: "death-knight",
specializationId: "death-knight:blood",
role: "tank",
level: 1,
primaryResource: "runic-power",
resources: { "runic-power": 0 },
maximumResources: { "runic-power": 100 },
runes: createDeathKnightRunes(),
abilities: [bloodPresence, icyTouch],
maintainedAbilityIds: new Set(),
activeAuraExclusiveGroups: new Set(),
});
expect(evaluateCombatRotation(deathKnight)?.ability.id).toBe(bloodPresence.id);
});
it("summons one real pet and ignores noncombat utility summons", () => {
const shadowBolt = ability("warlock", "Shadow Bolt");
const imp = ability("warlock", "Summon Imp");
const eye = ability("warlock", "Eye of Kilrogg");
const warlock = context({
classId: "warlock",
specializationId: null,
primaryResource: "mana",
resources: { mana: 100 },
maximumResources: { mana: 100 },
abilities: [shadowBolt, imp, eye],
activeSummonAbilityIds: new Set(),
hasActivePet: false,
});
expect(evaluateCombatRotation(warlock)?.ability.id).toBe(imp.id);
expect(evaluateCombatRotation({ ...warlock, hasActivePet: true })?.ability.id).toBe(shadowBolt.id);
expect(evaluateCombatRotation({ ...warlock, abilities: [shadowBolt, eye] })?.ability.id).toBe(shadowBolt.id);
});
it("is consumer-independent for equivalent normalized state", () => {
const abilities = [ability("mage", "Fireball"), ability("mage", "Fire Blast")];
const normalized = context({
classId: "mage",
specializationId: "mage:fire",
primaryResource: "mana" as ResourceType,
resources: { mana: 100 },
maximumResources: { mana: 100 },
abilities,
});
const companionDecision = evaluateCombatRotation(normalized);
const benchmarkDecision = evaluateCombatRotation({ ...normalized });
expect(benchmarkDecision).toEqual(companionDecision);
});
});
+561
View File
@@ -0,0 +1,561 @@
import type { ClassId } from "../app/characterCatalog";
import type { AbilityDefinition, ResourceType } from "./abilityCatalog";
import { aiHintsForAbility, canAbilityCastWhileMoving, runeCostForAbility } from "./abilityRules";
import type { DeathKnightRuneState } from "./combatActors";
import { channelAmountEffects } from "./combatChannelAmounts";
import { spendDeathKnightRunes } from "./combatResources";
import { healingAbilityCanAidOthers } from "./healingAbilityRules";
import type { PartyRole } from "./partyRoles";
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v4";
export interface RotationProfile {
readonly id: string;
readonly specializationId: string;
readonly classId: ClassId;
readonly abilityPriority: readonly string[];
}
export interface RotationFriendlyTarget {
readonly id: string;
readonly health: number;
readonly maxHealth: number;
readonly needsDispel?: boolean;
readonly fallen?: boolean;
}
export interface RotationHostileTarget {
readonly id: string;
readonly health: number;
readonly maxHealth: number;
readonly boss?: boolean;
readonly healthMultiplier?: number;
readonly activeCast?: { readonly interruptible?: boolean } | null;
}
export interface RotationContext {
readonly now: number;
readonly classId: ClassId;
readonly secondaryClassId?: ClassId | null;
readonly specializationId?: string | null;
readonly specialization?: string | null;
readonly role: PartyRole;
readonly level: number;
readonly health: number;
readonly maxHealth: number;
readonly selfId?: string;
readonly resources: Readonly<Partial<Record<ResourceType, number>>>;
readonly maximumResources: Readonly<Partial<Record<ResourceType, number>>>;
readonly primaryResource: ResourceType;
readonly comboPoints: number;
readonly comboTargetId: string | null;
readonly runes: readonly DeathKnightRuneState[];
readonly abilities: readonly AbilityDefinition[];
/** Learned actions capable of hostile damage, including unavailable ones. */
readonly knownDamagingAbilityNames?: ReadonlySet<string>;
readonly hostileTarget: RotationHostileTarget | null;
readonly friendlyTargets?: readonly RotationFriendlyTarget[];
readonly hostileCount?: number;
readonly targetIsLoose?: boolean;
readonly hasDangerousThreat?: boolean;
readonly requiredControl?: boolean;
readonly moving?: boolean;
readonly maintainedAbilityIds?: ReadonlySet<string>;
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
readonly activeSummonAbilityIds?: ReadonlySet<string>;
readonly hasActivePet?: boolean;
/** Proc/state-gated actions that the caller has positively made available. */
readonly availableConditionalAbilityNames?: ReadonlySet<string>;
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
}
export interface RotationDecision {
readonly ability: AbilityDefinition;
readonly targetId: string | null;
readonly profileId: string;
readonly ruleId: string;
readonly reason: string;
}
const profile = (
specializationId: string,
classId: ClassId,
abilityPriority: readonly string[],
): RotationProfile => Object.freeze({
id: `classic:${specializationId}`,
specializationId,
classId,
abilityPriority: Object.freeze(abilityPriority),
});
/** Playerbots priorities translated to the executable Healer Man spell model. */
export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([
profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Bladestorm", "Overpower", "Execute", "Slam", "Heroic Strike"]),
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodrage", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "Heroic Strike"]),
profile("warrior:protection", "warrior", ["Defensive Stance", "Battle Stance", "Bloodrage", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "Sunder Armor", "Heroic Strike"]),
profile("paladin:holy", "paladin", ["Concentration Aura", "Devotion Aura", "Blessing of Might", "Seal of Wisdom", "Seal of Light", "Seal of Righteousness", "Holy Shock", "Flash of Light", "Holy Light", "Sacred Shield", "Judgement of Light"]),
profile("paladin:protection", "paladin", ["Devotion Aura", "Blessing of Might", "Seal of Vengeance", "Seal of Corruption", "Seal of Wisdom", "Seal of Righteousness", "Hammer of the Righteous", "Shield of Righteousness", "Holy Shield", "Consecration", "Judgement of Wisdom"]),
profile("paladin:retribution", "paladin", ["Retribution Aura", "Devotion Aura", "Blessing of Might", "Seal of Command", "Seal of Vengeance", "Seal of Corruption", "Seal of Righteousness", "Divine Storm", "Crusader Strike", "Judgement of Wisdom", "Consecration", "Exorcism", "Hammer of Wrath"]),
profile("hunter:beast-mastery", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Kill Command", "Bestial Wrath", "Serpent Sting", "Arcane Shot", "Multi-Shot", "Steady Shot"]),
profile("hunter:marksmanship", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Kill Shot", "Chimera Shot", "Aimed Shot", "Serpent Sting", "Arcane Shot", "Steady Shot"]),
profile("hunter:survival", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Kill Shot", "Explosive Shot", "Black Arrow", "Serpent Sting", "Aimed Shot", "Arcane Shot", "Steady Shot"]),
profile("rogue:assassination", "rogue", ["Envenom", "Rupture", "Mutilate", "Eviscerate", "Sinister Strike"]),
profile("rogue:combat", "rogue", ["Killing Spree", "Blade Flurry", "Eviscerate", "Sinister Strike"]),
profile("rogue:subtlety", "rogue", ["Shadow Dance", "Rupture", "Eviscerate", "Hemorrhage", "Backstab"]),
profile("priest:discipline", "priest", ["Inner Fire", "Penance", "Power Word: Shield", "Flash Heal", "Greater Heal", "Renew"]),
profile("priest:holy", "priest", ["Inner Fire", "Circle of Healing", "Prayer of Mending", "Flash Heal", "Greater Heal", "Renew"]),
profile("priest:shadow", "priest", ["Shadowform", "Inner Fire", "Vampiric Touch", "Devouring Plague", "Shadow Word: Pain", "Mind Blast", "Mind Flay", "Shadow Word: Death"]),
profile("death-knight:blood", "death-knight", ["Frost Presence", "Blood Presence", "Heart Strike", "Death Strike", "Icy Touch", "Plague Strike", "Death Coil"]),
profile("death-knight:frost", "death-knight", ["Blood Presence", "Icy Touch", "Plague Strike", "Howling Blast", "Frost Strike", "Obliterate"]),
profile("death-knight:unholy", "death-knight", ["Unholy Presence", "Blood Presence", "Raise Dead", "Plague Strike", "Icy Touch", "Scourge Strike", "Death Coil", "Death and Decay"]),
profile("shaman:elemental", "shaman", ["Flametongue Weapon", "Rockbiter Weapon", "Lightning Shield", "Flame Shock", "Lava Burst", "Chain Lightning", "Lightning Bolt"]),
profile("shaman:enhancement", "shaman", ["Windfury Weapon", "Rockbiter Weapon", "Lightning Shield", "Feral Spirit", "Stormstrike", "Lava Lash", "Earth Shock", "Lightning Bolt"]),
profile("shaman:restoration", "shaman", ["Water Shield", "Rockbiter Weapon", "Lightning Shield", "Riptide", "Chain Heal", "Lesser Healing Wave", "Healing Wave", "Earth Shield"]),
profile("mage:arcane", "mage", ["Mage Armor", "Ice Armor", "Frost Armor", "Arcane Barrage", "Arcane Blast", "Arcane Missiles"]),
profile("mage:fire", "mage", ["Molten Armor", "Mage Armor", "Ice Armor", "Frost Armor", "Living Bomb", "Fireball", "Scorch", "Fire Blast"]),
profile("mage:frost", "mage", ["Frost Armor", "Ice Armor", "Mage Armor", "Deep Freeze", "Frostbolt", "Ice Lance", "Cone of Cold"]),
profile("warlock:affliction", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felhunter", "Summon Voidwalker", "Summon Imp", "Haunt", "Unstable Affliction", "Corruption", "Curse of Agony", "Drain Soul", "Shadow Bolt"]),
profile("warlock:demonology", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felguard", "Summon Voidwalker", "Summon Imp", "Metamorphosis", "Immolate", "Corruption", "Shadow Bolt"]),
profile("warlock:destruction", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Imp", "Chaos Bolt", "Conflagrate", "Immolate", "Incinerate", "Shadow Bolt"]),
profile("druid:balance", "druid", ["Moonkin Form", "Starfall", "Moonfire", "Insect Swarm", "Starfire", "Wrath"]),
profile("druid:feral-bear", "druid", ["Dire Bear Form", "Bear Form", "Mangle (Bear)", "Lacerate", "Swipe (Bear)", "Maul"]),
profile("druid:feral-cat", "druid", ["Cat Form", "Rip", "Savage Roar", "Mangle (Cat)", "Rake", "Shred", "Claw"]),
profile("druid:restoration", "druid", ["Tree of Life", "Wild Growth", "Swiftmend", "Lifebloom", "Rejuvenation", "Regrowth", "Healing Touch"]),
]);
const PROFILE_BY_SPECIALIZATION = new Map(
CLASSIC_ROTATION_PROFILES.map((entry) => [entry.specializationId, entry]),
);
const CONDITIONAL_ABILITY_NAMES = new Set([
"Overpower",
"Revenge",
"Victory Rush",
]);
const TARGET_HEALTH_GATES = new Map<string, number>([
["Drain Soul", 0.25],
["Hammer of Wrath", 0.2],
["Kill Shot", 0.2],
]);
const BEAR_FORM_ACTION_NAMES = new Set([
"Bash",
"Challenging Roar",
"Demoralizing Roar",
"Frenzied Regeneration",
"Growl",
"Lacerate",
"Mangle (Bear)",
"Maul",
"Swipe (Bear)",
]);
const CAT_FORM_ACTION_NAMES = new Set([
"Claw",
"Cower",
"Ferocious Bite",
"Mangle (Cat)",
"Pounce",
"Rake",
"Ravage",
"Rip",
"Savage Roar",
"Shred",
"Swipe (Cat)",
"Tiger's Fury",
]);
function slug(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
export function rotationProfileFor(
classId: ClassId,
specializationId?: string | null,
specialization?: string | null,
): RotationProfile | null {
const explicit = specializationId ? PROFILE_BY_SPECIALIZATION.get(specializationId) : null;
if (explicit?.classId === classId) return explicit;
const inferred = specialization ? PROFILE_BY_SPECIALIZATION.get(`${classId}:${slug(specialization)}`) : null;
return inferred?.classId === classId ? inferred : null;
}
function throughput(ability: AbilityDefinition, comboPoints: number): number {
return ability.effects.reduce((total, effect) => {
if (["damage", "heal", "shield", "dot", "hot", "execute"].includes(effect.kind) && "coefficient" in effect) {
const basePoints = "basePoints" in effect ? effect.basePoints ?? 0 : 0;
return total + Math.max(0, effect.coefficient) * 10 + Math.max(0, basePoints) * 0.01;
}
if (effect.kind === "finisher-damage") {
return total + (effect.baseCoefficient + effect.perComboCoefficient * comboPoints) * 10;
}
return total;
}, 0);
}
function isComboFinisher(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => effect.kind === "finisher-damage"
|| (effect.kind === "combo" && effect.amount < 0));
}
function isComboBuilder(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => effect.kind === "combo" && effect.amount > 0);
}
function isPetSummon(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => effect.kind === "pet"
&& ["summon", "call", "resurrect", "tame"].includes(effect.action));
}
export const PRECOMBAT_MINIMUM_BUFF_DURATION_MS = 60_000;
function isPersistentPetPreparation(
ability: AbilityDefinition,
effect: Extract<AbilityDefinition["effects"][number], { kind: "pet" }>,
): boolean {
if (["call", "resurrect", "tame"].includes(effect.action)) return true;
return effect.action === "summon" && ability.cooldownMs < PRECOMBAT_MINIMUM_BUFF_DURATION_MS;
}
/** Long-lived self maintenance and permanent pets that should exist before the first pull. */
export function precombatPreparationEffects(ability: AbilityDefinition): readonly AbilityDefinition["effects"][number][] {
return ability.effects.filter((effect) => (
effect.kind === "pet"
? isPersistentPetPreparation(ability, effect)
: effect.kind === "apply-aura"
&& effect.aura.disposition === "buff"
&& (effect.aura.durationMs === null || effect.aura.durationMs >= PRECOMBAT_MINIMUM_BUFF_DURATION_MS)
));
}
export function abilityIsPrecombatPreparation(ability: AbilityDefinition): boolean {
return !ability.passive && precombatPreparationEffects(ability).length > 0;
}
function isOffensiveSummon(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => effect.kind === "summon" && effect.coefficient >= 0.15);
}
function isCombatMaintenanceAura(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => effect.kind === "apply-aura"
&& effect.aura.disposition === "buff"
&& (effect.aura.durationMs === null || effect.aura.durationMs >= 15_000)
&& Boolean(
effect.aura.modifiers?.length
|| effect.aura.absorbs?.length
|| effect.aura.procs?.length
|| effect.aura.controlImmunities?.length
|| effect.aura.reflectSpellChance
|| effect.aura.redirectDamagePercent
|| effect.aura.utilityTags?.includes("weapon-enchant"),
));
}
export function abilityHasRotationSemantics(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => {
if (effect.kind === "unsupported" || effect.kind === "scripted" || effect.kind === "utility") return false;
if (effect.kind !== "apply-aura") return true;
return Boolean(
effect.control
|| effect.aura.modifiers?.length
|| effect.aura.absorbs?.length
|| effect.aura.procs?.length
|| effect.aura.utilityTags?.length
|| effect.aura.controlImmunities?.length
|| effect.aura.reflectSpellChance
|| effect.aura.redirectDamagePercent,
);
});
}
function targetForAbility(
ability: AbilityDefinition,
context: RotationContext,
lowest: RotationFriendlyTarget | null,
): string | null {
const categories = new Set(aiHintsForAbility(ability).categories);
const casterOnly = ability.target === "self" || (ability.effects.some((effect) => (
"recipient" in effect && effect.recipient === "caster"
)) && !ability.effects.some((effect) => (
"recipient" in effect && effect.recipient === "ability-target"
)));
const hostileAction = (["damage", "execute", "debuff", "control", "interrupt", "taunt"] as const)
.some((category) => categories.has(category));
if (casterOnly && !hostileAction) return context.selfId ?? null;
if (ability.target === "friendly") return lowest?.id ?? null;
if (ability.target === "any" && lowest && aiHintsForAbility(ability).categories.some((category) => (
category === "heal" || category === "dispel" || category === "resurrection" || category === "mitigation"
))) return lowest.id;
// Self-centered hostile area skills (for example Thunder Clap) still need an
// enemy anchor so execution can enumerate targets around the caster.
if (ability.target === "self" && (ability.radius ?? 0) > 0
&& (["damage", "execute", "debuff", "control", "interrupt", "taunt"] as const)
.some((category) => categories.has(category))) {
return context.hostileTarget?.id ?? null;
}
if (ability.target === "hostile" || ability.target === "any") return context.hostileTarget?.id ?? null;
return null;
}
interface ScoredRotationAbility {
readonly ability: AbilityDefinition;
readonly score: number;
readonly ruleId: string;
readonly reason: string;
readonly targetId: string | null;
}
function scoreAbility(
ability: AbilityDefinition,
context: RotationContext,
rotationProfile: RotationProfile | null,
lowest: RotationFriendlyTarget | null,
): ScoredRotationAbility | null {
const hints = aiHintsForAbility(ability);
const categories = new Set(hints.categories);
const targetHealth = context.hostileTarget
? context.hostileTarget.health / Math.max(1, context.hostileTarget.maxHealth)
: 1;
const selfHealth = context.health / Math.max(1, context.maxHealth);
const lowestHealth = lowest ? lowest.health / Math.max(1, lowest.maxHealth) : 1;
const injured = (context.friendlyTargets ?? []).filter((target) => (
!target.fallen && target.health > 0 && target.health < target.maxHealth * 0.9
));
const resource = context.resources[context.primaryResource] ?? 0;
const maximumResource = context.maximumResources[context.primaryResource] ?? 0;
const resourceFraction = maximumResource > 0 ? resource / maximumResource : 1;
const maintained = (context.maintainedAbilityIds?.has(ability.id) ?? false)
|| ability.effects.some((effect) => effect.kind === "apply-aura"
&& Boolean(effect.aura.exclusiveGroup
&& context.activeAuraExclusiveGroups?.has(effect.aura.exclusiveGroup)));
const activeSummon = context.activeSummonAbilityIds?.has(ability.id) ?? false;
const petSummon = isPetSummon(ability);
const offensiveSummon = isOffensiveSummon(ability);
const maintenanceBuff = isCombatMaintenanceAura(ability);
const channelAmounts = ability.castMode === "channel" ? channelAmountEffects(ability, 1) : [];
const damaging = categories.has("damage") || categories.has("execute")
|| channelAmounts.some(({ effect }) => effect.kind === "damage" || effect.kind === "dot");
const healingCategory = categories.has("heal") || categories.has("emergency-heal") || categories.has("area-heal");
const defensive = categories.has("mitigation") || categories.has("heal");
const offensiveMajorCooldown = ability.cooldownMs >= 60_000 && !defensive
&& (damaging || categories.has("buff") || categories.has("summon") || categories.has("pet"));
const durableTarget = Boolean(
context.hostileTarget?.boss
|| (context.hostileTarget?.healthMultiplier ?? 1) > 1
|| (context.hostileTarget?.maxHealth ?? 0) >= context.maxHealth * 5,
);
const profileIndex = rotationProfile?.abilityPriority.findIndex((name) => name === ability.name) ?? -1;
const knownDamagingAbilityNames = context.knownDamagingAbilityNames
?? new Set(context.abilities.filter((candidate) => {
const candidateCategories = aiHintsForAbility(candidate).categories;
return candidateCategories.includes("damage") || candidateCategories.includes("execute");
}).map((candidate) => candidate.name));
const hasKnownProfileDamageAction = Boolean(rotationProfile?.abilityPriority.some((name) => (
knownDamagingAbilityNames.has(name)
)));
const targetHealthGate = TARGET_HEALTH_GATES.get(ability.name);
if (targetHealthGate !== undefined && targetHealth > targetHealthGate) return null;
if (CONDITIONAL_ABILITY_NAMES.has(ability.name)
&& !context.availableConditionalAbilityNames?.has(ability.name)) return null;
if (context.classId === "druid") {
const specializationId = rotationProfile?.specializationId;
if (specializationId === "druid:feral-bear" && CAT_FORM_ACTION_NAMES.has(ability.name)) return null;
if (specializationId === "druid:feral-cat" && BEAR_FORM_ACTION_NAMES.has(ability.name)) return null;
if (specializationId && !specializationId.startsWith("druid:feral-")
&& (BEAR_FORM_ACTION_NAMES.has(ability.name) || CAT_FORM_ACTION_NAMES.has(ability.name))) return null;
}
if (categories.has("resurrection")) {
const fallen = (context.friendlyTargets ?? []).find((target) => target.fallen);
return fallen ? { ability, score: 1_020_000, ruleId: "reactive-resurrection", reason: "fallen ally", targetId: fallen.id } : null;
}
if (categories.has("dispel")) {
const dispel = (context.friendlyTargets ?? []).find((target) => target.needsDispel);
if (dispel) return { ability, score: 1_019_000, ruleId: "reactive-dispel", reason: "ally needs dispel", targetId: dispel.id };
if (!damaging) return null;
}
if (categories.has("interrupt")) {
if (!context.hostileTarget?.activeCast?.interruptible) {
if (!damaging) return null;
} else {
return { ability, score: 1_018_000, ruleId: "reactive-interrupt", reason: "interruptible enemy cast", targetId: context.hostileTarget.id };
}
}
if (categories.has("taunt")) {
if (context.role !== "tank" || !context.targetIsLoose) return null;
return { ability, score: 1_017_000, ruleId: "reactive-taunt", reason: "loose hostile target", targetId: context.hostileTarget?.id ?? null };
}
if (categories.has("threat") && !damaging && !maintenanceBuff) {
if (context.role === "tank" || !context.hasDangerousThreat) return null;
return {
ability,
score: 1_015_000,
ruleId: "reactive-threat-drop",
reason: "non-tank has enemy attention",
targetId: ability.target === "self" ? null : context.hostileTarget?.id ?? null,
};
}
if (categories.has("control") && !context.requiredControl && !damaging) return null;
// A friendly heal without an injured recipient must never fall through to
// the hostile target. Hybrid damage builds otherwise refresh HoTs on enemies
// forever because those auras also look like maintenance buffs.
const actionableHealing = healingCategory
&& !maintenanceBuff
&& !petSummon
&& !offensiveSummon;
if (maintenanceBuff && healingCategory
&& (ability.target === "friendly" || ability.target === "any") && !lowest) return null;
if (actionableHealing && !damaging) {
const healingTargetHealth = ability.target === "self" ? selfHealth : lowestHealth;
if ((ability.target === "friendly" || ability.target === "any") && !lowest) return null;
if (healingTargetHealth >= 0.9) return null;
}
// Pet stance and recall buttons require an explicit positioning/order intent;
// treating them as maintenance actions otherwise starves real attacks.
if (categories.has("pet") && !petSummon && !categories.has("summon") && !damaging
&& !categories.has("buff") && !categories.has("heal")) return null;
if (petSummon && context.hasActivePet) return null;
// Scouting objects, portals, ritual objects, and other zero-pressure DBC
// summons are valid player buttons but are not autonomous combat actions.
if (categories.has("summon") && !damaging && !petSummon && !offensiveSummon && !maintenanceBuff) return null;
if (categories.has("buff") && !damaging && !defensive && !maintenanceBuff
&& !categories.has("resource") && !petSummon && !offensiveSummon) return null;
if (categories.has("movement") && !damaging && !context.moving) return null;
if (context.requiredControl && categories.has("control")) {
return { ability, score: 1_016_000, ruleId: "reactive-control", reason: "encounter requested control", targetId: context.hostileTarget?.id ?? null };
}
if (offensiveMajorCooldown && !durableTarget) return null;
if (maintained && (categories.has("debuff") || categories.has("buff"))) return null;
if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null;
if (rotationProfile && profileIndex < 0 && !damaging && (maintenanceBuff || petSummon || offensiveSummon)) return null;
// A specialization profile is also an allow-list for utility GCDs once the
// build has a real damage action. Reactive interrupts, taunts, threat drops,
// and encounter-requested control already returned above; letting any other
// imported debuff/control action through here makes characters spam buttons
// such as Expose Armor and Demoralizing Shout instead of their rotation.
if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction
&& context.role !== "healer" && profileIndex < 0
&& (categories.has("debuff") || categories.has("control") || categories.has("interrupt"))) return null;
if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction
&& context.role !== "healer" && profileIndex < 0 && damaging) return null;
if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) return null;
if (isComboBuilder(ability) && context.comboPoints >= 5 && context.comboTargetId === context.hostileTarget?.id) return null;
let score = hints.priority ?? 0;
let ruleId = "semantic-fallback";
let reason = "shared semantic priority";
const maintenanceScore = rotationProfile === null ? 5_200 : profileIndex >= 0 ? 7_000 : 2_000;
const summonScore = rotationProfile === null ? 4_800 : profileIndex >= 0 ? 6_500 : 1_800;
if (profileIndex >= 0) {
score += context.role === "healer" || context.level < 10
? 4_000 - profileIndex * 120
: 100_000 - profileIndex * 5_000;
ruleId = `profile-${slug(ability.name)}`;
reason = `${rotationProfile!.specializationId} priority ${profileIndex + 1}`;
}
if (context.role === "healer") {
const healing = actionableHealing
&& (healingAbilityCanAidOthers(ability) || lowest?.id === context.selfId);
if (!healing && !categories.has("mitigation") && !damaging && !categories.has("resource")
&& !maintenanceBuff && !petSummon && !offensiveSummon) return null;
if (healing) {
if (!lowest || lowestHealth >= 0.9) return null;
score += lowestHealth < 0.3 ? 7_000 : lowestHealth < 0.6 ? 5_000 : 3_000;
if (categories.has("emergency-heal")) score += lowestHealth < 0.3 ? 1_500 : -1_000;
if (categories.has("area-heal")) score += injured.length >= 3 ? 6_500 : -1_200;
score += Math.min(500, throughput(ability, context.comboPoints) * 10);
} else if (categories.has("mitigation")) {
if (lowestHealth >= 0.8 && selfHealth >= 0.8) return null;
score += 2_500;
} else if (maintenanceBuff) {
score += context.maintainedAbilityIds === undefined ? 1_600 : maintenanceScore;
ruleId = "maintenance-buff";
reason = "missing maintenance buff";
} else if (petSummon || offensiveSummon) {
score += context.activeSummonAbilityIds === undefined ? 1_400 : summonScore;
ruleId = "maintenance-summon";
reason = "missing pet or summon";
} else if (damaging) {
score += injured.length ? -3_000 : 300;
}
} else {
if (categories.has("mitigation")) {
if (!maintenanceBuff && selfHealth >= 0.55) return null;
if (selfHealth < 0.55) score += selfHealth < 0.35 ? 180_000 : 140_000;
}
if (categories.has("execute")) {
if (targetHealth > 0.2) return null;
score += 6_000;
ruleId = "execute";
reason = "target below twenty percent";
}
if (categories.has("debuff")) score += 2_000;
if (categories.has("buff")) {
score += maintenanceBuff && context.maintainedAbilityIds !== undefined
? maintenanceScore
: 1_600;
if (!damaging && !defensive && maintenanceBuff) {
ruleId = "maintenance-buff";
reason = "missing maintenance buff";
}
}
if (petSummon || offensiveSummon) {
score += damaging || context.activeSummonAbilityIds === undefined
? 1_400
: summonScore;
if (!damaging) {
ruleId = "maintenance-summon";
reason = "missing pet or summon";
}
}
if (categories.has("resource")) score += resourceFraction < 0.3 ? 2_600 : -1_000;
if (damaging) score += 1_000 + throughput(ability, context.comboPoints) * 10;
if (isComboFinisher(ability)) score += 2_400;
if (isComboBuilder(ability)) score += context.comboPoints < 4 ? 1_400 : 0;
if ((context.hostileCount ?? 1) >= 3) score += categories.has("area-damage") ? 2_000 : -300;
else if ((context.hostileCount ?? 1) === 2) score += categories.has("area-damage") ? 900 : 0;
else if (categories.has("area-damage")) score -= 300;
}
if (score <= 0) return null;
return { ability, score, ruleId, reason, targetId: targetForAbility(ability, context, lowest) };
}
export function evaluateCombatRotation(context: RotationContext): RotationDecision | null {
const rotationProfile = rotationProfileFor(
context.classId,
context.specializationId,
context.specialization,
);
const lowest = [...(context.friendlyTargets ?? [])]
.filter((target) => target.health > 0 && !target.fallen)
.sort((left, right) => (
left.health / Math.max(1, left.maxHealth) - right.health / Math.max(1, right.maxHealth)
|| left.id.localeCompare(right.id)
))[0] ?? null;
const decision = context.abilities
.filter((ability) => !ability.passive)
.filter(abilityHasRotationSemantics)
.filter((ability) => !context.moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability))
.filter((ability) => {
if (context.classId !== "death-knight") return true;
const runeCost = runeCostForAbility(ability);
return !runeCost || spendDeathKnightRunes(context.runes, runeCost, context.now) !== null;
})
.filter((ability) => context.canUseAbility?.(ability) ?? true)
.map((ability) => scoreAbility(ability, context, rotationProfile, lowest))
.filter((candidate): candidate is ScoredRotationAbility => candidate !== null)
.sort((left, right) => right.score - left.score
|| left.ability.cooldownMs - right.ability.cooldownMs
|| left.ability.id.localeCompare(right.ability.id))[0];
return decision ? {
ability: decision.ability,
targetId: decision.targetId,
profileId: rotationProfile?.id ?? `semantic:${context.classId}`,
ruleId: decision.ruleId,
reason: decision.reason,
} : null;
}
+16
View File
@@ -24,6 +24,7 @@ import { defaultActionBindings } from "./actionBindings";
import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats"; import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds"; import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
function allocateTalentRanks(name: string, ranks: number): void { function allocateTalentRanks(name: string, ranks: number): void {
const talent = TALENT_NODES.find((node) => node.name === name); const talent = TALENT_NODES.find((node) => node.name === name);
@@ -289,6 +290,8 @@ describe("combat store", () => {
equipment: {}, equipment: {},
}); });
expect(useCombatStore.getState()).toMatchObject({ expect(useCombatStore.getState()).toMatchObject({
specializationId: "priest:holy",
gearRole: "healer",
resource: 3_285, resource: 3_285,
maxResource: 3_285, maxResource: 3_285,
}); });
@@ -414,6 +417,19 @@ describe("combat store", () => {
expect(useCombatStore.getState().actionBindings["primary:face-bottom"]).toBe("mage-frostbolt"); expect(useCombatStore.getState().actionBindings["primary:face-bottom"]).toBe("mage-frostbolt");
}); });
it("shows learned classic talent actions and clears their bindings after a talent reset", () => {
const talentRanks = runtimePartyTalentRanksForLevel("druid", "Balance", 80);
const talentAction = abilitiesForClass("druid").find((ability) => ability.talentNodeId
&& talentRanks[ability.talentNodeId] && !ability.passive)!;
expect(talentAction).toBeDefined();
useCombatStore.getState().initializeCharacter({ classId: "druid", specializationId: "druid:balance", level: 80, talentRanks });
expect(useCombatStore.getState().setActionBinding("secondary", "r2", talentAction.id)).toBe(true);
expect(useCombatStore.getState().actionBindings["secondary:r2"]).toBe(talentAction.id);
useCombatStore.getState().resetTalents();
expect(useCombatStore.getState().actionBindings["secondary:r2"]).toBeNull();
expect(useCombatStore.getState().setActionBinding("secondary", "r2", talentAction.id)).toBe(false);
});
it("grants the level-ten starter kit while retaining later ability gates", () => { it("grants the level-ten starter kit while retaining later ability gates", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1, xp: 0 }); useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1, xp: 0 });
expect(useCombatStore.getState().castAbility("mage-frost-nova", [0, 0, 0], 1_000).ok).toBe(true); expect(useCombatStore.getState().castAbility("mage-frost-nova", [0, 0, 0], 1_000).ok).toBe(true);
+190 -40
View File
@@ -15,9 +15,12 @@ import {
abilityAtLevel, abilityAtLevel,
abilityById, abilityById,
abilityRankForLevel, abilityRankForLevel,
abilityTalentNodeId,
defaultActionBarForClass, defaultActionBarForClass,
defaultActionBarForCharacter, defaultActionBarForCharacter,
isAbilityUnlocked, isAbilityUnlocked,
learnedAbilityForBuild,
learnedAbilityRank,
resourceProfileForClass, resourceProfileForClass,
triggeredAbilityBySpellId, triggeredAbilityBySpellId,
type AbilityAmountScaling, type AbilityAmountScaling,
@@ -218,6 +221,11 @@ import {
onlinePlayerRuntimeActor, onlinePlayerRuntimeActor,
onlinePlayerRuntimeActors, onlinePlayerRuntimeActors,
} from "./onlineSessionRuntime"; } from "./onlineSessionRuntime";
import {
abilityIsPrecombatPreparation,
precombatPreparationEffects,
rotationProfileFor,
} from "./combatRotation";
import { import {
emitCombatPresentation, emitCombatPresentation,
presentationDeliveryForAbility, presentationDeliveryForAbility,
@@ -709,6 +717,8 @@ export interface CombatState {
synchronizeActors: () => void; synchronizeActors: () => void;
/** Grants learned long-term class stat buffs to the full party without setup casts. */ /** Grants learned long-term class stat buffs to the full party without setup casts. */
applyDefaultPartyBuffs: (now?: number) => void; applyDefaultPartyBuffs: (now?: number) => void;
/** Applies specialization maintenance buffs and permanent pets before the first pull. */
applyPrecombatPreparation: (now?: number) => void;
/** Commits companion simulation results to authoritative actors and mirrors party frames. */ /** Commits companion simulation results to authoritative actors and mirrors party frames. */
commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void; commitCompanionActors: (members: readonly import("./partyStore").PartyMember[]) => void;
castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult; castSlot: (index: number, origin?: CombatPosition, now?: number) => CastResult;
@@ -1559,9 +1569,8 @@ function automaticClassPassiveUnlockLevel(ability: AbilityDefinition): number {
function isAutomaticClassPassive(ability: AbilityDefinition): boolean { function isAutomaticClassPassive(ability: AbilityDefinition): boolean {
if (!ability.passive) return false; if (!ability.passive) return false;
if (ability.source !== "coa-tree") return true; if (ability.source !== "coa-tree") return true;
const node = ability.talentEntryId === undefined const nodeId = abilityTalentNodeId(ability);
? null const node = nodeId ? talentNodeById(nodeId) : null;
: talentNodeById(`coa-entry-${ability.talentEntryId}`);
return Boolean( return Boolean(
node?.isPassive node?.isPassive
&& (node.abilityEssenceCost ?? 0) === 0 && (node.abilityEssenceCost ?? 0) === 0
@@ -2161,12 +2170,9 @@ function syncPassiveAuras(
now: number, now: number,
): ActiveAura[] { ): ActiveAura[] {
const definitions = abilities.flatMap((catalogAbility) => { const definitions = abilities.flatMap((catalogAbility) => {
if (!catalogAbility.passive || !isAbilityUnlocked(catalogAbility, level)) return []; if (!catalogAbility.passive) return [];
const learnedRank = catalogAbility.talentEntryId === undefined const ability = learnedAbilityForBuild(catalogAbility, level, talentRanks);
? undefined if (!ability) return [];
: talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
if (catalogAbility.talentEntryId !== undefined && !learnedRank) return [];
const ability = abilityAtLevel(catalogAbility, level, learnedRank);
return ability.effects return ability.effects
.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura") .filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => effect.kind === "apply-aura")
.map((effect) => auraDefinitionForAbility(effect.aura, ability)); .map((effect) => auraDefinitionForAbility(effect.aura, ability));
@@ -3163,6 +3169,20 @@ function grantDamageResourceInWork(
abilityId, rawAmount: requested, effectiveAmount: restored, overheal: requested - restored, absorbed: 0, critical: false }); abilityId, rawAmount: requested, effectiveAmount: restored, overheal: requested - restored, absorbed: 0, critical: false });
} }
function withoutUnlearnedTalentBindings(
bindings: ActionBindings,
abilities: readonly AbilityDefinition[],
talentRanks: TalentRanks,
): ActionBindings {
const unlearned = new Set(abilities
.filter((ability) => abilityTalentNodeId(ability) && !(learnedAbilityRank(ability, talentRanks) ?? 0))
.map((ability) => ability.id));
return Object.fromEntries(Object.entries(bindings).map(([id, abilityId]) => [
id,
abilityId && unlearned.has(abilityId) ? null : abilityId,
])) as ActionBindings;
}
function timedEffect( function timedEffect(
kind: "dot" | "hot", kind: "dot" | "hot",
abilityId: string, abilityId: string,
@@ -3562,6 +3582,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
set((state) => ({ set((state) => ({
...initialState(), ...initialState(),
classId, classId,
specializationId,
gearRole,
secondaryClassId, secondaryClassId,
raceId, raceId,
level: progression.level, level: progression.level,
@@ -3579,7 +3601,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()), auras: syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()),
talentRanks, talentRanks,
talentModifiers, talentModifiers,
actionBindings: normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)), actionBindings: withoutUnlearnedTalentBindings(
normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
abilities,
talentRanks,
),
inventory, inventory,
bossCards: normalizeBossCardCollection(profile.bossCards), bossCards: normalizeBossCardCollection(profile.bossCards),
equipment, equipment,
@@ -4222,6 +4248,90 @@ export const useCombatStore = create<CombatState>((set, get) => ({
} }
get().synchronizeActors(); get().synchronizeActors();
}, },
applyPrecombatPreparation: (now = combatNow()) => {
get().synchronizeActors();
const state = get();
const partyMembers = usePartyStore.getState().members;
const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play";
const sources = [
...(includePlayer ? [{
id: PLAYER_AURA_ENTITY_ID,
classId: state.classId,
secondaryClassId: state.secondaryClassId,
specializationId: state.specializationId,
specialization: specializationById(state.specializationId)?.displayName ?? null,
role: state.gearRole,
level: state.level,
talentRanks: state.talentRanks,
abilities: state.abilities,
}] : []),
...partyMembers.filter((member) => member.health > 0).map((member) => ({
id: member.id,
classId: member.classId,
secondaryClassId: member.secondaryClassId ?? null,
specializationId: member.specializationId,
specialization: member.specialization,
role: member.role,
level: member.level,
talentRanks: member.talentRanks,
abilities: abilitiesForCharacter(member.classId, member.secondaryClassId),
})),
];
for (const source of sources) {
const rotation = rotationProfileFor(source.classId, source.specializationId, source.specialization);
const priority = (ability: AbilityDefinition) => {
const index = rotation?.abilityPriority.indexOf(ability.name) ?? -1;
return index < 0 ? Number.MAX_SAFE_INTEGER : index;
};
const abilities = source.abilities
.map((ability) => learnedAbilityForBuild(ability, source.level, source.talentRanks))
.filter((ability): ability is AbilityDefinition => ability !== null)
.filter(abilityIsPrecombatPreparation)
.filter((ability) => !rotation || priority(ability) < Number.MAX_SAFE_INTEGER)
.sort((left, right) => priority(left) - priority(right) || left.name.localeCompare(right.name));
for (const ability of abilities) {
const effects = precombatPreparationEffects(ability);
const auraEffects = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
effect.kind === "apply-aura"
));
const current = get();
const maintained = current.auras.some((aura) => (
aura.targetId === source.id
&& (aura.expiresAt === null || aura.expiresAt > now)
&& auraEffects.some((effect) => effect.aura.id === aura.definition.id)
));
const blockedByExclusiveGroup = current.auras.some((aura) => (
aura.targetId === source.id
&& (aura.expiresAt === null || aura.expiresAt > now)
&& Boolean(aura.definition.exclusiveGroup)
&& auraEffects.some((effect) => effect.aura.exclusiveGroup === aura.definition.exclusiveGroup)
));
if (!maintained && !blockedByExclusiveGroup) {
for (const effect of auraEffects) get().applyActorAura(source.id, source.id, ability, effect, now);
}
const petEffects = effects.filter((effect): effect is Extract<AbilityEffect, { kind: "pet" }> => (
effect.kind === "pet"
));
if (!petEffects.length) continue;
const hasActivePet = get().summons.some((summon) => {
if (summon.ownerActorId !== source.id || summon.expiresAt <= now) return false;
const summonAbility = abilityById(summon.sourceAbilityId);
return summonAbility?.effects.some((effect) => effect.kind === "pet"
&& ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false;
});
if (hasActivePet) continue;
const position = actorPosition(get(), source.id) ?? get().playerPosition;
const petEffect = petEffects[0];
get().summonActor(source.id, ability, {
kind: "summon",
creatureId: petEffect.creatureId ?? 1,
coefficient: 0.35,
durationMs: Number.MAX_SAFE_INTEGER - now,
}, position, null, now);
}
}
get().synchronizeActors();
},
commitCompanionActors: (members) => { commitCompanionActors: (members) => {
let actors: Readonly<Record<string, CombatActorState>> = {}; let actors: Readonly<Record<string, CombatActorState>> = {};
set((state) => { set((state) => {
@@ -4254,9 +4364,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const state = get(); const state = get();
const party = usePartyStore.getState(); const party = usePartyStore.getState();
const catalogAbility = abilityById(abilityId); const catalogAbility = abilityById(abilityId);
const learnedTalentRank = catalogAbility?.talentEntryId === undefined const talentNodeId = catalogAbility ? abilityTalentNodeId(catalogAbility) : null;
? undefined const learnedTalentRank = catalogAbility ? learnedAbilityRank(catalogAbility, state.talentRanks) : undefined;
: state.talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
const ability = catalogAbility const ability = catalogAbility
? abilityAtLevel(catalogAbility, state.level, learnedTalentRank) ? abilityAtLevel(catalogAbility, state.level, learnedTalentRank)
: null; : null;
@@ -4287,7 +4396,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
let failure: CastFailureReason | undefined; let failure: CastFailureReason | undefined;
if (!ability) failure = "unknown-ability"; if (!ability) failure = "unknown-ability";
else if (!state.abilities.some((entry) => entry.id === ability.id)) failure = "wrong-class"; else if (!state.abilities.some((entry) => entry.id === ability.id)) failure = "wrong-class";
else if (ability.talentEntryId !== undefined && !learnedTalentRank) failure = "not-learned"; else if (talentNodeId && !learnedTalentRank) failure = "not-learned";
else if (ability.passive) failure = "passive"; else if (ability.passive) failure = "passive";
else if (ability.effects.every((effect) => effect.kind === "scripted" || effect.kind === "unsupported")) failure = "unsupported"; else if (ability.effects.every((effect) => effect.kind === "scripted" || effect.kind === "unsupported")) failure = "unsupported";
else if (!isAbilityUnlocked(ability, state.level)) failure = "level-locked"; else if (!isAbilityUnlocked(ability, state.level)) failure = "level-locked";
@@ -5177,12 +5286,29 @@ export const useCombatStore = create<CombatState>((set, get) => ({
for (const recipientId of recipients) { for (const recipientId of recipients) {
const previousAuras = work.auras; const previousAuras = work.auras;
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue; if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
work.auras = [...applyCombatAura(previousAuras, { const auraResult = applyCombatAura(previousAuras, {
definition: auraDefinition, definition: auraDefinition,
sourceId: PLAYER_AURA_ENTITY_ID, sourceId: PLAYER_AURA_ENTITY_ID,
targetId: recipientId, targetId: recipientId,
now, now,
}).auras]; });
work.auras = [...auraResult.auras];
const grantedAbsorb = auraResult.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
if (grantedAbsorb > 0) {
recordCombatEventInWork(work, {
occurredAt: now,
kind: "absorb",
sourceActorId: PLAYER_AURA_ENTITY_ID,
targetActorId: recipientId,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: grantedAbsorb,
effectiveAmount: grantedAbsorb,
overheal: 0,
absorbed: grantedAbsorb,
critical: false,
});
}
applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras); applyAuraHealthTransitionInWork(work, recipientId, previousAuras, work.auras);
if (auraDefinition.controlImmunities?.length) { if (auraDefinition.controlImmunities?.length) {
if ( if (
@@ -6169,6 +6295,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetId: targetActorId, targetId: targetActorId,
now, now,
}); });
const grantedAbsorb = result.aura.absorbs.reduce((total, absorb) => total + absorb.remaining, 0);
let health = state.health; let health = state.health;
let maxHealth = state.maxHealth; let maxHealth = state.maxHealth;
const actors = { ...state.actors }; const actors = { ...state.actors };
@@ -6207,24 +6334,40 @@ export const useCombatStore = create<CombatState>((set, get) => ({
} }
} }
} }
let combatEvents = recordCombatEvent(state.combatEvents, {
occurredAt: now,
kind: "aura",
sourceActorId,
targetActorId,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: 0,
effectiveAmount: 1,
overheal: 0,
absorbed: 0,
critical: false,
});
if (grantedAbsorb > 0) {
combatEvents = recordCombatEvent(combatEvents, {
occurredAt: now,
kind: "absorb",
sourceActorId,
targetActorId,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: grantedAbsorb,
effectiveAmount: grantedAbsorb,
overheal: 0,
absorbed: grantedAbsorb,
critical: false,
});
}
set({ set({
auras: result.auras, auras: result.auras,
health, health,
maxHealth, maxHealth,
actors, actors,
combatEvents: recordCombatEvent(state.combatEvents, { combatEvents,
occurredAt: now,
kind: "aura",
sourceActorId,
targetActorId,
abilityId: ability.id,
school: presentationSchoolForAbility(ability),
rawAmount: 0,
effectiveAmount: 1,
overheal: 0,
absorbed: 0,
critical: false,
}),
}); });
if (companionActor) mirrorCompanionActor(companionActor); if (companionActor) mirrorCompanionActor(companionActor);
return true; return true;
@@ -8221,13 +8364,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
if ( if (
summon.health <= 0 summon.health <= 0
|| summon.controlledUntil > now || summon.controlledUntil > now
|| summon.nextActionAt > now
|| summon.globalCooldownEndsAt > now
|| summon.cast || summon.cast
|| summon.attackDamage <= 0 || summon.attackDamage <= 0
) return summon; ) return summon;
const readyToAct = summon.nextActionAt <= now && summon.globalCooldownEndsAt <= now;
const summonAbility = abilityById(summon.sourceAbilityId); const summonAbility = abilityById(summon.sourceAbilityId);
if (summon.kind === "totem" && summonAbility?.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot")) { if (summon.kind === "totem" && summonAbility?.effects.some((effect) => effect.kind === "heal" || effect.kind === "hot")) {
if (!readyToAct) return summon;
const partyMembers = usePartyStore.getState().members; const partyMembers = usePartyStore.getState().members;
const injured = [ const injured = [
{ id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: state.playerPosition }, { id: PLAYER_AURA_ENTITY_ID, health: work.health, maxHealth: work.maxHealth, position: state.playerPosition },
@@ -8265,12 +8408,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
} }
return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) }; return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) };
} }
const targetId = summon.targetId && !work.mobs[summon.targetId]?.dead const targetId = summon.targetId && work.mobs[summon.targetId] && !work.mobs[summon.targetId].dead
? summon.targetId ? summon.targetId
: state.selectedTargetId && !work.mobs[state.selectedTargetId]?.dead : state.selectedTargetId && work.mobs[state.selectedTargetId] && !work.mobs[state.selectedTargetId].dead
? state.selectedTargetId ? state.selectedTargetId
: Object.values(work.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null; : Object.values(work.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
if (!targetId) return { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) }; if (!targetId) return readyToAct
? { ...summon, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) }
: summon;
const targetPosition = getMobPosition(targetId); const targetPosition = getMobPosition(targetId);
let position = summon.position; let position = summon.position;
if (targetPosition) { if (targetPosition) {
@@ -8283,7 +8428,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
position = projectCombatGroundPoint(requested, step, position) ?? position; position = projectCombatGroundPoint(requested, step, position) ?? position;
} }
if ( if (
Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5) readyToAct
&& Math.sqrt(distanceSquared(position, targetPosition)) <= (summon.kind === "totem" ? 30 : 5)
&& combatHasLineOfSight(position, targetPosition) && combatHasLineOfSight(position, targetPosition)
) { ) {
damageMobInWork( damageMobInWork(
@@ -8297,7 +8443,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
); );
} }
} }
return { ...summon, targetId, position, nextActionAt: now + Math.max(500, summon.pulseIntervalMs) }; return {
...summon,
targetId,
position,
nextActionAt: readyToAct ? now + Math.max(500, summon.pulseIntervalMs) : summon.nextActionAt,
};
}); });
for (const [id, mob] of Object.entries(work.mobs)) { for (const [id, mob] of Object.entries(work.mobs)) {
@@ -8412,11 +8563,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const cast = get().activeCast; const cast = get().activeCast;
if (cast?.mode === "channel") { if (cast?.mode === "channel") {
const catalogAbility = abilityById(cast.abilityId); const catalogAbility = abilityById(cast.abilityId);
const learnedTalentRank = catalogAbility?.talentEntryId === undefined
? undefined
: get().talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0;
const ability = catalogAbility const ability = catalogAbility
? abilityAtLevel(catalogAbility, get().level, learnedTalentRank) ? learnedAbilityForBuild(catalogAbility, get().level, get().talentRanks)
: null; : null;
let ticksCompleted = cast.ticksCompleted; let ticksCompleted = cast.ticksCompleted;
let nextTickAt = cast.nextTickAt; let nextTickAt = cast.nextTickAt;
@@ -8775,6 +8923,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
maxResource, maxResource,
resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource), resource: clamp(state.maxResource > 0 ? state.resource / state.maxResource * maxResource : 0, 0, maxResource),
auras, auras,
actionBindings: withoutUnlearnedTalentBindings(state.actionBindings, state.abilities, talentRanks),
feedback: nextFeedback("talent", "Talent points reset."), feedback: nextFeedback("talent", "Talent points reset."),
}; };
}), }),
@@ -8783,6 +8932,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const state = get(); const state = get();
if (abilityId !== null && !state.abilities.some((ability) => ( if (abilityId !== null && !state.abilities.some((ability) => (
ability.id === abilityId && !ability.passive ability.id === abilityId && !ability.passive
&& (!abilityTalentNodeId(ability) || Boolean(learnedAbilityRank(ability, state.talentRanks)))
))) return false; ))) return false;
set({ actionBindings: withActionBinding(state.actionBindings, layer, control, abilityId) }); set({ actionBindings: withActionBinding(state.actionBindings, layer, control, abilityId) });
return true; return true;
+21 -3
View File
@@ -10,6 +10,7 @@ import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRun
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime"; import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
import { subscribeCombatEvents } from "./combatEvents"; import { subscribeCombatEvents } from "./combatEvents";
import { bossDifficultyMultipliers } from "./dungeonDifficulty"; import { bossDifficultyMultipliers } from "./dungeonDifficulty";
import { dungeonEnemyHealthBaseline } from "./dungeonLevelScaling";
import { abilitiesForClass } from "./abilityCatalog"; import { abilitiesForClass } from "./abilityCatalog";
import type { ContentCategoryId } from "../app/contentCategories"; import type { ContentCategoryId } from "../app/contentCategories";
@@ -80,11 +81,28 @@ describe("dungeon boss durability and difficulty", () => {
const stockadesBoss = stockades.bosses[0]; const stockadesBoss = stockades.bosses[0];
const stockadesEntity = stockades.entities[stockades.staticSpawns.find((entry) => entry.id === stockadesBoss.id)!.entityId]; const stockadesEntity = stockades.entities[stockades.staticSpawns.find((entry) => entry.id === stockadesBoss.id)!.entityId];
it("uses the party health curve for trash regardless of dungeon catalog", () => {
const abbey = requireDungeonDefinition("forsaken-abbey");
const trashFrom = (definition: typeof stockades) => {
const spawn = definition.staticSpawns.find((entry) => definition.entities[entry.entityId].kind === "mob")!;
return { entity: definition.entities[spawn.entityId], instanceId: spawn.id };
};
const stockadesTrash = trashFrom(stockades);
const abbeyTrash = trashFrom(abbey);
for (const source of ["wow", "rom", "coa"] as const) {
const expected = dungeonEnemyHealthBaseline(5, source);
expect(dungeonEnemyScaling(stockades, stockadesTrash.entity, stockadesTrash.instanceId,
5, "normal", 1, source)).toMatchObject({ level: 5, maxHealth: expected });
expect(dungeonEnemyScaling(abbey, abbeyTrash.entity, abbeyTrash.instanceId,
5, "normal", 1, source)).toMatchObject({ level: 5, maxHealth: expected });
}
});
it("gives each level-three Stockades party its source-calibrated health minimum", () => { it("gives each level-three Stockades party its source-calibrated health minimum", () => {
const easy = dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "wow"); const easy = dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "wow");
expect(easy).toMatchObject({ level: 4, maxHealth: 1300 }); expect(easy).toMatchObject({ level: 4, maxHealth: 1500 });
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 10, "wow").maxHealth) expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 10, "wow").maxHealth)
.toBe(1300 * bossDifficultyMultipliers(10).health); .toBe(1500 * bossDifficultyMultipliers(10).health);
// The dungeon catalog must not choose the companion power curve. // The dungeon catalog must not choose the companion power curve.
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "rom").maxHealth).toBeCloseTo(7074.8); expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "rom").maxHealth).toBeCloseTo(7074.8);
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "coa").maxHealth).toBe(10766); expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "coa").maxHealth).toBe(10766);
@@ -99,7 +117,7 @@ describe("dungeon boss durability and difficulty", () => {
expect(ordinary.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(25); expect(ordinary.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(25);
expect(last.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(45); expect(last.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(45);
} }
}); }, 30000);
} }
it("allows real Stockades boss mechanics during a level-three WoW Priest party fight", () => { it("allows real Stockades boss mechanics during a level-three WoW Priest party fight", () => {
+27 -9
View File
@@ -1,12 +1,22 @@
import { creatureRuntimeBaseStats } from "./azerothCoreReference"; import { creatureRuntimeBaseStats } from "./azerothCoreReference";
import { bossDifficultyMultipliers } from "./dungeonDifficulty"; import { bossDifficultyMultipliers } from "./dungeonDifficulty";
import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling"; import { dungeonEnemyHealthBaseline, dungeonPartyHealthScale, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
import { finalDungeonBoss } from "./dungeonRunStore"; import { finalDungeonBoss } from "./dungeonRunStore";
import type { DungeonDefinition } from "./dungeonTypes"; import type { DungeonDefinition } from "./dungeonTypes";
import type { PopulationEntityDefinition } from "./mobPopulation"; import type { PopulationEntityDefinition } from "./mobPopulation";
import type { ContentCategoryId } from "../app/contentCategories"; import type { ContentCategoryId } from "../app/contentCategories";
const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [ const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [
// The starter anchors include enough room for authentic presence, imbue,
// disease, and shock rotations to retain the 25/45-second mechanic window.
[2, 1_050],
[4, 1_500],
[9, 7_000],
[21, 11_000],
[41, 23_000],
[80, 180_000],
];
const WOW_BOSS_TEST_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [
[2, 900], [2, 900],
[4, 1_300], [4, 1_300],
[9, 7_000], [9, 7_000],
@@ -15,6 +25,7 @@ const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])
[80, 180_000], [80, 180_000],
]; ];
const BOSS_TEST_HEALTH_FLOOR_MULTIPLIER = 0.6; const BOSS_TEST_HEALTH_FLOOR_MULTIPLIER = 0.6;
const FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER = 1.9;
function interpolatedFloor(level: number, anchors: readonly (readonly [number, number])[]): number { function interpolatedFloor(level: number, anchors: readonly (readonly [number, number])[]): number {
const upperIndex = anchors.findIndex(([anchorLevel]) => level <= anchorLevel); const upperIndex = anchors.findIndex(([anchorLevel]) => level <= anchorLevel);
@@ -26,12 +37,15 @@ function interpolatedFloor(level: number, anchors: readonly (readonly [number, n
return lowerHealth + (upperHealth - lowerHealth) * progress; return lowerHealth + (upperHealth - lowerHealth) * progress;
} }
function partyBossHealthFloor(level: number, characterSource: ContentCategoryId): number { function partyBossHealthFloor(level: number, characterSource: ContentCategoryId, testing: boolean): number {
if (characterSource === "wow") return interpolatedFloor(level, WOW_BOSS_HEALTH_FLOOR); if (characterSource === "wow") return interpolatedFloor(
level,
testing ? WOW_BOSS_TEST_HEALTH_FLOOR : WOW_BOSS_HEALTH_FLOOR,
);
const baseline = 1_000 + level * 1_200 + level * level * 22; const baseline = 1_000 + level * 1_200 + level * level * 22;
// Each catalog is calibrated independently against its strongest canonical // Each catalog is calibrated independently against its strongest canonical
// class-role cohort so every fight retains the 25/45-second mechanic window. // class-role cohort so every fight retains the 25/45-second mechanic window.
return baseline * (characterSource === "rom" ? 1.15 : 1.75); return baseline * 1.15 * dungeonPartyHealthScale(characterSource);
} }
/** Campaign fights retain their full durability floor; testing rooms use a reduced floor. */ /** Campaign fights retain their full durability floor; testing rooms use a reduced floor. */
@@ -43,14 +57,18 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
const level = scaledDungeonEnemyLevel(characterLevel, boss); const level = scaledDungeonEnemyLevel(characterLevel, boss);
const authoredHealth = entity.combat?.healthMultiplier ?? 1; const authoredHealth = entity.combat?.healthMultiplier ?? 1;
const authoredDamage = entity.combat?.damageMultiplier ?? 1; const authoredDamage = entity.combat?.damageMultiplier ?? 1;
const baseline = dungeonEnemyHealthBaseline(level, dungeon.expansion); const baseline = dungeonEnemyHealthBaseline(level, characterSource);
const native = entity.combat?.serverEntry ? creatureRuntimeBaseStats(entity.combat.serverEntry, level) : null; // Isolated testing rooms retain a packaged source boss's native health so
const originalHealth = (baseline ?? native?.health ?? (boss ? 260 + level * 65 : 72 + level * 22)) * authoredHealth; // developers can inspect it without changing campaign cross-catalog balance.
const native = testing && entity.combat?.serverEntry
? creatureRuntimeBaseStats(entity.combat.serverEntry, level)
: null;
const originalHealth = (native?.health ?? baseline) * authoredHealth;
const multipliers = boss ? bossDifficultyMultipliers(bossDifficulty) : { health: 1, damage: 1 }; const multipliers = boss ? bossDifficultyMultipliers(bossDifficulty) : { health: 1, damage: 1 };
const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId; const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
// Match durability to the character's game, including when crossing dungeon catalogs. // Match durability to the character's game, including when crossing dungeon catalogs.
const partyHealthFloor = partyBossHealthFloor(level, characterSource); const partyHealthFloor = partyBossHealthFloor(level, characterSource, testing);
const bossFloor = partyHealthFloor * (finalBoss ? 1.8 : 1); const bossFloor = partyHealthFloor * (finalBoss ? FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER : 1);
// A test room's sole objective must not promote every boss to a campaign // A test room's sole objective must not promote every boss to a campaign
// finale. A reduced floor still prevents high-level source creatures with // finale. A reduced floor still prevents high-level source creatures with
// tiny extrapolated level-two health from dying before mechanics can run. // tiny extrapolated level-two health from dying before mechanics can run.
+11 -10
View File
@@ -14,18 +14,19 @@ describe("normal dungeon level scaling", () => {
expect(scaledDungeonEnemyLevel(80, true)).toBe(80); expect(scaledDungeonEnemyLevel(80, true)).toBe(80);
}); });
it("grows RoM health with ability ranks and clamps invalid levels", () => { it("uses source-calibrated health curves for every party", () => {
expect(dungeonEnemyHealthBaseline(1, "runewaker")).toBeGreaterThan(1_000); expect(dungeonEnemyHealthBaseline(5, "wow")).toBe(306);
expect(dungeonEnemyHealthBaseline(20, "runewaker")).toBeGreaterThan(dungeonEnemyHealthBaseline(8, "runewaker")!); expect(dungeonEnemyHealthBaseline(5, "rom")).toBe(1_700);
expect(dungeonEnemyHealthBaseline(70, "runewaker")).toBeGreaterThan(dungeonEnemyHealthBaseline(20, "runewaker")! * 5); expect(dungeonEnemyHealthBaseline(5, "coa")).toBe(2_587);
expect(dungeonEnemyHealthBaseline(0, "runewaker")).toBe(dungeonEnemyHealthBaseline(1, "runewaker")); expect(dungeonEnemyHealthBaseline(20, "rom")).toBeGreaterThan(dungeonEnemyHealthBaseline(8, "rom"));
expect(dungeonEnemyHealthBaseline(100, "runewaker")).toBe(dungeonEnemyHealthBaseline(80, "runewaker")); expect(dungeonEnemyHealthBaseline(20, "coa")).toBeGreaterThan(dungeonEnemyHealthBaseline(20, "rom"));
expect(dungeonEnemyHealthBaseline(Number.NaN, "runewaker")).toBe(dungeonEnemyHealthBaseline(1, "runewaker"));
}); });
it("retains native WoW creature health across its three expansions", () => { it("clamps every party curve to the supported player-level range", () => {
for (const expansion of ["classic", "burning-crusade", "wrath"] as const) { for (const source of ["wow", "rom", "coa"] as const) {
expect(dungeonEnemyHealthBaseline(20, expansion)).toBeUndefined(); expect(dungeonEnemyHealthBaseline(0, source)).toBe(dungeonEnemyHealthBaseline(1, source));
expect(dungeonEnemyHealthBaseline(100, source)).toBe(dungeonEnemyHealthBaseline(80, source));
expect(dungeonEnemyHealthBaseline(Number.NaN, source)).toBe(dungeonEnemyHealthBaseline(1, source));
} }
}); });
}); });
+20 -8
View File
@@ -1,5 +1,9 @@
import type { ContentCategoryId } from "../app/contentCategories";
import { standardWowDungeonCreatureHealth } from "./azerothCoreReference";
import { clampPlayerLevel } from "./progression"; import { clampPlayerLevel } from "./progression";
import type { DungeonExpansion } from "./dungeonTypes";
const ROM_PARTY_HEALTH_SCALE = 1.15;
const COA_PARTY_HEALTH_SCALE = 1.75;
/** /**
* Normal campaign dungeons scale to the character instead of retaining the * Normal campaign dungeons scale to the character instead of retaining the
@@ -11,13 +15,21 @@ export function scaledDungeonEnemyLevel(characterLevel: number, boss: boolean):
} }
/** /**
* RoM creatures do not have packaged native health tables. Give their dungeon * Enemy durability follows the party's combat catalog, not the dungeon's
* baseline enough growth for a full party's rank-scaled abilities; the old * source game. This lets a WoW party take its familiar curve into a RoM
* 72 + level * 22 solo-target fallback was overwhelmed by the opening volley. * dungeon while stronger RoM and CoA parties retain meaningful kill times in
* Authored creature and boss health multipliers are applied separately. * every catalog. Authored creature and boss multipliers are applied separately.
*/ */
export function dungeonEnemyHealthBaseline(level: number, expansion: DungeonExpansion): number | undefined { export function dungeonEnemyHealthBaseline(level: number, partySource: ContentCategoryId): number {
if (expansion !== "runewaker") return undefined;
const safeLevel = clampPlayerLevel(level); const safeLevel = clampPlayerLevel(level);
return 1_000 + safeLevel * 100 + safeLevel * safeLevel * 8; if (partySource === "wow") return standardWowDungeonCreatureHealth(safeLevel);
const romBaseline = 1_000 + safeLevel * 100 + safeLevel * safeLevel * 8;
if (partySource === "rom") return romBaseline;
return Math.round(romBaseline * COA_PARTY_HEALTH_SCALE / ROM_PARTY_HEALTH_SCALE);
}
/** Keeps trash and boss source calibration on the same relative power scale. */
export function dungeonPartyHealthScale(partySource: ContentCategoryId): number {
if (partySource === "wow" || partySource === "rom") return 1;
return COA_PARTY_HEALTH_SCALE / ROM_PARTY_HEALTH_SCALE;
} }
+1 -1
View File
@@ -36,7 +36,7 @@ it("turns an actual RoM party's lethal damage to Anglo into one timed completion
combat.synchronizeActors(); combat.synchronizeActors();
const bossLevel = scaledDungeonEnemyLevel(1, true); const bossLevel = scaledDungeonEnemyLevel(1, true);
combat.registerMob(boss.id, { name: entity.name, boss: true, level: bossLevel, xpReward: 0, combat.registerMob(boss.id, { name: entity.name, boss: true, level: bossLevel, xpReward: 0,
maxHealth: dungeonEnemyHealthBaseline(bossLevel, "runewaker")! * healthMultiplier, maxHealth: dungeonEnemyHealthBaseline(bossLevel, "rom") * healthMultiplier,
healthMultiplier, attacks: bossCombat.attacks }); healthMultiplier, attacks: bossCombat.attacks });
setMobPosition(boss.id, [0, 0, 0]); setMobPosition(boss.id, [0, 0, 0]);
for (const member of usePartyStore.getState().members) { for (const member of usePartyStore.getState().members) {
+17
View File
@@ -54,6 +54,23 @@ describe("dungeon simulation fixed clock", () => {
}); });
describe("spectator roster and exact-hash approvals", () => { describe("spectator roster and exact-hash approvals", () => {
it("tracks the chosen party member and returns to follow mode when one is picked", () => {
const store = useDungeonSimulationStore.getState();
store.startLive({ dungeonId: route.dungeonId, route, mode: "navigation-only", seed: "camera" });
expect(useDungeonSimulationStore.getState()).toMatchObject({
cameraMode: "follow-member",
spectatedMemberId: null,
});
store.setCameraMode("free-fly");
store.setSpectatedMember("party-healer");
expect(useDungeonSimulationStore.getState()).toMatchObject({
cameraMode: "follow-member",
spectatedMemberId: "party-healer",
});
store.leave();
expect(useDungeonSimulationStore.getState().spectatedMemberId).toBeNull();
});
it("creates a stable tank, healer, and three damage dealers", () => { it("creates a stable tank, healer, and three damage dealers", () => {
const first = createSpectatorDungeonParty("same-seed", 80, "wow"); const first = createSpectatorDungeonParty("same-seed", 80, "wow");
const second = createSpectatorDungeonParty("same-seed", 80, "wow"); const second = createSpectatorDungeonParty("same-seed", 80, "wow");
+5 -1
View File
@@ -33,6 +33,7 @@ export interface DungeonSimulationState {
runState: "idle" | "running" | "paused" | "finished"; runState: "idle" | "running" | "paused" | "finished";
speed: DungeonSimulationSpeed; speed: DungeonSimulationSpeed;
cameraMode: DungeonSpectatorCameraMode; cameraMode: DungeonSpectatorCameraMode;
spectatedMemberId: string | null;
dungeonId: string | null; dungeonId: string | null;
routeId: string | null; routeId: string | null;
routeHash: string | null; routeHash: string | null;
@@ -56,6 +57,7 @@ export interface DungeonSimulationState {
toggleRunning: () => void; toggleRunning: () => void;
setSpeed: (speed: DungeonSimulationSpeed) => void; setSpeed: (speed: DungeonSimulationSpeed) => void;
setCameraMode: (mode: DungeonSpectatorCameraMode) => void; setCameraMode: (mode: DungeonSpectatorCameraMode) => void;
setSpectatedMember: (memberId: string | null) => void;
step: () => void; step: () => void;
seekReplay: (elapsedMs: number) => void; seekReplay: (elapsedMs: number) => void;
consumeTicks: (wallDeltaMs: number) => readonly number[]; consumeTicks: (wallDeltaMs: number) => readonly number[];
@@ -72,7 +74,8 @@ const initialState = {
mode: "full-combat" as DungeonSimulationMode, mode: "full-combat" as DungeonSimulationMode,
runState: "idle" as const, runState: "idle" as const,
speed: 1 as DungeonSimulationSpeed, speed: 1 as DungeonSimulationSpeed,
cameraMode: "follow-leader" as DungeonSpectatorCameraMode, cameraMode: "follow-member" as DungeonSpectatorCameraMode,
spectatedMemberId: null as string | null,
dungeonId: null as string | null, dungeonId: null as string | null,
routeId: null as string | null, routeId: null as string | null,
routeHash: null as string | null, routeHash: null as string | null,
@@ -146,6 +149,7 @@ export const useDungeonSimulationStore = create<DungeonSimulationState>((set, ge
}, },
setSpeed: (speed) => set({ speed }), setSpeed: (speed) => set({ speed }),
setCameraMode: (cameraMode) => set({ cameraMode }), setCameraMode: (cameraMode) => set({ cameraMode }),
setSpectatedMember: (spectatedMemberId) => set({ spectatedMemberId, cameraMode: "follow-member" }),
step: () => set((state) => { step: () => set((state) => {
if (state.runState !== "paused") return state; if (state.runState !== "paused") return state;
if (state.sessionMode === "spectate-replay" && state.replay) { if (state.sessionMode === "spectate-replay" && state.replay) {
+1 -1
View File
@@ -3,7 +3,7 @@ import type { Vector3Tuple } from "./dungeonTypes";
export type DungeonSessionMode = "play" | "spectate-live" | "spectate-replay"; export type DungeonSessionMode = "play" | "spectate-live" | "spectate-replay";
export type DungeonSimulationMode = "full-combat" | "navigation-only"; export type DungeonSimulationMode = "full-combat" | "navigation-only";
export type DungeonSimulationSpeed = 0.5 | 1 | 2 | 4; export type DungeonSimulationSpeed = 0.5 | 1 | 2 | 4;
export type DungeonSpectatorCameraMode = "follow-leader" | "free-fly"; export type DungeonSpectatorCameraMode = "follow-member" | "free-fly";
export type DungeonRouteReviewStatus = "Candidate" | "Approved" | "NeedsWork"; export type DungeonRouteReviewStatus = "Candidate" | "Approved" | "NeedsWork";
export type DungeonSimulationResult = "completed" | "wiped" | "stopped" | "recording-limit"; export type DungeonSimulationResult = "completed" | "wiped" | "stopped" | "recording-limit";
export type DungeonSimulationFindingSeverity = "info" | "warning" | "critical"; export type DungeonSimulationFindingSeverity = "info" | "warning" | "critical";
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1 +1 @@
{"schemaVersion":1,"maximumCoaLevel":60,"coaBudgetsByClass":{"barbarian":{"maximumAbility":26,"maximumTalent":25},"witch-doctor":{"maximumAbility":26,"maximumTalent":25},"felsworn":{"maximumAbility":26,"maximumTalent":25},"witch-hunter":{"maximumAbility":26,"maximumTalent":25},"stormbringer":{"maximumAbility":26,"maximumTalent":25},"knight-of-xoroth":{"maximumAbility":26,"maximumTalent":25},"guardian":{"maximumAbility":26,"maximumTalent":25},"templar":{"maximumAbility":26,"maximumTalent":25},"bloodmage":{"maximumAbility":26,"maximumTalent":25},"ranger":{"maximumAbility":26,"maximumTalent":25},"chronomancer":{"maximumAbility":26,"maximumTalent":25},"necromancer":{"maximumAbility":26,"maximumTalent":25},"pyromancer":{"maximumAbility":26,"maximumTalent":25},"cultist":{"maximumAbility":26,"maximumTalent":25},"starcaller":{"maximumAbility":26,"maximumTalent":25},"sun-cleric":{"maximumAbility":26,"maximumTalent":25},"tinker":{"maximumAbility":26,"maximumTalent":25},"venomancer":{"maximumAbility":26,"maximumTalent":25},"reaper":{"maximumAbility":26,"maximumTalent":25},"primalist":{"maximumAbility":26,"maximumTalent":25},"runemaster":{"maximumAbility":26,"maximumTalent":25}},"trees":[{"id":"death-knight-blood","classId":"death-knight"},{"id":"death-knight-frost","classId":"death-knight"},{"id":"death-knight-unholy","classId":"death-knight"},{"id":"druid-balance","classId":"druid"},{"id":"druid-feral-combat","classId":"druid"},{"id":"druid-restoration","classId":"druid"},{"id":"hunter-beast-mastery","classId":"hunter"},{"id":"hunter-marksmanship","classId":"hunter"},{"id":"hunter-survival","classId":"hunter"},{"id":"mage-arcane","classId":"mage"},{"id":"mage-fire","classId":"mage"},{"id":"mage-frost","classId":"mage"},{"id":"paladin-holy","classId":"paladin"},{"id":"paladin-protection","classId":"paladin"},{"id":"paladin-retribution","classId":"paladin"},{"id":"priest-discipline","classId":"priest"},{"id":"priest-holy","classId":"priest"},{"id":"priest-shadow","classId":"priest"},{"id":"rogue-assassination","classId":"rogue"},{"id":"rogue-combat","classId":"rogue"},{"id":"rogue-subtlety","classId":"rogue"},{"id":"shaman-elemental","classId":"shaman"},{"id":"shaman-enhancement","classId":"shaman"},{"id":"shaman-restoration","classId":"shaman"},{"id":"warlock-affliction","classId":"warlock"},{"id":"warlock-demonology","classId":"warlock"},{"id":"warlock-destruction","classId":"warlock"},{"id":"warrior-arms","classId":"warrior"},{"id":"warrior-fury","classId":"warrior"},{"id":"warrior-protection","classId":"warrior"},{"id":"coa-barbarian-31","classId":"barbarian"},{"id":"coa-barbarian-32","classId":"barbarian"},{"id":"coa-barbarian-33","classId":"barbarian"},{"id":"coa-barbarian-87","classId":"barbarian"},{"id":"coa-witch-doctor-36","classId":"witch-doctor"},{"id":"coa-witch-doctor-34","classId":"witch-doctor"},{"id":"coa-witch-doctor-35","classId":"witch-doctor"},{"id":"coa-witch-doctor-87","classId":"witch-doctor"},{"id":"coa-felsworn-37","classId":"felsworn"},{"id":"coa-felsworn-38","classId":"felsworn"},{"id":"coa-felsworn-18","classId":"felsworn"},{"id":"coa-felsworn-87","classId":"felsworn"},{"id":"coa-witch-hunter-39","classId":"witch-hunter"},{"id":"coa-witch-hunter-40","classId":"witch-hunter"},{"id":"coa-witch-hunter-41","classId":"witch-hunter"},{"id":"coa-witch-hunter-94","classId":"witch-hunter"},{"id":"coa-witch-hunter-87","classId":"witch-hunter"},{"id":"coa-stormbringer-42","classId":"stormbringer"},{"id":"coa-stormbringer-43","classId":"stormbringer"},{"id":"coa-stormbringer-44","classId":"stormbringer"},{"id":"coa-stormbringer-87","classId":"stormbringer"},{"id":"coa-knight-of-xoroth-46","classId":"knight-of-xoroth"},{"id":"coa-knight-of-xoroth-45","classId":"knight-of-xoroth"},{"id":"coa-knight-of-xoroth-47","classId":"knight-of-xoroth"},{"id":"coa-knight-of-xoroth-87","classId":"knight-of-xoroth"},{"id":"coa-guardian-7","classId":"guardian"},{"id":"coa-guardian-48","classId":"guardian"},{"id":"coa-guardian-49","classId":"guardian"},{"id":"coa-guardian-87","classId":"guardian"},{"id":"coa-templar-50","classId":"templar"},{"id":"coa-templar-16","classId":"templar"},{"id":"coa-templar-51","classId":"templar"},{"id":"coa-templar-87","classId":"templar"},{"id":"coa-bloodmage-26","classId":"bloodmage"},{"id":"coa-bloodmage-52","classId":"bloodmage"},{"id":"coa-bloodmage-53","classId":"bloodmage"},{"id":"coa-bloodmage-93","classId":"bloodmage"},{"id":"coa-bloodmage-87","classId":"bloodmage"},{"id":"coa-ranger-55","classId":"ranger"},{"id":"coa-ranger-87","classId":"ranger"},{"id":"coa-ranger-2","classId":"ranger"},{"id":"coa-ranger-54","classId":"ranger"},{"id":"coa-chronomancer-56","classId":"chronomancer"},{"id":"coa-chronomancer-57","classId":"chronomancer"},{"id":"coa-chronomancer-87","classId":"chronomancer"},{"id":"coa-chronomancer-58","classId":"chronomancer"},{"id":"coa-necromancer-61","classId":"necromancer"},{"id":"coa-necromancer-87","classId":"necromancer"},{"id":"coa-necromancer-59","classId"Line truncated {"schemaVersion":1,"maximumCoaLevel":60,"coaBudgetsByClass":{"barbarian":{"maximumAbility":26,"maximumTalent":25},"witch-doctor":{"maximumAbility":26,"maximumTalent":25},"felsworn":{"maximumAbility":26,"maximumTalent":25},"witch-hunter":{"maximumAbility":26,"maximumTalent":25},"stormbringer":{"maximumAbility":26,"maximumTalent":25},"knight-of-xoroth":{"maximumAbility":26,"maximumTalent":25},"guardian":{"maximumAbility":26,"maximumTalent":25},"templar":{"maximumAbility":26,"maximumTalent":25},"bloodmage":{"maximumAbility":26,"maximumTalent":25},"ranger":{"maximumAbility":26,"maximumTalent":25},"chronomancer":{"maximumAbility":26,"maximumTalent":25},"necromancer":{"maximumAbility":26,"maximumTalent":25},"pyromancer":{"maximumAbility":26,"maximumTalent":25},"cultist":{"maximumAbility":26,"maximumTalent":25},"starcaller":{"maximumAbility":26,"maximumTalent":25},"sun-cleric":{"maximumAbility":26,"maximumTalent":25},"tinker":{"maximumAbility":26,"maximumTalent":25},"venomancer":{"maximumAbility":26,"maximumTalent":25},"reaper":{"maximumAbility":26,"maximumTalent":25},"primalist":{"maximumAbility":26,"maximumTalent":25},"runemaster":{"maximumAbility":26,"maximumTalent":25}},"trees":[{"id":"death-knight-blood","classId":"death-knight"},{"id":"death-knight-frost","classId":"death-knight"},{"id":"death-knight-unholy","classId":"death-knight"},{"id":"druid-balance","classId":"druid"},{"id":"druid-feral-combat","classId":"druid"},{"id":"druid-restoration","classId":"druid"},{"id":"hunter-beast-mastery","classId":"hunter"},{"id":"hunter-marksmanship","classId":"hunter"},{"id":"hunter-survival","classId":"hunter"},{"id":"mage-arcane","classId":"mage"},{"id":"mage-fire","classId":"mage"},{"id":"mage-frost","classId":"mage"},{"id":"paladin-holy","classId":"paladin"},{"id":"paladin-protection","classId":"paladin"},{"id":"paladin-retribution","classId":"paladin"},{"id":"priest-discipline","classId":"priest"},{"id":"priest-holy","classId":"priest"},{"id":"priest-shadow","classId":"priest"},{"id":"rogue-assassination","classId":"rogue"},{"id":"rogue-combat","classId":"rogue"},{"id":"rogue-subtlety","classId":"rogue"},{"id":"shaman-elemental","classId":"shaman"},{"id":"shaman-enhancement","classId":"shaman"},{"id":"shaman-restoration","classId":"shaman"},{"id":"warlock-affliction","classId":"warlock"},{"id":"warlock-demonology","classId":"warlock"},{"id":"warlock-destruction","classId":"warlock"},{"id":"warrior-arms","classId":"warrior"},{"id":"warrior-fury","classId":"warrior"},{"id":"warrior-protection","classId":"warrior"},{"id":"coa-barbarian-31","classId":"barbarian"},{"id":"coa-barbarian-32","classId":"barbarian"},{"id":"coa-barbarian-33","classId":"barbarian"},{"id":"coa-barbarian-87","classId":"barbarian"},{"id":"coa-witch-doctor-36","classId":"witch-doctor"},{"id":"coa-witch-doctor-34","classId":"witch-doctor"},{"id":"coa-witch-doctor-35","classId":"witch-doctor"},{"id":"coa-witch-doctor-87","classId":"witch-doctor"},{"id":"coa-felsworn-37","classId":"felsworn"},{"id":"coa-felsworn-38","classId":"felsworn"},{"id":"coa-felsworn-18","classId":"felsworn"},{"id":"coa-felsworn-87","classId":"felsworn"},{"id":"coa-witch-hunter-39","classId":"witch-hunter"},{"id":"coa-witch-hunter-40","classId":"witch-hunter"},{"id":"coa-witch-hunter-41","classId":"witch-hunter"},{"id":"coa-witch-hunter-94","classId":"witch-hunter"},{"id":"coa-witch-hunter-87","classId":"witch-hunter"},{"id":"coa-stormbringer-42","classId":"stormbringer"},{"id":"coa-stormbringer-43","classId":"stormbringer"},{"id":"coa-stormbringer-44","classId":"stormbringer"},{"id":"coa-stormbringer-87","classId":"stormbringer"},{"id":"coa-knight-of-xoroth-46","classId":"knight-of-xoroth"},{"id":"coa-knight-of-xoroth-45","classId":"knight-of-xoroth"},{"id":"coa-knight-of-xoroth-47","classId":"knight-of-xoroth"},{"id":"coa-knight-of-xoroth-87","classId":"knight-of-xoroth"},{"id":"coa-guardian-7","classId":"guardian"},{"id":"coa-guardian-48","classId":"guardian"},{"id":"coa-guardian-49","classId":"guardian"},{"id":"coa-guardian-87","classId":"guardian"},{"id":"coa-templar-50","classId":"templar"},{"id":"coa-templar-16","classId":"templar"},{"id":"coa-templar-51","classId":"templar"},{"id":"coa-templar-87","classId":"templar"},{"id":"coa-bloodmage-26","classId":"bloodmage"},{"id":"coa-bloodmage-52","classId":"bloodmage"},{"id":"coa-bloodmage-53","classId":"bloodmage"},{"id":"coa-bloodmage-93","classId":"bloodmage"},{"id":"coa-bloodmage-87","classId":"bloodmage"},{"id":"coa-ranger-55","classId":"ranger"},{"id":"coa-ranger-87","classId":"ranger"},{"id":"coa-ranger-2","classId":"ranger"},{"id":"coa-ranger-54","classId":"ranger"},{"id":"coa-chronomancer-56","classId":"chronomancer"},{"id":"coa-chronomancer-57","classId":"chronomancer"},{"id":"coa-chronomancer-87","classId":"chronomancer"},{"id":"coa-chronomancer-58","classId":"chronomancer"},{"id":"coa-necromancer-61","classId":"necromancer"},{"id":"coa-necromancer-87","classId":"necromancer"},{"id":"coa-necromancer-59","classId"Line truncated
+3 -10
View File
@@ -1,7 +1,7 @@
import type { ClassId } from "../app/characterCatalog"; import type { ClassId } from "../app/characterCatalog";
import { import {
abilitiesForClass, abilitiesForClass,
abilityAtLevel, learnedAbilityForBuild,
type AbilityDefinition, type AbilityDefinition,
} from "./abilityCatalog"; } from "./abilityCatalog";
import { import {
@@ -82,21 +82,14 @@ const PRIMARY_LOADOUT_CONTROLS: readonly ActionBindingControl[] = [
"face-right", "face-right",
]; ];
function learnedTalentRank(ability: AbilityDefinition, talentRanks: Readonly<Record<string, number>>): number {
return ability.talentEntryId === undefined
? 0
: Math.max(0, Math.trunc(talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0));
}
function isSelectableClientSpell( function isSelectableClientSpell(
ability: AbilityDefinition, ability: AbilityDefinition,
level: number, level: number,
talentRanks: Readonly<Record<string, number>>, talentRanks: Readonly<Record<string, number>>,
): boolean { ): boolean {
return !ability.passive return !ability.passive
&& ability.unlockLevel <= level
&& CLIENT_GROUNDED_SOURCES.has(ability.source) && CLIENT_GROUNDED_SOURCES.has(ability.source)
&& (ability.talentEntryId === undefined || learnedTalentRank(ability, talentRanks) > 0); && learnedAbilityForBuild(ability, level, talentRanks) !== null;
} }
export function manastormActiveSpellLoadoutModel( export function manastormActiveSpellLoadoutModel(
@@ -108,7 +101,7 @@ export function manastormActiveSpellLoadoutModel(
const options = abilitiesForClass(classId) const options = abilitiesForClass(classId)
.filter((ability) => isSelectableClientSpell(ability, level, talentRanks)) .filter((ability) => isSelectableClientSpell(ability, level, talentRanks))
.map((ability) => { .map((ability) => {
const resolved = abilityAtLevel(ability, level, learnedTalentRank(ability, talentRanks)); const resolved = learnedAbilityForBuild(ability, level, talentRanks)!;
return Object.freeze({ return Object.freeze({
id: ability.id, id: ability.id,
abilityId: ability.id, abilityId: ability.id,
+39
View File
@@ -6,6 +6,7 @@ import { EMPTY_ITEM_STATS } from "./itemStats";
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady, partyAbilityAmount } from "./partyAbilityAi"; import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady, partyAbilityAmount } from "./partyAbilityAi";
import { usePartyStore, type PartyMember } from "./partyStore"; import { usePartyStore, type PartyMember } from "./partyStore";
import { deriveCharacterStats } from "./wotlkStats"; import { deriveCharacterStats } from "./wotlkStats";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
function memberFor(classId: PartyMember["classId"], role: PartyMember["role"]): PartyMember { function memberFor(classId: PartyMember["classId"], role: PartyMember["role"]): PartyMember {
const base = usePartyStore.getState().members[0]; const base = usePartyStore.getState().members[0];
@@ -88,6 +89,44 @@ describe("catalog-driven party rotations", () => {
expect(partyAbilityIsReady(member, heal, 2_000)).toBe(false); expect(partyAbilityIsReady(member, heal, 2_000)).toBe(false);
}); });
it("banks Rage for a higher-priority signature strike instead of starving it with Slam", () => {
const talents = runtimePartyTalentRanksForLevel("warrior", "Arms", 80);
const base = {
...memberFor("warrior", "damage"),
specialization: "Arms",
specializationId: "warrior:arms",
talentRanks: talents,
};
const stance = abilitiesForClass("warrior").find((ability) => ability.name === "Battle Stance")!;
const rend = abilitiesForClass("warrior").find((ability) => ability.name === "Rend")!;
const maintainedAbilityIds = new Set([stance.id, rend.id]);
const target = { ...useCombatStore.getState().mobs.caster, activeCast: null };
const empty = { ...base, resource: 0, resourcePools: { rage: 0 } };
expect(choosePartyAbility(empty, { now: 2_000, target, maintainedAbilityIds })?.name)
.toBe("Bloodrage");
const bloodrage = abilitiesForClass("warrior").find((ability) => ability.name === "Bloodrage")!;
const saving = { ...base, resource: 15, resourcePools: { rage: 15 }, cooldowns: { [bloodrage.id]: 60_000 } };
expect(choosePartyAbility(saving, { now: 2_000, target, maintainedAbilityIds })).toBeNull();
const funded = { ...base, resource: 30, resourcePools: { rage: 30 }, cooldowns: { [bloodrage.id]: 60_000 } };
expect(choosePartyAbility(funded, { now: 2_000, target, maintainedAbilityIds })?.name)
.toBe("Mortal Strike");
});
it("opens Arcane combat with the learned Arcane Barrage signature", () => {
const member = {
...memberFor("mage", "damage"),
specialization: "Arcane",
specializationId: "mage:arcane",
talentRanks: runtimePartyTalentRanksForLevel("mage", "Arcane", 80),
};
const maintainedAbilityIds = new Set(abilitiesForClass("mage")
.filter((ability) => ["Mage Armor", "Ice Armor", "Frost Armor"].includes(ability.name))
.map((ability) => ability.id));
const target = { ...useCombatStore.getState().mobs.caster, activeCast: null };
expect(choosePartyAbility(member, { now: 2_000, target, maintainedAbilityIds })?.name)
.toBe("Arcane Barrage");
});
it("preserves spell base damage when its bonus-power coefficient is zero", () => { it("preserves spell base damage when its bonus-power coefficient is zero", () => {
const member = { ...memberFor("warrior", "tank"), level: 5, gearStats: EMPTY_ITEM_STATS }; const member = { ...memberFor("warrior", "tank"), level: 5, gearStats: EMPTY_ITEM_STATS };
const ability = abilitiesForClass("warrior").find((candidate) => candidate.id === "warrior-heroic-strike")!; const ability = abilitiesForClass("warrior").find((candidate) => candidate.id === "warrior-heroic-strike")!;
+114 -55
View File
@@ -1,12 +1,17 @@
import { import {
abilitiesForClass, abilitiesForCharacter,
abilityAtLevel, learnedAbilityForBuild,
isAbilityUnlocked,
type AbilityDefinition, type AbilityDefinition,
type AbilityEffect, type AbilityEffect,
type ResourceType, type ResourceType,
} from "./abilityCatalog"; } from "./abilityCatalog";
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules"; import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
import {
evaluateCombatRotation,
rotationProfileFor,
type RotationDecision,
type RotationFriendlyTarget,
} from "./combatRotation";
import { spendDeathKnightRunes } from "./combatResources"; import { spendDeathKnightRunes } from "./combatResources";
import { equipmentAdjustedCombatAmount, type MobCombatState } from "./combatStore"; import { equipmentAdjustedCombatAmount, type MobCombatState } from "./combatStore";
import type { PartyMember } from "./partyStore"; import type { PartyMember } from "./partyStore";
@@ -17,11 +22,18 @@ export interface PartyAbilityContext {
readonly now: number; readonly now: number;
readonly target: MobCombatState | null; readonly target: MobCombatState | null;
readonly targetIsLoose?: boolean; readonly targetIsLoose?: boolean;
readonly hasDangerousThreat?: boolean;
readonly requiredControl?: boolean; readonly requiredControl?: boolean;
readonly allyNeedsDispel?: boolean; readonly allyNeedsDispel?: boolean;
readonly fallenAlly?: boolean; readonly fallenAlly?: boolean;
readonly injuredAllies?: number; readonly injuredAllies?: number;
readonly lowestHealthFraction?: number; readonly lowestHealthFraction?: number;
readonly hostileCount?: number;
readonly friendlyTargets?: readonly RotationFriendlyTarget[];
readonly maintainedAbilityIds?: ReadonlySet<string>;
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
readonly activeSummonAbilityIds?: ReadonlySet<string>;
readonly hasActivePet?: boolean;
readonly canUseAbility?: (ability: AbilityDefinition) => boolean; readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
} }
@@ -39,8 +51,7 @@ function abilityCosts(member: PartyMember, ability: AbilityDefinition): readonly
} }
export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean { export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean {
if (ability.passive || !isAbilityUnlocked(ability, member.level)) return false; if (ability.passive || !learnedAbilityForBuild(ability, member.level, member.talentRanks)) return false;
if (ability.talentEntryId !== undefined && !(member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0)) return false;
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false; if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false; if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false; if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
@@ -50,63 +61,111 @@ export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinit
return !runeCost || spendDeathKnightRunes(member.runes, runeCost, now) !== null; return !runeCost || spendDeathKnightRunes(member.runes, runeCost, now) !== null;
} }
function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] { function learnedAbilities(member: PartyMember): readonly AbilityDefinition[] {
return abilitiesForClass(member.classId) return abilitiesForCharacter(member.classId, member.secondaryClassId)
.map((ability) => abilityAtLevel(ability, member.level, ability.talentEntryId === undefined .map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
? undefined : member.talentRanks[`coa-entry-${ability.talentEntryId}`] ?? 0)) .filter((ability): ability is AbilityDefinition => ability !== null);
.filter((ability) => partyAbilityIsReady(member, ability, now));
} }
function scoreAbility(member: PartyMember, ability: AbilityDefinition, context: PartyAbilityContext): number { function isComboFinisher(ability: AbilityDefinition): boolean {
const categories = new Set(aiHintsForAbility(ability).categories); return ability.effects.some((effect) => effect.kind === "finisher-damage"
const damaging = categories.has("damage") || categories.has("execute"); || (effect.kind === "combo" && effect.amount < 0));
if (categories.has("taunt") && (member.role !== "tank" || !context.targetIsLoose)) return 0; }
if (!damaging && categories.has("interrupt") && !context.target?.activeCast) return 0;
const control = categories.has("control") || ability.effects.some((effect) => effect.kind === "apply-aura" && effect.control); const RESERVED_SIGNATURE_ACTIONS = new Set(["Mortal Strike"]);
if (!damaging && control && !context.requiredControl) return 0;
let score = ability.ai?.priority ?? 0; function shouldReserveForHigherPriority(
if (member.role === "tank") { member: PartyMember,
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000; learned: readonly AbilityDefinition[],
if (context.targetIsLoose && categories.has("taunt")) score += 900; decision: RotationDecision,
if (categories.has("mitigation") && member.health / member.maxHealth < 0.55) score += 760; now: number,
if (categories.has("control") && context.requiredControl) score += 700; ): boolean {
if (categories.has("debuff")) score += 300; const rotation = rotationProfileFor(member.classId, member.specializationId, member.specialization);
if (categories.has("damage")) score += 180; const decisionIndex = rotation?.abilityPriority.indexOf(decision.ability.name) ?? -1;
} else if (member.role === "healer") { if (!rotation || decisionIndex <= 0) return false;
if (context.fallenAlly && categories.has("resurrection")) score += 1_200; return rotation.abilityPriority.slice(0, decisionIndex).some((name) => {
if (context.allyNeedsDispel && categories.has("dispel")) score += 1_050; const candidate = learned.find((ability) => ability.name === name);
if ((context.lowestHealthFraction ?? 1) < 0.3 && categories.has("emergency-heal")) score += 950; if (!candidate || candidate.passive || isComboFinisher(candidate)) return false;
if ((context.injuredAllies ?? 0) >= 3 && categories.has("area-heal")) score += 850; if (!RESERVED_SIGNATURE_ACTIONS.has(candidate.name)) return false;
if ((context.lowestHealthFraction ?? 1) < 0.9 && categories.has("heal")) score += 600; if ((member.cooldowns[candidate.id] ?? 0) > now) return false;
if (categories.has("mitigation") && ((context.lowestHealthFraction ?? 1) < 0.8 || member.health / member.maxHealth < 0.8)) score += 350; if ((member.schoolLockedUntil[candidate.castRules?.school ?? "physical"] ?? 0) > now) return false;
if ((context.lowestHealthFraction ?? 1) < 0.9 && categories.has("heal")) { const categories = aiHintsForAbility(candidate).categories;
score += Math.min(150, ability.effects.reduce((total, effect) => total + (effect.kind === "heal" || effect.kind === "hot" if (!categories.includes("damage") || categories.includes("execute")) return false;
? partyAbilityAmount(member, ability, effect) * (effect.kind === "hot" ? Math.min(3, effect.ticks) : 1) : 0), 0) / member.maxHealth * 100); const blockedCosts = abilityCosts(member, candidate).filter((cost) => (
} cost.amount > currentResource(member, cost.resource)
if (context.target?.activeCast && categories.has("interrupt")) score += 250; ));
if (categories.has("damage")) score += 80; if (!blockedCosts.length) return false;
} else { return blockedCosts.some((cost) => !decision.ability.effects.some((effect) => (
if (context.target?.activeCast && categories.has("interrupt")) score += 1_000; effect.kind === "resource"
if (context.requiredControl && categories.has("control")) score += 900; && (effect.resource ?? member.resourceType) === cost.resource
if ((context.target?.health ?? 1) / Math.max(1, context.target?.maxHealth ?? 1) <= 0.2 && categories.has("execute")) score += 800; && effect.amount > 0
if (categories.has("debuff")) score += 520; )));
if (categories.has("damage")) score += 300; });
} }
if (ability.castTimeMs > 0 && context.target?.activeCast && categories.has("interrupt")) score -= 800;
return score; export function choosePartyRotationDecision(
member: PartyMember,
context: PartyAbilityContext,
): RotationDecision | null {
const friendlyTargets = context.friendlyTargets ?? (context.fallenAlly || context.allyNeedsDispel
|| (context.lowestHealthFraction ?? 1) < 1
? [{
id: "rotation-friendly-target",
health: (context.lowestHealthFraction ?? 1) * 100,
maxHealth: 100,
fallen: context.fallenAlly,
needsDispel: context.allyNeedsDispel,
}]
: []);
const maximumResources = Object.fromEntries(
[...new Set([...Object.keys(member.resourcePools), member.resourceType])]
.map((resource) => [resource, companionResourceMaximum(member, resource)]),
);
const learned = learnedAbilities(member);
const decision = evaluateCombatRotation({
now: context.now,
classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId,
specialization: member.specialization,
role: member.role,
level: member.level,
health: member.health,
maxHealth: member.maxHealth,
selfId: member.id,
resources: member.resourcePools,
maximumResources,
primaryResource: member.resourceType,
comboPoints: member.comboPoints,
comboTargetId: member.comboTargetId,
runes: member.runes,
abilities: learned.filter((ability) => partyAbilityIsReady(member, ability, context.now)),
knownDamagingAbilityNames: new Set(learned.filter((ability) => {
const categories = aiHintsForAbility(ability).categories;
return categories.includes("damage") || categories.includes("execute");
}).map((ability) => ability.name)),
hostileTarget: context.target,
friendlyTargets,
hostileCount: context.hostileCount,
targetIsLoose: context.targetIsLoose,
hasDangerousThreat: context.hasDangerousThreat,
requiredControl: context.requiredControl,
maintainedAbilityIds: context.maintainedAbilityIds,
activeAuraExclusiveGroups: context.activeAuraExclusiveGroups,
activeSummonAbilityIds: context.activeSummonAbilityIds,
hasActivePet: context.hasActivePet,
canUseAbility: context.canUseAbility,
});
return decision && shouldReserveForHigherPriority(member, learned, decision, context.now)
? null
: decision;
} }
export function choosePartyAbility( export function choosePartyAbility(
member: PartyMember, member: PartyMember,
context: PartyAbilityContext, context: PartyAbilityContext,
): AbilityDefinition | null { ): AbilityDefinition | null {
return candidateAbilities(member, context.now) return choosePartyRotationDecision(member, context)?.ability ?? null;
.filter((ability) => context.canUseAbility?.(ability) ?? true)
.map((ability) => ({ ability, score: scoreAbility(member, ability, context) }))
.filter((candidate) => candidate.score > 0)
.sort((left, right) => right.score - left.score
|| left.ability.cooldownMs - right.ability.cooldownMs
|| left.ability.id.localeCompare(right.ability.id))[0]?.ability ?? null;
} }
export function commitPartyAbilityUse( export function commitPartyAbilityUse(
@@ -155,7 +214,7 @@ export function partyAbilityAmount(
const coefficient = "coefficient" in effect const coefficient = "coefficient" in effect
? effect.coefficient ? effect.coefficient
: effect.kind === "finisher-damage" : effect.kind === "finisher-damage"
? effect.baseCoefficient + effect.perComboCoefficient * 5 ? effect.baseCoefficient + effect.perComboCoefficient * member.comboPoints
: 0; : 0;
return equipmentAdjustedCombatAmount( return equipmentAdjustedCombatAmount(
"coefficient" in effect ? effect : coefficient, "coefficient" in effect ? effect : coefficient,
+146 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createDungeonParty, usePartyStore, type PartyMember } from "./partyStore"; import { createConfiguredPartyMember, createDungeonParty, usePartyStore, type PartyMember } from "./partyStore";
import { useCombatStore } from "./combatStore"; import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
@@ -76,6 +76,151 @@ describe("NPC sustained damage audit", () => {
expect(useCombatStore.getState().mobs["area:c"].health).toBe(1_000); expect(useCombatStore.getState().mobs["area:c"].health).toBe(1_000);
}); });
it("generates Bear-form Rage from weapon hits and regenerates Cat-form Energy", () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const bear = createConfiguredPartyMember({
name: "Balance Bear",
classId: "druid",
specialization: "Feral (Bear)",
specializationId: "druid:feral-bear",
talentTreeId: "druid-feral-bear",
talentRanks: {},
role: "tank",
}, 80, 0, "feral-runtime");
usePartyStore.setState({ members: [{ ...bear, globalCooldownEndsAt: 60_000 }] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(bear.id, [0, 0, 0]);
useCombatStore.getState().registerMob("feral-target", { name: "Training target", maxHealth: 100_000, xpReward: 0 });
setMobPosition("feral-target", [2, 0, 0]);
useCombatStore.getState().damageMob("feral-target", 1, 100);
updatePartyRuntimeTargetVisibility(bear.id, "feral-target", true);
advanceDungeonPartyCombat(1_000);
expect(usePartyStore.getState().members[0].resourcePools.rage).toBe(6);
const cat = createConfiguredPartyMember({
name: "Balance Cat",
classId: "druid",
specialization: "Feral (Cat)",
specializationId: "druid:feral-cat",
talentTreeId: "druid-feral-cat",
talentRanks: {},
role: "damage",
}, 80, 1, "feral-runtime");
usePartyStore.setState({ members: [{
...cat,
globalCooldownEndsAt: 60_000,
resourcePools: { ...cat.resourcePools, energy: 0 },
}] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(cat.id, [0, 0, 0]);
updatePartyRuntimeTargetVisibility(cat.id, "feral-target", true);
advanceDungeonPartyCombat(2_000);
expect(usePartyStore.getState().members[0].resourcePools.energy).toBe(10);
});
it("finishes companion casts with the learned level rank instead of rank one", () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const mage = createConfiguredPartyMember({
name: "Balance Frost Mage",
classId: "mage",
specialization: "Frost",
specializationId: "mage:frost",
talentTreeId: "mage-frost",
role: "damage",
}, 80, 0, "ranked-cast");
const frostbolt = abilitiesForClass("mage").find((ability) => ability.name === "Frostbolt")!;
usePartyStore.setState({ members: [{
...mage,
cooldowns: Object.fromEntries(abilitiesForClass("mage")
.filter((ability) => ability.id !== frostbolt.id)
.map((ability) => [ability.id, 60_000])),
}] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(mage.id, [-12, 0, 0]);
useCombatStore.getState().registerMob("ranked-target", { name: "Training target", maxHealth: 100_000, xpReward: 0 });
setMobPosition("ranked-target", [0, 0, 0]);
useCombatStore.getState().damageMob("ranked-target", 1, 100);
updatePartyRuntimeTargetVisibility(mage.id, "ranked-target", true);
for (let now = 1_000; now <= 6_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
const hit = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.sourceActorId === mage.id && event.abilityId === frostbolt.id
));
expect(hit?.rawAmount).toBeGreaterThan(700);
});
it("preserves Runic Power generated by companion rune attacks", () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const frost = createConfiguredPartyMember({
name: "Balance Frost Knight",
classId: "death-knight",
specialization: "Frost",
specializationId: "death-knight:frost",
talentTreeId: "death-knight-frost",
role: "damage",
}, 80, 0, "runic-generation");
const icyTouch = abilitiesForClass("death-knight").find((ability) => ability.name === "Icy Touch")!;
usePartyStore.setState({ members: [{
...frost,
cooldowns: Object.fromEntries(abilitiesForClass("death-knight")
.filter((ability) => ability.id !== icyTouch.id)
.map((ability) => [ability.id, 60_000])),
}] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(frost.id, [-12, 0, 0]);
useCombatStore.getState().registerMob("runic-target", { name: "Training target", maxHealth: 100_000, xpReward: 0 });
setMobPosition("runic-target", [0, 0, 0]);
useCombatStore.getState().damageMob("runic-target", 1, 100);
updatePartyRuntimeTargetVisibility(frost.id, "runic-target", true);
advanceDungeonPartyCombat(1_000);
expect(usePartyStore.getState().members[0].resourcePools["runic-power"]).toBe(10);
});
it("casts three-yard melee abilities from the companion melee formation", () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const rogue = createConfiguredPartyMember({
name: "Balance Combat Rogue",
classId: "rogue",
specialization: "Combat",
specializationId: "rogue:combat",
talentTreeId: "rogue-combat",
role: "damage",
}, 80, 0, "melee-reach");
const sinisterStrike = abilitiesForClass("rogue").find((ability) => ability.name === "Sinister Strike")!;
usePartyStore.setState({ members: [{
...rogue,
cooldowns: Object.fromEntries(abilitiesForClass("rogue")
.filter((ability) => ability.id !== sinisterStrike.id)
.map((ability) => [ability.id, 60_000])),
}] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(rogue.id, [0, 0, 0]);
useCombatStore.getState().registerMob("melee-reach-target", { name: "Training target", maxHealth: 100_000, xpReward: 0 });
setMobPosition("melee-reach-target", [3.3, 0, 0]);
useCombatStore.getState().damageMob("melee-reach-target", 1, 100);
updatePartyRuntimeTargetVisibility(rogue.id, "melee-reach-target", true);
advanceDungeonPartyCombat(1_000);
expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({
kind: "damage",
sourceActorId: rogue.id,
abilityId: sinisterStrike.id,
}));
});
for (const level of [1, 3, 5, 20, 80]) { for (const level of [1, 3, 5, 20, 80]) {
it(`executes damaging abilities for every party template at level ${level}`, () => { it(`executes damaging abilities for every party template at level ${level}`, () => {
const templates = partyTemplates(level); const templates = partyTemplates(level);
+166 -1
View File
@@ -19,10 +19,12 @@ import {
partyUsesRangedAttacks, partyUsesRangedAttacks,
resetPartyRuntimeClock, resetPartyRuntimeClock,
} from "./partyRuntime"; } from "./partyRuntime";
import { usePartyStore } from "./partyStore"; import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { EMPTY_ITEM_STATS } from "./itemStats"; import { EMPTY_ITEM_STATS } from "./itemStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
import { abilitiesForClass } from "./abilityCatalog"; import { abilitiesForClass } from "./abilityCatalog";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
function registerEngagedMob(id = "pack:a", maxHealth = 200): void { function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 }); useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
@@ -72,6 +74,169 @@ describe("party combat runtime", () => {
expect(effects.find(effect => effect.targetId === first.targetId)?.nextTickAt).toBe(first.nextTickAt); expect(effects.find(effect => effect.targetId === first.targetId)?.nextTickAt).toBe(first.nextTickAt);
}); });
it("precasts Earth Shield on the tank while the Restoration Shaman is at full health", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const tank = createConfiguredPartyMember({
name: "Tank",
classId: "warrior",
specialization: "Protection",
specializationId: "warrior:protection",
talentTreeId: "warrior-protection",
role: "tank",
}, 80, 0, "earth-shield-runtime");
const healer = createConfiguredPartyMember({
name: "Healer",
classId: "shaman",
specialization: "Restoration",
specializationId: "shaman:restoration",
talentTreeId: "shaman-restoration",
role: "healer",
}, 80, 1, "earth-shield-runtime");
usePartyStore.getState().initializeParty("earth-shield-runtime", 80, useCombatStore.getState().health, "damage");
usePartyStore.setState({ members: [tank, healer] });
registerPartyRuntimePosition(tank.id, [0, 0, 0]);
registerPartyRuntimePosition(healer.id, [-12, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().registerMob("earth-shield-boss", {
name: "Earth Shield boss", boss: true, maxHealth: 100_000, xpReward: 0,
});
setMobPosition("earth-shield-boss", [2, 0, 0]);
useCombatStore.getState().damageMob("earth-shield-boss", 1, 100);
updatePartyRuntimeTargetVisibility(tank.id, "earth-shield-boss", true);
updatePartyRuntimeTargetVisibility(healer.id, "earth-shield-boss", true);
for (const now of [1_000, 2_500, 4_000]) advanceDungeonPartyCombat(now);
expect(useCombatStore.getState().auras.some((aura) => (
aura.sourceId === healer.id
&& aura.targetId === tank.id
&& aura.definition.exclusiveGroup === "shaman:earth-shield"
))).toBe(true);
});
it("turns Feral Spirit into two live Enhancement summons against a boss", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const shaman = createConfiguredPartyMember({
name: "Enhancement",
classId: "shaman",
specialization: "Enhancement",
specializationId: "shaman:enhancement",
talentTreeId: "shaman-enhancement",
role: "damage",
}, 80, 0, "feral-spirit-runtime");
usePartyStore.getState().initializeParty("feral-spirit-runtime", 80, useCombatStore.getState().health, "damage");
usePartyStore.setState({ members: [shaman] });
registerPartyRuntimePosition(shaman.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().registerMob("feral-spirit-boss", {
name: "Feral Spirit boss", boss: true, maxHealth: 100_000, xpReward: 0,
});
setMobPosition("feral-spirit-boss", [2, 0, 0]);
useCombatStore.getState().damageMob("feral-spirit-boss", 1, 100);
updatePartyRuntimeTargetVisibility(shaman.id, "feral-spirit-boss", true);
for (const now of [1_000, 2_500, 4_000, 5_500, 7_000]) advanceDungeonPartyCombat(now);
const feralSpirit = abilitiesForClass("shaman").find((ability) => ability.name === "Feral Spirit")!;
expect(useCombatStore.getState().summons.filter((summon) => (
summon.ownerActorId === shaman.id && summon.sourceAbilityId === feralSpirit.id
))).toHaveLength(2);
const beforeSummonAttack = useCombatStore.getState().mobs["feral-spirit-boss"].health;
useCombatStore.getState().tick(0.1, 10_000);
expect(useCombatStore.getState().mobs["feral-spirit-boss"].health).toBeLessThan(beforeSummonAttack);
});
it("gives companion Hunter and Warlock pets a hostile target when summoned from self", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const hunter = createConfiguredPartyMember({
name: "Beast Mastery",
classId: "hunter",
specialization: "Beast Mastery",
specializationId: "hunter:beast-mastery",
talentTreeId: "hunter-beast-mastery",
role: "damage",
}, 80, 0, "pet-target-runtime");
const warlock = createConfiguredPartyMember({
name: "Demonology",
classId: "warlock",
specialization: "Demonology",
specializationId: "warlock:demonology",
talentTreeId: "warlock-demonology",
talentRanks: runtimePartyTalentRanksForLevel("warlock", "Demonology", 80),
role: "damage",
}, 80, 1, "pet-target-runtime");
usePartyStore.getState().initializeParty("pet-target-runtime", 80, useCombatStore.getState().health, "damage");
usePartyStore.setState({ members: [hunter, warlock] });
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [-14.5, 0, 0]);
useCombatStore.getState().synchronizeActors();
registerEngagedMob("pet-runtime-boss", 100_000);
const events: StructuredCombatEvent[] = [];
const stop = subscribeCombatEvents((event) => events.push(event));
for (let now = 1_000; now <= 30_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
stop();
const pets = useCombatStore.getState().summons.filter((summon) => (
summon.ownerActorId === hunter.id || summon.ownerActorId === warlock.id
));
expect(new Set(pets.map((summon) => summon.ownerActorId))).toEqual(new Set([hunter.id, warlock.id]));
expect(pets.every((summon) => summon.targetId === "pet-runtime-boss")).toBe(true);
expect(events.filter((event) => event.kind === "damage"
&& [hunter.id, warlock.id].includes(event.sourceActorId))).toEqual(expect.arrayContaining([
expect.objectContaining({ abilityId: "wow335-hunter-call-pet" }),
expect.objectContaining({ abilityId: "wow335-warlock-summon-felguard" }),
]));
});
it("casts Power Word: Shield on an injured ally and grants absorb capacity", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const tank = createConfiguredPartyMember({
name: "Tank",
classId: "warrior",
specialization: "Protection",
specializationId: "warrior:protection",
talentTreeId: "warrior-protection",
role: "tank",
}, 80, 0, "discipline-shield-runtime");
const healer = createConfiguredPartyMember({
name: "Discipline",
classId: "priest",
specialization: "Discipline",
specializationId: "priest:discipline",
talentTreeId: "priest-discipline",
role: "healer",
}, 80, 1, "discipline-shield-runtime");
const priestAbilities = abilitiesForClass("priest");
const shield = priestAbilities.find((ability) => ability.name === "Power Word: Shield")!;
usePartyStore.getState().initializeParty("discipline-shield-runtime", 80, useCombatStore.getState().health, "damage");
usePartyStore.setState({
members: [
{ ...tank, health: Math.round(tank.maxHealth * 0.5) },
{
...healer,
resource: 100_000,
maxResource: 100_000,
resourcePools: { mana: 100_000 },
cooldowns: Object.fromEntries(priestAbilities.filter((ability) => ability.id !== shield.id)
.map((ability) => [ability.id, 60_000])),
},
],
});
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
registerEngagedMob("discipline-shield-boss", 100_000);
advanceDungeonPartyCombat(1_000);
const applied = useCombatStore.getState().auras.find((aura) => (
aura.sourceId === healer.id
&& aura.targetId === tank.id
&& aura.definition.name.includes("Power Word: Shield")
));
expect(applied?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBeGreaterThan(0);
});
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => { it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
registerEngagedMob(); registerEngagedMob();
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!; const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more