forked from phenom/healer-man
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a05a79eb83 | ||
|
|
38b3d4d160 | ||
|
|
2dbb0acfc1 | ||
|
|
d6976e6ef6 |
No files matched your search
+4
-4
@@ -131,8 +131,8 @@ services:
|
|||||||
command: >-
|
command: >-
|
||||||
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"
|
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: "1"
|
BALANCE_LAB_MAX_CONCURRENT: "4"
|
||||||
BALANCE_LAB_MAX_QUEUED: "8"
|
BALANCE_LAB_MAX_QUEUED: "100"
|
||||||
BALANCE_LAB_PYTHON: "python3"
|
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
|
||||||
@@ -184,8 +184,8 @@ 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
|
||||||
|
|||||||
@@ -3598,3 +3598,44 @@ Vite's existing large-chunk advisory remains informational.
|
|||||||
errors. Evidence is in
|
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`.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
+10
-2
@@ -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
|
||||||
@@ -653,8 +661,8 @@ services:
|
|||||||
command: >-
|
command: >-
|
||||||
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"
|
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: "1"
|
BALANCE_LAB_MAX_CONCURRENT: "4"
|
||||||
BALANCE_LAB_MAX_QUEUED: "8"
|
BALANCE_LAB_MAX_QUEUED: "100"
|
||||||
BALANCE_LAB_PYTHON: "python3"
|
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
|
||||||
|
|||||||
+59
-24
@@ -56,15 +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, class/spec selection strategy, a searchable spec picker, simulations per
|
a single level or an all-brackets schedule, class/spec selection strategy, a
|
||||||
party/boss, duration, random seed, boss category, and a searchable multi-boss
|
searchable spec picker, simulations per party/boss, duration, random seed, boss
|
||||||
picker. Random mode exposes the requested parties per source; coverage modes
|
category, and a searchable multi-boss picker. The bracket schedules cover level
|
||||||
show the derived minimum party count before submission.
|
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>/
|
||||||
@@ -139,10 +142,12 @@ command installs `python3`, prints `python3 --version` during startup, and sets
|
|||||||
`BALANCE_LAB_PYTHON=python3`; use the exact YAML in `DEPLOYMENT.md` rather than
|
`BALANCE_LAB_PYTHON=python3`; use the exact YAML in `DEPLOYMENT.md` rather than
|
||||||
the older Node-only command.
|
the older Node-only command.
|
||||||
|
|
||||||
`BALANCE_LAB_MAX_CONCURRENT` defaults to `1` and is capped at four.
|
`BALANCE_LAB_MAX_CONCURRENT` defaults to four and is capped at 16.
|
||||||
`BALANCE_LAB_MAX_QUEUED` defaults to eight and is capped at 50. Raising
|
`BALANCE_LAB_MAX_QUEUED` defaults to 100 and is capped at 200, which leaves room
|
||||||
concurrency permits parallel server processes but can make both the game server
|
for an 80-job every-level sweep. Raising concurrency permits more parallel
|
||||||
and simulations less responsive. Requests are capped before launch, subprocess
|
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.
|
||||||
@@ -282,18 +287,26 @@ under `parties/<party-id>/` and adds these suite-level files:
|
|||||||
`totalHealing` and `totalHealingReceived` are the corresponding absolute
|
`totalHealing` and `totalHealingReceived` are the corresponding absolute
|
||||||
values across the included runs. Reports created before target attribution
|
values across the included runs. Reports created before target attribution
|
||||||
was added remain readable but require a rerun to populate healing received.
|
was added remain readable but require a rerun to populate healing received.
|
||||||
- Ability `activeRuns` counts simulations with at least one cast, hit, damage,
|
- Member and ability `absorbs` count shield capacity granted by both direct
|
||||||
healing, overheal, or absorb event. `usageRate` divides that by every run for
|
shield effects and named absorb auras such as Power Word: Shield. Member
|
||||||
that build; `damageShare` divides the ability's effective damage by the
|
`deathEvents` counts each alive-to-dead transition, so a member that is
|
||||||
class/spec's total effective damage. `available · unused` means the ability
|
resurrected and defeated again contributes another death.
|
||||||
was learned and executable at the tested level/talent setup but emitted no
|
- Ability `activeRuns` counts simulations with at least one cast, completed
|
||||||
combat event. This is a review signal, not proof of an AI defect: resurrection,
|
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
|
||||||
@@ -329,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
|
||||||
@@ -343,16 +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
|
||||||
|
allow-list.
|
||||||
- `server/server.mjs`: GM-authenticated runtime-preflight, job, status, and
|
- `server/server.mjs`: GM-authenticated runtime-preflight, job, status, and
|
||||||
artifact HTTP routes.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Combat rotation matrix
|
# Combat rotation matrix
|
||||||
|
|
||||||
Healer Man uses AzerothCore playerbots commit `531282e` as a read-only behavioral reference. The implementation is an independent TypeScript policy over Healer Man's executable catalog, not a port of playerbots C++.
|
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 |
|
| Concern | Playerbots behavior translated into Healer Man |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -16,6 +18,14 @@ Healer Man uses AzerothCore playerbots commit `531282e` as a read-only behaviora
|
|||||||
| Combo points | Builders run below four points; finishers require at least four points on the current target. |
|
| 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. |
|
| 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`.
|
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
|
## Classic specialization profiles
|
||||||
@@ -24,9 +34,9 @@ The leftmost available action wins after reactive rules, learned-talent gating,
|
|||||||
|
|
||||||
| Class | Specialization | Maintenance and core priority |
|
| Class | Specialization | Maintenance and core priority |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Warrior | Arms | Battle Stance; Mortal Strike; Rend; Overpower; Execute; Slam; Heroic Strike |
|
| Warrior | Arms | Battle Stance; Bloodrage; Rend; Mortal Strike; Bladestorm; Overpower; Execute; Slam; Heroic Strike |
|
||||||
| Warrior | Fury | Berserker Stance; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike |
|
| Warrior | Fury | Berserker/Battle Stance; Bloodrage; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike |
|
||||||
| Warrior | Protection | Defensive Stance; Shield Slam; Revenge; Devastate; Thunder Clap; 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 | 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 | 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 |
|
| Paladin | Retribution | Retribution Aura; Blessing of Might; Divine Storm; Crusader Strike; Judgement of Wisdom; Consecration; Exorcism; Hammer of Wrath |
|
||||||
@@ -40,8 +50,8 @@ The leftmost available action wins after reactive rules, learned-talent gating,
|
|||||||
| Priest | Holy | Inner Fire; Circle of Healing; Prayer of Mending; 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 |
|
| 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 | Blood | Frost Presence (Blood Presence fallback); Heart Strike; Death Strike; Icy Touch; Plague Strike; Death Coil |
|
||||||
| Death Knight | Frost | Blood Presence; Howling Blast; Obliterate; Frost Strike; Icy Touch; Plague Strike |
|
| Death Knight | Frost | Blood Presence; Icy Touch; Plague Strike; Howling Blast; Frost Strike; Obliterate |
|
||||||
| Death Knight | Unholy | Unholy Presence (Blood Presence fallback); Raise Dead; Scourge Strike; Death and Decay; Plague Strike; Icy Touch; Death Coil |
|
| 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 | 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 | 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 |
|
| Shaman | Restoration | Water Shield; Rockbiter Weapon; Lightning Shield; Riptide; Chain Heal; Lesser Healing Wave; Healing Wave; Earth Shield |
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.52",
|
"version": "0.1.56",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.52",
|
"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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.52",
|
"version": "0.1.56",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.52",
|
"versionName": "0.1.56",
|
||||||
"versionCode": 1052
|
"versionCode": 1056
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ 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 {
|
||||||
|
abilityById,
|
||||||
abilitiesForCharacter,
|
abilitiesForCharacter,
|
||||||
learnedAbilityForBuild,
|
learnedAbilityForBuild,
|
||||||
preloadAbilityCatalogsForClasses,
|
preloadAbilityCatalogsForClasses,
|
||||||
@@ -17,6 +18,7 @@ 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";
|
||||||
@@ -238,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];
|
||||||
@@ -370,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") {
|
||||||
@@ -414,8 +436,6 @@ async function runBoss(
|
|||||||
} 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;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -449,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,
|
||||||
@@ -479,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,
|
||||||
@@ -531,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();
|
||||||
|
|||||||
@@ -54,8 +54,19 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
|
|||||||
await page.locator('.balance-lab-screen').waitFor();
|
await page.locator('.balance-lab-screen').waitFor();
|
||||||
await page.getByText('Server runtime ready').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(), /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');
|
||||||
|
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');
|
await page.getByLabel('Class/spec selection').selectOption('all');
|
||||||
assert(Number(await page.getByLabel('Coverage parties').inputValue()) > 1, 'all-spec coverage should derive multiple parties');
|
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('Class/spec selection').selectOption('specific');
|
||||||
@@ -70,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(), 7);
|
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);
|
||||||
@@ -117,7 +128,7 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
|
|||||||
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');
|
||||||
@@ -132,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 {
|
||||||
|
|||||||
+131
-12
@@ -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):
|
||||||
|
try:
|
||||||
self._run_command(command, label)
|
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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
+59
-13
@@ -17,6 +17,7 @@ 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 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([
|
||||||
@@ -103,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
|
||||||
@@ -218,14 +231,14 @@ 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
|
const pythonExecutable = String(options.pythonExecutable
|
||||||
?? process.env.BALANCE_LAB_PYTHON
|
?? process.env.BALANCE_LAB_PYTHON
|
||||||
@@ -239,8 +252,14 @@ export function createBalanceLabJobService(options = {}) {
|
|||||||
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");
|
||||||
@@ -414,10 +433,9 @@ export function createBalanceLabJobService(options = {}) {
|
|||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
function requireAvailableRuntime() {
|
||||||
start(input, account) {
|
|
||||||
if (closed) throw new GameDatabaseError("Balance Lab runner is shutting down.", 503, "balance_lab_unavailable");
|
if (closed) throw new GameDatabaseError("Balance Lab runner is shutting down.", 503, "balance_lab_unavailable");
|
||||||
const runtime = runtimeProbe();
|
const runtime = currentRuntime();
|
||||||
if (!runtime.available) {
|
if (!runtime.available) {
|
||||||
throw new GameDatabaseError(
|
throw new GameDatabaseError(
|
||||||
`${runtime.error} Install Python 3 in the server container and set BALANCE_LAB_PYTHON=python3, then restart the app.`,
|
`${runtime.error} Install Python 3 in the server container and set BALANCE_LAB_PYTHON=python3, then restart the app.`,
|
||||||
@@ -425,15 +443,20 @@ export function createBalanceLabJobService(options = {}) {
|
|||||||
"balance_lab_runtime_unavailable",
|
"balance_lab_runtime_unavailable",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireQueueCapacity(count) {
|
||||||
const pending = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length;
|
const pending = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length;
|
||||||
if (pending >= maxQueued) {
|
if (pending + count > maxQueued) {
|
||||||
throw new GameDatabaseError(
|
throw new GameDatabaseError(
|
||||||
"The Balance Lab server queue is full. Wait for a running job to finish.",
|
`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,
|
429,
|
||||||
"balance_lab_queue_full",
|
"balance_lab_queue_full",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const spec = normalizeBalanceLabJobSpec(input);
|
}
|
||||||
|
|
||||||
|
function queueJob(spec, account) {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const outputDir = path.join(jobsDir, id);
|
const outputDir = path.join(jobsDir, id);
|
||||||
mkdirSync(outputDir, { recursive: true });
|
mkdirSync(outputDir, { recursive: true });
|
||||||
@@ -462,9 +485,26 @@ export function createBalanceLabJobService(options = {}) {
|
|||||||
jobs.set(id, job);
|
jobs.set(id, job);
|
||||||
persistJob(job);
|
persistJob(job);
|
||||||
queue.push(job);
|
queue.push(job);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start(input, account) {
|
||||||
|
const spec = normalizeBalanceLabJobSpec(input);
|
||||||
|
requireAvailableRuntime();
|
||||||
|
requireQueueCapacity(1);
|
||||||
|
const job = queueJob(spec, account);
|
||||||
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))
|
||||||
@@ -472,7 +512,13 @@ export function createBalanceLabJobService(options = {}) {
|
|||||||
.map(publicJob);
|
.map(publicJob);
|
||||||
},
|
},
|
||||||
runtime() {
|
runtime() {
|
||||||
return { ...runtimeProbe() };
|
return {
|
||||||
|
...currentRuntime(),
|
||||||
|
maxConcurrent,
|
||||||
|
running,
|
||||||
|
queued: queue.length,
|
||||||
|
maxQueued,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
get(id) {
|
get(id) {
|
||||||
return publicJob(requireJob(id));
|
return publicJob(requireJob(id));
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
balanceLabCommand,
|
balanceLabCommand,
|
||||||
createBalanceLabJobService,
|
createBalanceLabJobService,
|
||||||
inspectBalanceLabRuntime,
|
inspectBalanceLabRuntime,
|
||||||
|
normalizeBalanceLabJobBatch,
|
||||||
normalizeBalanceLabJobSpec,
|
normalizeBalanceLabJobSpec,
|
||||||
} from "./balance-lab-jobs.mjs";
|
} from "./balance-lab-jobs.mjs";
|
||||||
|
|
||||||
@@ -43,6 +44,20 @@ test("normalizes and bounds server Balance Lab requests", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -293,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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -514,6 +514,16 @@ export function createGameServer(options = {}) {
|
|||||||
json(response, 200, { runtime: balanceLabJobs.runtime() });
|
json(response, 200, { runtime: balanceLabJobs.runtime() });
|
||||||
return;
|
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)) {
|
||||||
|
|||||||
+10
-1
@@ -252,7 +252,9 @@ test("serves the game and authenticated cloud-save API", async () => {
|
|||||||
headers: { Authorization: `Bearer ${gmAuth.token}` },
|
headers: { Authorization: `Bearer ${gmAuth.token}` },
|
||||||
});
|
});
|
||||||
assert.equal(balanceLabRuntime.status, 200);
|
assert.equal(balanceLabRuntime.status, 200);
|
||||||
assert.equal((await balanceLabRuntime.json()).runtime.available, true);
|
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}` },
|
||||||
@@ -260,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",
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export interface BalanceLabRuntimeStatus {
|
|||||||
readonly executable: string;
|
readonly executable: string;
|
||||||
readonly version: string | null;
|
readonly version: string | null;
|
||||||
readonly error: string | null;
|
readonly error: string | null;
|
||||||
|
readonly maxConcurrent: number;
|
||||||
|
readonly running: number;
|
||||||
|
readonly queued: number;
|
||||||
|
readonly maxQueued: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BalanceLabJobSpec {
|
export interface BalanceLabJobSpec {
|
||||||
@@ -183,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)}`,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)];
|
||||||
|
}
|
||||||
@@ -219,6 +219,10 @@ export class BenchmarkController {
|
|||||||
comboTargetId: combat.comboTargetId,
|
comboTargetId: combat.comboTargetId,
|
||||||
runes: combat.deathKnightRunes,
|
runes: combat.deathKnightRunes,
|
||||||
abilities: availableAbilities,
|
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,
|
hostileTarget,
|
||||||
friendlyTargets: [
|
friendlyTargets: [
|
||||||
{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, fallen: combat.health <= 0 },
|
{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, fallen: combat.health <= 0 },
|
||||||
|
|||||||
@@ -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(950);
|
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, 950 * 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", () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|||||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
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 });
|
||||||
@@ -150,20 +151,36 @@ describe("named aura combat runtime", () => {
|
|||||||
const summonIds = prepared.summons.map((summon) => summon.id);
|
const summonIds = prepared.summons.map((summon) => summon.id);
|
||||||
useCombatStore.getState().applyPrecombatPreparation(now + 1_000);
|
useCombatStore.getState().applyPrecombatPreparation(now + 1_000);
|
||||||
expect(useCombatStore.getState().summons.map((summon) => summon.id)).toEqual(summonIds);
|
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"
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import {
|
|||||||
import { createDeathKnightRunes } from "./combatActors";
|
import { createDeathKnightRunes } from "./combatActors";
|
||||||
import { aiHintsForAbility } from "./abilityRules";
|
import { aiHintsForAbility } from "./abilityRules";
|
||||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||||
|
import { partyTalentPrimaryTreeId } from "./partyTalents";
|
||||||
import { specializationsForClass } from "./specializationCatalog";
|
import { specializationsForClass } from "./specializationCatalog";
|
||||||
|
import { talentNodeById } from "./talentCatalog";
|
||||||
|
|
||||||
const SIGNATURES = Object.freeze({
|
const SIGNATURES = Object.freeze({
|
||||||
warrior: "Mortal Strike",
|
warrior: "Mortal Strike",
|
||||||
@@ -156,6 +158,26 @@ describe("shared combat rotations", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
it("keeps specialization talent actions isolated", () => {
|
||||||
const furyTalents = runtimePartyTalentRanksForLevel("warrior", "Fury", 80);
|
const furyTalents = runtimePartyTalentRanksForLevel("warrior", "Fury", 80);
|
||||||
const armsTalents = runtimePartyTalentRanksForLevel("warrior", "Arms", 80);
|
const armsTalents = runtimePartyTalentRanksForLevel("warrior", "Arms", 80);
|
||||||
@@ -285,6 +307,99 @@ describe("shared combat rotations", () => {
|
|||||||
.toBe("strike");
|
.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", () => {
|
it("never falls a full-health friendly maintenance heal through to a hostile target", () => {
|
||||||
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
|
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
|
||||||
const prayer = semanticAbility("prayer", [
|
const prayer = semanticAbility("prayer", [
|
||||||
@@ -371,6 +486,45 @@ describe("shared combat rotations", () => {
|
|||||||
.toBe(lightningBolt.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", () => {
|
it("enters a learned Death Knight presence before attacking", () => {
|
||||||
const bloodPresence = learnedAbilityForBuild(ability("death-knight", "Blood Presence"), 1, {})!;
|
const bloodPresence = learnedAbilityForBuild(ability("death-knight", "Blood Presence"), 1, {})!;
|
||||||
const icyTouch = learnedAbilityForBuild(ability("death-knight", "Icy Touch"), 1, {})!;
|
const icyTouch = learnedAbilityForBuild(ability("death-knight", "Icy Touch"), 1, {})!;
|
||||||
|
|||||||
+94
-17
@@ -7,7 +7,7 @@ import { spendDeathKnightRunes } from "./combatResources";
|
|||||||
import { healingAbilityCanAidOthers } from "./healingAbilityRules";
|
import { healingAbilityCanAidOthers } from "./healingAbilityRules";
|
||||||
import type { PartyRole } from "./partyRoles";
|
import type { PartyRole } from "./partyRoles";
|
||||||
|
|
||||||
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v1";
|
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v4";
|
||||||
|
|
||||||
export interface RotationProfile {
|
export interface RotationProfile {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
@@ -51,6 +51,8 @@ export interface RotationContext {
|
|||||||
readonly comboTargetId: string | null;
|
readonly comboTargetId: string | null;
|
||||||
readonly runes: readonly DeathKnightRuneState[];
|
readonly runes: readonly DeathKnightRuneState[];
|
||||||
readonly abilities: readonly AbilityDefinition[];
|
readonly abilities: readonly AbilityDefinition[];
|
||||||
|
/** Learned actions capable of hostile damage, including unavailable ones. */
|
||||||
|
readonly knownDamagingAbilityNames?: ReadonlySet<string>;
|
||||||
readonly hostileTarget: RotationHostileTarget | null;
|
readonly hostileTarget: RotationHostileTarget | null;
|
||||||
readonly friendlyTargets?: readonly RotationFriendlyTarget[];
|
readonly friendlyTargets?: readonly RotationFriendlyTarget[];
|
||||||
readonly hostileCount?: number;
|
readonly hostileCount?: number;
|
||||||
@@ -62,6 +64,8 @@ export interface RotationContext {
|
|||||||
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
|
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
|
||||||
readonly activeSummonAbilityIds?: ReadonlySet<string>;
|
readonly activeSummonAbilityIds?: ReadonlySet<string>;
|
||||||
readonly hasActivePet?: boolean;
|
readonly hasActivePet?: boolean;
|
||||||
|
/** Proc/state-gated actions that the caller has positively made available. */
|
||||||
|
readonly availableConditionalAbilityNames?: ReadonlySet<string>;
|
||||||
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
|
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,9 +90,9 @@ const profile = (
|
|||||||
|
|
||||||
/** Playerbots priorities translated to the executable Healer Man spell model. */
|
/** Playerbots priorities translated to the executable Healer Man spell model. */
|
||||||
export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([
|
export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([
|
||||||
profile("warrior:arms", "warrior", ["Battle Stance", "Mortal Strike", "Rend", "Overpower", "Execute", "Slam", "Heroic Strike"]),
|
profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Bladestorm", "Overpower", "Execute", "Slam", "Heroic Strike"]),
|
||||||
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "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", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "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: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: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("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"]),
|
||||||
@@ -102,13 +106,13 @@ export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.free
|
|||||||
profile("priest:holy", "priest", ["Inner Fire", "Circle of Healing", "Prayer of Mending", "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("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: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", "Howling Blast", "Obliterate", "Frost Strike", "Icy Touch", "Plague Strike"]),
|
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", "Scourge Strike", "Death and Decay", "Plague Strike", "Icy Touch", "Death Coil"]),
|
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: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: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("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: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", "Pyroblast", "Fireball", "Scorch", "Fire Blast"]),
|
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("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: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:demonology", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felguard", "Summon Voidwalker", "Summon Imp", "Metamorphosis", "Immolate", "Corruption", "Shadow Bolt"]),
|
||||||
@@ -123,6 +127,45 @@ const PROFILE_BY_SPECIALIZATION = new Map(
|
|||||||
CLASSIC_ROTATION_PROFILES.map((entry) => [entry.specializationId, entry]),
|
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 {
|
function slug(value: string): string {
|
||||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||||
}
|
}
|
||||||
@@ -303,32 +346,52 @@ function scoreAbility(
|
|||||||
|| (context.hostileTarget?.maxHealth ?? 0) >= context.maxHealth * 5,
|
|| (context.hostileTarget?.maxHealth ?? 0) >= context.maxHealth * 5,
|
||||||
);
|
);
|
||||||
const profileIndex = rotationProfile?.abilityPriority.findIndex((name) => name === ability.name) ?? -1;
|
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")) {
|
if (categories.has("resurrection")) {
|
||||||
const fallen = (context.friendlyTargets ?? []).find((target) => target.fallen);
|
const fallen = (context.friendlyTargets ?? []).find((target) => target.fallen);
|
||||||
return fallen ? { ability, score: 20_000, ruleId: "reactive-resurrection", reason: "fallen ally", targetId: fallen.id } : null;
|
return fallen ? { ability, score: 1_020_000, ruleId: "reactive-resurrection", reason: "fallen ally", targetId: fallen.id } : null;
|
||||||
}
|
}
|
||||||
if (categories.has("dispel")) {
|
if (categories.has("dispel")) {
|
||||||
const dispel = (context.friendlyTargets ?? []).find((target) => target.needsDispel);
|
const dispel = (context.friendlyTargets ?? []).find((target) => target.needsDispel);
|
||||||
if (dispel) return { ability, score: 19_000, ruleId: "reactive-dispel", reason: "ally needs dispel", targetId: dispel.id };
|
if (dispel) return { ability, score: 1_019_000, ruleId: "reactive-dispel", reason: "ally needs dispel", targetId: dispel.id };
|
||||||
if (!damaging) return null;
|
if (!damaging) return null;
|
||||||
}
|
}
|
||||||
if (categories.has("interrupt")) {
|
if (categories.has("interrupt")) {
|
||||||
if (!context.hostileTarget?.activeCast?.interruptible) {
|
if (!context.hostileTarget?.activeCast?.interruptible) {
|
||||||
if (!damaging) return null;
|
if (!damaging) return null;
|
||||||
} else {
|
} else {
|
||||||
return { ability, score: 18_000, ruleId: "reactive-interrupt", reason: "interruptible enemy cast", targetId: context.hostileTarget.id };
|
return { ability, score: 1_018_000, ruleId: "reactive-interrupt", reason: "interruptible enemy cast", targetId: context.hostileTarget.id };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (categories.has("taunt")) {
|
if (categories.has("taunt")) {
|
||||||
if (context.role !== "tank" || !context.targetIsLoose) return null;
|
if (context.role !== "tank" || !context.targetIsLoose) return null;
|
||||||
return { ability, score: 17_000, ruleId: "reactive-taunt", reason: "loose hostile target", targetId: context.hostileTarget?.id ?? 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 (categories.has("threat") && !damaging && !maintenanceBuff) {
|
||||||
if (context.role === "tank" || !context.hasDangerousThreat) return null;
|
if (context.role === "tank" || !context.hasDangerousThreat) return null;
|
||||||
return {
|
return {
|
||||||
ability,
|
ability,
|
||||||
score: 15_000,
|
score: 1_015_000,
|
||||||
ruleId: "reactive-threat-drop",
|
ruleId: "reactive-threat-drop",
|
||||||
reason: "non-tank has enemy attention",
|
reason: "non-tank has enemy attention",
|
||||||
targetId: ability.target === "self" ? null : context.hostileTarget?.id ?? null,
|
targetId: ability.target === "self" ? null : context.hostileTarget?.id ?? null,
|
||||||
@@ -339,9 +402,11 @@ function scoreAbility(
|
|||||||
// the hostile target. Hybrid damage builds otherwise refresh HoTs on enemies
|
// the hostile target. Hybrid damage builds otherwise refresh HoTs on enemies
|
||||||
// forever because those auras also look like maintenance buffs.
|
// forever because those auras also look like maintenance buffs.
|
||||||
const actionableHealing = healingCategory
|
const actionableHealing = healingCategory
|
||||||
&& !(maintenanceBuff && categories.has("resource"))
|
&& !maintenanceBuff
|
||||||
&& !petSummon
|
&& !petSummon
|
||||||
&& !offensiveSummon;
|
&& !offensiveSummon;
|
||||||
|
if (maintenanceBuff && healingCategory
|
||||||
|
&& (ability.target === "friendly" || ability.target === "any") && !lowest) return null;
|
||||||
if (actionableHealing && !damaging) {
|
if (actionableHealing && !damaging) {
|
||||||
const healingTargetHealth = ability.target === "self" ? selfHealth : lowestHealth;
|
const healingTargetHealth = ability.target === "self" ? selfHealth : lowestHealth;
|
||||||
if ((ability.target === "friendly" || ability.target === "any") && !lowest) return null;
|
if ((ability.target === "friendly" || ability.target === "any") && !lowest) return null;
|
||||||
@@ -356,15 +421,25 @@ function scoreAbility(
|
|||||||
// summons are valid player buttons but are not autonomous combat actions.
|
// summons are valid player buttons but are not autonomous combat actions.
|
||||||
if (categories.has("summon") && !damaging && !petSummon && !offensiveSummon && !maintenanceBuff) return null;
|
if (categories.has("summon") && !damaging && !petSummon && !offensiveSummon && !maintenanceBuff) return null;
|
||||||
if (categories.has("buff") && !damaging && !defensive && !maintenanceBuff
|
if (categories.has("buff") && !damaging && !defensive && !maintenanceBuff
|
||||||
&& !petSummon && !offensiveSummon) return null;
|
&& !categories.has("resource") && !petSummon && !offensiveSummon) return null;
|
||||||
if (categories.has("movement") && !damaging && !context.moving) return null;
|
if (categories.has("movement") && !damaging && !context.moving) return null;
|
||||||
if (context.requiredControl && categories.has("control")) {
|
if (context.requiredControl && categories.has("control")) {
|
||||||
return { ability, score: 16_000, ruleId: "reactive-control", reason: "encounter requested control", targetId: context.hostileTarget?.id ?? null };
|
return { ability, score: 1_016_000, ruleId: "reactive-control", reason: "encounter requested control", targetId: context.hostileTarget?.id ?? null };
|
||||||
}
|
}
|
||||||
if (offensiveMajorCooldown && !durableTarget) return null;
|
if (offensiveMajorCooldown && !durableTarget) return null;
|
||||||
if (maintained && (categories.has("debuff") || categories.has("buff"))) return null;
|
if (maintained && (categories.has("debuff") || categories.has("buff"))) return null;
|
||||||
if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null;
|
if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null;
|
||||||
if (rotationProfile && profileIndex < 0 && !damaging && (maintenanceBuff || petSummon || offensiveSummon)) 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 (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;
|
if (isComboBuilder(ability) && context.comboPoints >= 5 && context.comboTargetId === context.hostileTarget?.id) return null;
|
||||||
|
|
||||||
@@ -374,7 +449,9 @@ function scoreAbility(
|
|||||||
const maintenanceScore = rotationProfile === null ? 5_200 : profileIndex >= 0 ? 7_000 : 2_000;
|
const maintenanceScore = rotationProfile === null ? 5_200 : profileIndex >= 0 ? 7_000 : 2_000;
|
||||||
const summonScore = rotationProfile === null ? 4_800 : profileIndex >= 0 ? 6_500 : 1_800;
|
const summonScore = rotationProfile === null ? 4_800 : profileIndex >= 0 ? 6_500 : 1_800;
|
||||||
if (profileIndex >= 0) {
|
if (profileIndex >= 0) {
|
||||||
score += 4_000 - profileIndex * 120;
|
score += context.role === "healer" || context.level < 10
|
||||||
|
? 4_000 - profileIndex * 120
|
||||||
|
: 100_000 - profileIndex * 5_000;
|
||||||
ruleId = `profile-${slug(ability.name)}`;
|
ruleId = `profile-${slug(ability.name)}`;
|
||||||
reason = `${rotationProfile!.specializationId} priority ${profileIndex + 1}`;
|
reason = `${rotationProfile!.specializationId} priority ${profileIndex + 1}`;
|
||||||
}
|
}
|
||||||
@@ -407,7 +484,7 @@ function scoreAbility(
|
|||||||
} else {
|
} else {
|
||||||
if (categories.has("mitigation")) {
|
if (categories.has("mitigation")) {
|
||||||
if (!maintenanceBuff && selfHealth >= 0.55) return null;
|
if (!maintenanceBuff && selfHealth >= 0.55) return null;
|
||||||
if (selfHealth < 0.55) score += selfHealth < 0.35 ? 8_000 : 3_000;
|
if (selfHealth < 0.55) score += selfHealth < 0.35 ? 180_000 : 140_000;
|
||||||
}
|
}
|
||||||
if (categories.has("execute")) {
|
if (categories.has("execute")) {
|
||||||
if (targetHealth > 0.2) return null;
|
if (targetHealth > 0.2) return null;
|
||||||
|
|||||||
+58
-16
@@ -5286,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 (
|
||||||
@@ -6278,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 };
|
||||||
@@ -6316,12 +6334,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
set({
|
let combatEvents = recordCombatEvent(state.combatEvents, {
|
||||||
auras: result.auras,
|
|
||||||
health,
|
|
||||||
maxHealth,
|
|
||||||
actors,
|
|
||||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
|
||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
kind: "aura",
|
kind: "aura",
|
||||||
sourceActorId,
|
sourceActorId,
|
||||||
@@ -6333,7 +6346,28 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
overheal: 0,
|
overheal: 0,
|
||||||
absorbed: 0,
|
absorbed: 0,
|
||||||
critical: false,
|
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({
|
||||||
|
auras: result.auras,
|
||||||
|
health,
|
||||||
|
maxHealth,
|
||||||
|
actors,
|
||||||
|
combatEvents,
|
||||||
});
|
});
|
||||||
if (companionActor) mirrorCompanionActor(companionActor);
|
if (companionActor) mirrorCompanionActor(companionActor);
|
||||||
return true;
|
return true;
|
||||||
@@ -8330,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 },
|
||||||
@@ -8374,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) {
|
||||||
@@ -8392,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(
|
||||||
@@ -8406,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)) {
|
||||||
|
|||||||
@@ -100,9 +100,9 @@ describe("dungeon boss durability and difficulty", () => {
|
|||||||
|
|
||||||
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: 1365 });
|
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(1365 * 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);
|
||||||
@@ -117,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", () => {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ 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,
|
// The starter anchors include enough room for authentic presence, imbue,
|
||||||
// disease, and shock rotations to retain the 25/45-second mechanic window.
|
// disease, and shock rotations to retain the 25/45-second mechanic window.
|
||||||
[2, 950],
|
[2, 1_050],
|
||||||
[4, 1_365],
|
[4, 1_500],
|
||||||
[9, 7_000],
|
[9, 7_000],
|
||||||
[21, 11_000],
|
[21, 11_000],
|
||||||
[41, 23_000],
|
[41, 23_000],
|
||||||
@@ -25,6 +25,7 @@ const WOW_BOSS_TEST_HEALTH_FLOOR: readonly (readonly [level: number, health: num
|
|||||||
[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);
|
||||||
@@ -67,7 +68,7 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
|
|||||||
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, testing);
|
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.
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
@@ -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")!;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
|
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
|
||||||
import {
|
import {
|
||||||
evaluateCombatRotation,
|
evaluateCombatRotation,
|
||||||
|
rotationProfileFor,
|
||||||
type RotationDecision,
|
type RotationDecision,
|
||||||
type RotationFriendlyTarget,
|
type RotationFriendlyTarget,
|
||||||
} from "./combatRotation";
|
} from "./combatRotation";
|
||||||
@@ -60,11 +61,46 @@ 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 abilitiesForCharacter(member.classId, member.secondaryClassId)
|
return abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||||
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
|
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
|
||||||
.filter((ability): ability is AbilityDefinition => ability !== null)
|
.filter((ability): ability is AbilityDefinition => ability !== null);
|
||||||
.filter((ability) => partyAbilityIsReady(member, ability, now));
|
}
|
||||||
|
|
||||||
|
function isComboFinisher(ability: AbilityDefinition): boolean {
|
||||||
|
return ability.effects.some((effect) => effect.kind === "finisher-damage"
|
||||||
|
|| (effect.kind === "combo" && effect.amount < 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESERVED_SIGNATURE_ACTIONS = new Set(["Mortal Strike"]);
|
||||||
|
|
||||||
|
function shouldReserveForHigherPriority(
|
||||||
|
member: PartyMember,
|
||||||
|
learned: readonly AbilityDefinition[],
|
||||||
|
decision: RotationDecision,
|
||||||
|
now: number,
|
||||||
|
): boolean {
|
||||||
|
const rotation = rotationProfileFor(member.classId, member.specializationId, member.specialization);
|
||||||
|
const decisionIndex = rotation?.abilityPriority.indexOf(decision.ability.name) ?? -1;
|
||||||
|
if (!rotation || decisionIndex <= 0) return false;
|
||||||
|
return rotation.abilityPriority.slice(0, decisionIndex).some((name) => {
|
||||||
|
const candidate = learned.find((ability) => ability.name === name);
|
||||||
|
if (!candidate || candidate.passive || isComboFinisher(candidate)) return false;
|
||||||
|
if (!RESERVED_SIGNATURE_ACTIONS.has(candidate.name)) return false;
|
||||||
|
if ((member.cooldowns[candidate.id] ?? 0) > now) return false;
|
||||||
|
if ((member.schoolLockedUntil[candidate.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
||||||
|
const categories = aiHintsForAbility(candidate).categories;
|
||||||
|
if (!categories.includes("damage") || categories.includes("execute")) return false;
|
||||||
|
const blockedCosts = abilityCosts(member, candidate).filter((cost) => (
|
||||||
|
cost.amount > currentResource(member, cost.resource)
|
||||||
|
));
|
||||||
|
if (!blockedCosts.length) return false;
|
||||||
|
return blockedCosts.some((cost) => !decision.ability.effects.some((effect) => (
|
||||||
|
effect.kind === "resource"
|
||||||
|
&& (effect.resource ?? member.resourceType) === cost.resource
|
||||||
|
&& effect.amount > 0
|
||||||
|
)));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function choosePartyRotationDecision(
|
export function choosePartyRotationDecision(
|
||||||
@@ -85,7 +121,8 @@ export function choosePartyRotationDecision(
|
|||||||
[...new Set([...Object.keys(member.resourcePools), member.resourceType])]
|
[...new Set([...Object.keys(member.resourcePools), member.resourceType])]
|
||||||
.map((resource) => [resource, companionResourceMaximum(member, resource)]),
|
.map((resource) => [resource, companionResourceMaximum(member, resource)]),
|
||||||
);
|
);
|
||||||
return evaluateCombatRotation({
|
const learned = learnedAbilities(member);
|
||||||
|
const decision = evaluateCombatRotation({
|
||||||
now: context.now,
|
now: context.now,
|
||||||
classId: member.classId,
|
classId: member.classId,
|
||||||
secondaryClassId: member.secondaryClassId,
|
secondaryClassId: member.secondaryClassId,
|
||||||
@@ -102,7 +139,11 @@ export function choosePartyRotationDecision(
|
|||||||
comboPoints: member.comboPoints,
|
comboPoints: member.comboPoints,
|
||||||
comboTargetId: member.comboTargetId,
|
comboTargetId: member.comboTargetId,
|
||||||
runes: member.runes,
|
runes: member.runes,
|
||||||
abilities: candidateAbilities(member, context.now),
|
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,
|
hostileTarget: context.target,
|
||||||
friendlyTargets,
|
friendlyTargets,
|
||||||
hostileCount: context.hostileCount,
|
hostileCount: context.hostileCount,
|
||||||
@@ -115,6 +156,9 @@ export function choosePartyRotationDecision(
|
|||||||
hasActivePet: context.hasActivePet,
|
hasActivePet: context.hasActivePet,
|
||||||
canUseAbility: context.canUseAbility,
|
canUseAbility: context.canUseAbility,
|
||||||
});
|
});
|
||||||
|
return decision && shouldReserveForHigherPriority(member, learned, decision, context.now)
|
||||||
|
? null
|
||||||
|
: decision;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function choosePartyAbility(
|
export function choosePartyAbility(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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")!;
|
||||||
|
|||||||
+113
-21
@@ -18,7 +18,7 @@ import {
|
|||||||
} from "./abilityRules";
|
} from "./abilityRules";
|
||||||
import type { MobCombatState } from "./combatStore";
|
import type { MobCombatState } from "./combatStore";
|
||||||
import { useCombatStore } from "./combatStore";
|
import { useCombatStore } from "./combatStore";
|
||||||
import { memberFromCompanionActor } from "./combatActorAuthority";
|
import { companionResourceMaximum, memberFromCompanionActor } from "./combatActorAuthority";
|
||||||
import type { MobVector3 } from "./mobPopulation";
|
import type { MobVector3 } from "./mobPopulation";
|
||||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||||
import { getMobPosition } from "./mobRuntimeRegistry";
|
import { getMobPosition } from "./mobRuntimeRegistry";
|
||||||
@@ -713,9 +713,13 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu
|
|||||||
targetActorId: targetId, abilityId, name: "Auto Attack", source: "fallback", school: "physical",
|
targetActorId: targetId, abilityId, name: "Auto Attack", source: "fallback", school: "physical",
|
||||||
delivery: baseClassFor(member.classId) === "hunter" ? "projectile" : identity.delivery, origin, targetPosition, instantCast: true });
|
delivery: baseClassFor(member.classId) === "hunter" ? "projectile" : identity.delivery, origin, targetPosition, instantCast: true });
|
||||||
}
|
}
|
||||||
if (member.resourceType !== "rage" || result.amount <= 0) return member;
|
if (member.resourcePools.rage === undefined || result.amount <= 0) return member;
|
||||||
const resource = Math.min(member.maxResource, member.resource + 6);
|
const rage = Math.min(companionResourceMaximum(member, "rage"), member.resourcePools.rage + 6);
|
||||||
return { ...member, resource, resourcePools: { ...member.resourcePools, rage: resource } };
|
return {
|
||||||
|
...member,
|
||||||
|
resource: member.resourceType === "rage" ? rage : member.resource,
|
||||||
|
resourcePools: { ...member.resourcePools, rage },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function partyFriendlyPosition(actorId: string): MobVector3 | null {
|
function partyFriendlyPosition(actorId: string): MobVector3 | null {
|
||||||
@@ -741,9 +745,16 @@ function partyAbilityTargetIsValid(
|
|||||||
&& !ability.effects.some((effect) => effect.kind === "resurrection")) return false;
|
&& !ability.effects.some((effect) => effect.kind === "resurrection")) return false;
|
||||||
const distance = Math.sqrt(distanceSquared(origin, targetPosition));
|
const distance = Math.sqrt(distanceSquared(origin, targetPosition));
|
||||||
const targeting = targetingRulesForAbility(ability);
|
const targeting = targetingRulesForAbility(ability);
|
||||||
const maximumRange = hostile && ability.target === "self"
|
const catalogMaximumRange = hostile && ability.target === "self"
|
||||||
? targeting.radius ?? ability.radius ?? ability.range.max
|
? targeting.radius ?? ability.radius ?? ability.range.max
|
||||||
: ability.range.max;
|
: ability.range.max;
|
||||||
|
// Several stock melee SkillLine rows use a three-yard DBC range while the
|
||||||
|
// companion formation deliberately settles just outside that distance.
|
||||||
|
// Apply the same combat-reach leeway used by companion weapon swings so
|
||||||
|
// builders, diseases, bleeds, and finishers remain usable in formation.
|
||||||
|
const maximumRange = hostile && catalogMaximumRange > 0 && catalogMaximumRange <= 5
|
||||||
|
? Math.max(catalogMaximumRange, partyAttackRange(member))
|
||||||
|
: catalogMaximumRange;
|
||||||
if (distance < ability.range.min || distance > maximumRange || Math.abs(origin[1] - targetPosition[1]) > 8) return false;
|
if (distance < ability.range.min || distance > maximumRange || Math.abs(origin[1] - targetPosition[1]) > 8) return false;
|
||||||
if (targeting.requiresLineOfSight && !combatHasLineOfSight(origin, targetPosition)) return false;
|
if (targeting.requiresLineOfSight && !combatHasLineOfSight(origin, targetPosition)) return false;
|
||||||
if (hostile && targeting.requiresFacing) {
|
if (hostile && targeting.requiresFacing) {
|
||||||
@@ -901,6 +912,45 @@ function choosePartySupportIntent(
|
|||||||
return { ability, targetId: dispelTargetId };
|
return { ability, targetId: dispelTargetId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Preventive damage-taken procs (Earth Shield / Prayer of Mending-shaped
|
||||||
|
// effects) belong on the tank before damage lands. Normal HoTs and direct
|
||||||
|
// heals still wait for the injured-target path below.
|
||||||
|
const preventiveTarget = members
|
||||||
|
.filter((candidate) => candidate.health > 0)
|
||||||
|
.sort((left, right) => (
|
||||||
|
Number(right.role === "tank") - Number(left.role === "tank")
|
||||||
|
|| left.id.localeCompare(right.id)
|
||||||
|
))[0];
|
||||||
|
if (preventiveTarget) {
|
||||||
|
const maintainedAbilityIds = new Set(combat.auras
|
||||||
|
.filter((aura) => aura.sourceId === member.id && (aura.expiresAt === null || aura.expiresAt > now))
|
||||||
|
.flatMap((aura) => abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||||
|
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
|
||||||
|
.filter((ability): ability is AbilityDefinition => ability !== null)
|
||||||
|
.filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||||
|
&& effect.aura.id === aura.definition.id))
|
||||||
|
.map((ability) => ability.id)));
|
||||||
|
const activeAuraExclusiveGroups = new Set(combat.auras
|
||||||
|
.filter((aura) => aura.sourceId === member.id && (aura.expiresAt === null || aura.expiresAt > now))
|
||||||
|
.flatMap((aura) => aura.definition.exclusiveGroup ? [aura.definition.exclusiveGroup] : []));
|
||||||
|
const preventive = choosePartyAbility(member, {
|
||||||
|
now,
|
||||||
|
target: null,
|
||||||
|
friendlyTargets: [{
|
||||||
|
id: preventiveTarget.id,
|
||||||
|
health: preventiveTarget.health,
|
||||||
|
maxHealth: preventiveTarget.maxHealth,
|
||||||
|
}],
|
||||||
|
maintainedAbilityIds,
|
||||||
|
activeAuraExclusiveGroups,
|
||||||
|
canUseAbility: ability => (ability.target === "friendly" || ability.target === "any")
|
||||||
|
&& ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||||
|
&& effect.aura.disposition === "buff"
|
||||||
|
&& effect.aura.procs?.some((proc) => proc.triggers === "damage-taken"))
|
||||||
|
&& partyAbilityTargetIsValid(member, ability, preventiveTarget.id),
|
||||||
|
});
|
||||||
|
if (preventive) return { ability: preventive, targetId: preventiveTarget.id };
|
||||||
|
}
|
||||||
const injured = [
|
const injured = [
|
||||||
...(!simulatedPartyExcludesPlayer() && combat.health > 0 && combat.health < combat.maxHealth
|
...(!simulatedPartyExcludesPlayer() && combat.health > 0 && combat.health < combat.maxHealth
|
||||||
? [{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, role: "healer" as const }]
|
? [{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, role: "healer" as const }]
|
||||||
@@ -922,7 +972,8 @@ function choosePartySupportIntent(
|
|||||||
health: candidate.health,
|
health: candidate.health,
|
||||||
maxHealth: candidate.maxHealth,
|
maxHealth: candidate.maxHealth,
|
||||||
})),
|
})),
|
||||||
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind))
|
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind)
|
||||||
|
|| (effect.kind === "apply-aura" && Boolean(effect.aura.absorbs?.length)))
|
||||||
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
|
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
|
||||||
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
|
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
|
||||||
&& (!ability.effects.some(effect => effect.kind === "heal") || target.health / target.maxHealth > 0.5))
|
&& (!ability.effects.some(effect => effect.kind === "heal") || target.health / target.maxHealth > 0.5))
|
||||||
@@ -1085,6 +1136,23 @@ function executePartyAbility(
|
|||||||
ability.id,
|
ability.id,
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
|
// Resource restoration writes through the authoritative actor store.
|
||||||
|
// Mirror only its resource fields: the actor has not received this
|
||||||
|
// action's cooldown/GCD yet, so a full actor-to-member copy would erase
|
||||||
|
// the committed action state and let generators such as Bloodrage spam.
|
||||||
|
if (friendlyTargetId === member.id) {
|
||||||
|
const resourceMirror = memberFromCompanionActor(
|
||||||
|
runtimeMember,
|
||||||
|
useCombatStore.getState().actors[member.id],
|
||||||
|
);
|
||||||
|
runtimeMember = {
|
||||||
|
...runtimeMember,
|
||||||
|
resource: resourceMirror.resource,
|
||||||
|
maxResource: resourceMirror.maxResource,
|
||||||
|
resourcePools: resourceMirror.resourcePools,
|
||||||
|
lastResourceSpentAt: resourceMirror.lastResourceSpentAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (effect.kind === "combo") {
|
} else if (effect.kind === "combo") {
|
||||||
const current = runtimeMember.comboTargetId === targetId ? runtimeMember.comboPoints : 0;
|
const current = runtimeMember.comboTargetId === targetId ? runtimeMember.comboPoints : 0;
|
||||||
@@ -1095,7 +1163,12 @@ function executePartyAbility(
|
|||||||
comboTargetId: comboPoints > 0 ? targetId : null,
|
comboTargetId: comboPoints > 0 ? targetId : null,
|
||||||
};
|
};
|
||||||
} else if (effect.kind === "summon") {
|
} else if (effect.kind === "summon") {
|
||||||
useCombatStore.getState().summonActor(member.id, ability, effect, origin, targetId, now);
|
const summonTargetId = combat.mobs[targetId] && !combat.mobs[targetId].dead
|
||||||
|
? targetId
|
||||||
|
: combat.selectedTargetId && combat.mobs[combat.selectedTargetId] && !combat.mobs[combat.selectedTargetId].dead
|
||||||
|
? combat.selectedTargetId
|
||||||
|
: Object.values(combat.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
|
||||||
|
useCombatStore.getState().summonActor(member.id, ability, effect, origin, summonTargetId, now);
|
||||||
} else if (effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) {
|
} else if (effect.kind === "pet" && ["summon", "call", "resurrect", "tame"].includes(effect.action)) {
|
||||||
const summonEffect: Extract<AbilityEffect, { kind: "summon" }> = {
|
const summonEffect: Extract<AbilityEffect, { kind: "summon" }> = {
|
||||||
kind: "summon",
|
kind: "summon",
|
||||||
@@ -1103,7 +1176,12 @@ function executePartyAbility(
|
|||||||
coefficient: 0.35,
|
coefficient: 0.35,
|
||||||
durationMs: Number.MAX_SAFE_INTEGER - now,
|
durationMs: Number.MAX_SAFE_INTEGER - now,
|
||||||
};
|
};
|
||||||
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, targetId, now);
|
const summonTargetId = combat.mobs[targetId] && !combat.mobs[targetId].dead
|
||||||
|
? targetId
|
||||||
|
: combat.selectedTargetId && combat.mobs[combat.selectedTargetId] && !combat.mobs[combat.selectedTargetId].dead
|
||||||
|
? combat.selectedTargetId
|
||||||
|
: Object.values(combat.mobs).find((mob) => mob.engaged && !mob.dead)?.id ?? null;
|
||||||
|
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, summonTargetId, now);
|
||||||
} else if (effect.kind === "movement") {
|
} else if (effect.kind === "movement") {
|
||||||
const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
|
const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
|
||||||
if (targetPosition && effect.movement !== "knockback" && effect.toward !== "away") {
|
if (targetPosition && effect.movement !== "knockback" && effect.toward !== "away") {
|
||||||
@@ -1207,26 +1285,37 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
);
|
);
|
||||||
members = members.map((member) => {
|
members = members.map((member) => {
|
||||||
const profile = resourceProfileForClass(member.classId);
|
const profile = resourceProfileForClass(member.classId);
|
||||||
const regeneration = (profile.type === "rage" || profile.type === "runic-power")
|
const passiveRates: Partial<Record<ResourceType, number>> = {
|
||||||
? 0
|
mana: 4,
|
||||||
: profile.regenerationPerSecond + member.gearStats.mp5 / 5 + member.gearStats.spirit * 0.02;
|
rage: 0,
|
||||||
const pool = advanceCombatResourcePool(normalizeActorResourcePool(
|
energy: 10,
|
||||||
member.resourceType,
|
focus: 6,
|
||||||
member.resource,
|
"runic-power": 0,
|
||||||
member.maxResource,
|
};
|
||||||
|
const resourcePools: Partial<Record<ResourceType, number>> = { ...member.resourcePools };
|
||||||
|
for (const type of Object.keys(resourcePools) as ResourceType[]) {
|
||||||
|
const manaRegeneration = member.gearStats.mp5 / 5 + member.gearStats.spirit * 0.02;
|
||||||
|
const regeneration = type === member.resourceType
|
||||||
|
? (profile.type === "rage" || profile.type === "runic-power" ? 0 : profile.regenerationPerSecond + manaRegeneration)
|
||||||
|
: (passiveRates[type] ?? 0) + (type === "mana" ? manaRegeneration : 0);
|
||||||
|
resourcePools[type] = advanceCombatResourcePool(normalizeActorResourcePool(
|
||||||
|
type,
|
||||||
|
resourcePools[type] ?? 0,
|
||||||
|
companionResourceMaximum(member, type),
|
||||||
regeneration,
|
regeneration,
|
||||||
member.lastResourceSpentAt[member.resourceType] ?? 0,
|
member.lastResourceSpentAt[type] ?? 0,
|
||||||
), {
|
), {
|
||||||
inCombat: Boolean(targetId),
|
inCombat: Boolean(targetId),
|
||||||
actorLevel: member.level,
|
actorLevel: member.level,
|
||||||
now,
|
now,
|
||||||
deltaSeconds,
|
deltaSeconds,
|
||||||
manaSpiritRegenerationPerSecond: member.gearStats.spirit * 0.02,
|
manaSpiritRegenerationPerSecond: type === "mana" ? member.gearStats.spirit * 0.02 : 0,
|
||||||
});
|
}).current;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
resource: pool.current,
|
resource: resourcePools[member.resourceType] ?? member.resource,
|
||||||
resourcePools: { ...member.resourcePools, [member.resourceType]: pool.current },
|
resourcePools,
|
||||||
cooldowns: Object.fromEntries(Object.entries(member.cooldowns).filter(([, readyAt]) => readyAt > now)),
|
cooldowns: Object.fromEntries(Object.entries(member.cooldowns).filter(([, readyAt]) => readyAt > now)),
|
||||||
schoolLockedUntil: Object.fromEntries(Object.entries(member.schoolLockedUntil).filter(([, readyAt]) => (readyAt ?? 0) > now)),
|
schoolLockedUntil: Object.fromEntries(Object.entries(member.schoolLockedUntil).filter(([, readyAt]) => (readyAt ?? 0) > now)),
|
||||||
};
|
};
|
||||||
@@ -1291,7 +1380,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
|||||||
const currentCombat = useCombatStore.getState();
|
const currentCombat = useCombatStore.getState();
|
||||||
if (member.activeCast) {
|
if (member.activeCast) {
|
||||||
const activeCast = member.activeCast;
|
const activeCast = member.activeCast;
|
||||||
const castingAbility = abilityById(member.activeCast.abilityId);
|
const catalogAbility = abilityById(member.activeCast.abilityId);
|
||||||
|
const castingAbility = catalogAbility
|
||||||
|
? learnedAbilityForBuild(catalogAbility, member.level, member.talentRanks)
|
||||||
|
: null;
|
||||||
const castTargetId = member.activeCastTargetId;
|
const castTargetId = member.activeCastTargetId;
|
||||||
if (!castingAbility || !castTargetId) {
|
if (!castingAbility || !castTargetId) {
|
||||||
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null };
|
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null };
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "../app/characterCatalog";
|
} from "../app/characterCatalog";
|
||||||
import {
|
import {
|
||||||
createDungeonParty,
|
createDungeonParty,
|
||||||
|
createConfiguredPartyMember,
|
||||||
isHealerSpecialization,
|
isHealerSpecialization,
|
||||||
isTankSpecialization,
|
isTankSpecialization,
|
||||||
usePartyStore,
|
usePartyStore,
|
||||||
@@ -74,6 +75,30 @@ describe("dungeon party composition", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates the secondary Rage and Energy pools used by druid forms", () => {
|
||||||
|
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-resources");
|
||||||
|
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-resources");
|
||||||
|
|
||||||
|
expect(bear.resourcePools).toMatchObject({ mana: bear.maxResource, rage: 0, energy: 100 });
|
||||||
|
expect(cat.resourcePools).toMatchObject({ mana: cat.maxResource, rage: 0, energy: 100 });
|
||||||
|
});
|
||||||
|
|
||||||
it("is deterministic per character and scales every ally to the player level", () => {
|
it("is deterministic per character and scales every ally to the player level", () => {
|
||||||
const first = createDungeonParty("character-42", 18);
|
const first = createDungeonParty("character-42", 18);
|
||||||
const second = createDungeonParty("character-42", 18);
|
const second = createDungeonParty("character-42", 18);
|
||||||
|
|||||||
+51
-9
@@ -31,7 +31,12 @@ import type { RuntimeTalentRanks as TalentRanks } from "./talentRuntimeCatalog";
|
|||||||
import type { EnemyControlMechanic } from "./enemyAttacks";
|
import type { EnemyControlMechanic } from "./enemyAttacks";
|
||||||
import type { ContentCategoryId } from "../app/contentCategories";
|
import type { ContentCategoryId } from "../app/contentCategories";
|
||||||
import type { PartyRole, PartySize } from "./partyRoles";
|
import type { PartyRole, PartySize } from "./partyRoles";
|
||||||
import { resourceProfileForClass, type ResourceType } from "./abilityCatalog";
|
import {
|
||||||
|
abilitiesForCharacter,
|
||||||
|
learnedAbilityForBuild,
|
||||||
|
resourceProfileForClass,
|
||||||
|
type ResourceType,
|
||||||
|
} from "./abilityCatalog";
|
||||||
import { createDeathKnightRunes, type CombatActorCastState, type DeathKnightRuneState } from "./combatActors";
|
import { createDeathKnightRunes, type CombatActorCastState, type DeathKnightRuneState } from "./combatActors";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -222,6 +227,43 @@ function memberMaxResource(
|
|||||||
: profile.maximum;
|
: profile.maximum;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function secondaryResourceMaximum(type: ResourceType): number {
|
||||||
|
if (type === "nature-power") return 10;
|
||||||
|
if (type === "psi") return 6;
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialSecondaryResource(type: ResourceType, maximum: number): number {
|
||||||
|
return type === "rage" || type === "runic-power" || type === "nature-power" || type === "psi"
|
||||||
|
? 0
|
||||||
|
: maximum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps every resource used by the member's learned kit available to companion AI. */
|
||||||
|
function withMemberAbilityResourcePools(member: PartyMember): PartyMember {
|
||||||
|
const types = new Set<ResourceType>([member.resourceType]);
|
||||||
|
for (const ability of abilitiesForCharacter(member.classId, member.secondaryClassId)) {
|
||||||
|
const learned = learnedAbilityForBuild(ability, member.level, member.talentRanks);
|
||||||
|
if (!learned) continue;
|
||||||
|
for (const cost of learned.costs?.length ? learned.costs : [learned.cost]) {
|
||||||
|
if (cost.resource !== "health") types.add(cost.resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resourcePools: Partial<Record<ResourceType, number>> = {};
|
||||||
|
for (const type of types) {
|
||||||
|
if (type === member.resourceType) {
|
||||||
|
resourcePools[type] = member.resource;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const maximum = secondaryResourceMaximum(type);
|
||||||
|
resourcePools[type] = Math.max(0, Math.min(
|
||||||
|
maximum,
|
||||||
|
member.resourcePools[type] ?? initialSecondaryResource(type, maximum),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return { ...member, resourcePools: Object.freeze(resourcePools) };
|
||||||
|
}
|
||||||
|
|
||||||
function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMember {
|
function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMember {
|
||||||
const maxHealth = memberMaxHealth(
|
const maxHealth = memberMaxHealth(
|
||||||
member.level,
|
member.level,
|
||||||
@@ -237,7 +279,7 @@ function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMe
|
|||||||
const resource = Math.max(0, Math.round(
|
const resource = Math.max(0, Math.round(
|
||||||
member.resource / Math.max(1, member.maxResource) * maxResource,
|
member.resource / Math.max(1, member.maxResource) * maxResource,
|
||||||
));
|
));
|
||||||
return {
|
return withMemberAbilityResourcePools({
|
||||||
...member,
|
...member,
|
||||||
gearStats,
|
gearStats,
|
||||||
health,
|
health,
|
||||||
@@ -245,7 +287,7 @@ function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMe
|
|||||||
resource,
|
resource,
|
||||||
maxResource,
|
maxResource,
|
||||||
resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }),
|
resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMember(
|
function createMember(
|
||||||
@@ -286,7 +328,7 @@ function createMember(
|
|||||||
const resource = Math.round(
|
const resource = Math.round(
|
||||||
maxResource * (resourceProfile.maximum > 0 ? resourceProfile.initial / resourceProfile.maximum : 0),
|
maxResource * (resourceProfile.maximum > 0 ? resourceProfile.initial / resourceProfile.maximum : 0),
|
||||||
);
|
);
|
||||||
return {
|
return withMemberAbilityResourcePools({
|
||||||
id,
|
id,
|
||||||
name: template.name,
|
name: template.name,
|
||||||
raceId: race.id,
|
raceId: race.id,
|
||||||
@@ -323,7 +365,7 @@ function createMember(
|
|||||||
tauntReadyAt: 0,
|
tauntReadyAt: 0,
|
||||||
controlledUntil: 0,
|
controlledUntil: 0,
|
||||||
controlMechanic: null,
|
controlMechanic: null,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates one exact companion while retaining the live party stat/equipment path. */
|
/** Creates one exact companion while retaining the live party stat/equipment path. */
|
||||||
@@ -339,12 +381,12 @@ export function createConfiguredPartyMember(
|
|||||||
specialization: configuration.specialization,
|
specialization: configuration.specialization,
|
||||||
talentTreeId: configuration.talentTreeId ?? configuration.specializationId ?? "",
|
talentTreeId: configuration.talentTreeId ?? configuration.specializationId ?? "",
|
||||||
}, configuration.role, level, index, seed, configuration.talentRanks);
|
}, configuration.role, level, index, seed, configuration.talentRanks);
|
||||||
return Object.freeze({
|
return Object.freeze(withMemberAbilityResourcePools({
|
||||||
...member,
|
...member,
|
||||||
secondaryClassId: configuration.secondaryClassId ?? null,
|
secondaryClassId: configuration.secondaryClassId ?? null,
|
||||||
specializationId: configuration.specializationId ?? member.specializationId,
|
specializationId: configuration.specializationId ?? member.specializationId,
|
||||||
talentRanks: configuration.talentRanks ?? member.talentRanks,
|
talentRanks: configuration.talentRanks ?? member.talentRanks,
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -571,7 +613,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
|
|||||||
const resource = Math.max(0, Math.round(
|
const resource = Math.max(0, Math.round(
|
||||||
member.resource / Math.max(1, member.maxResource) * maxResource,
|
member.resource / Math.max(1, member.maxResource) * maxResource,
|
||||||
));
|
));
|
||||||
return {
|
return withMemberAbilityResourcePools({
|
||||||
...member,
|
...member,
|
||||||
level: safeLevel,
|
level: safeLevel,
|
||||||
health,
|
health,
|
||||||
@@ -582,7 +624,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
|
|||||||
talentRanks: isRomClassId(member.classId)
|
talentRanks: isRomClassId(member.classId)
|
||||||
? Object.freeze({})
|
? Object.freeze({})
|
||||||
: runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel),
|
: runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel),
|
||||||
};
|
});
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -14,6 +14,5 @@ describe("generated party talent runtime plans", () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}, 15_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
normalizeTalentRanks,
|
normalizeTalentRanks,
|
||||||
spentTalentPoints,
|
spentTalentPoints,
|
||||||
talentNodeById,
|
talentNodeById,
|
||||||
|
talentNodesForTree,
|
||||||
talentPointsForLevel,
|
talentPointsForLevel,
|
||||||
talentTreeById,
|
talentTreeById,
|
||||||
} from "./talentCatalog";
|
} from "./talentCatalog";
|
||||||
@@ -27,10 +28,13 @@ const EXPECTED_SPECIALIZATIONS = [
|
|||||||
["warrior", "Arms", "warrior-arms"],
|
["warrior", "Arms", "warrior-arms"],
|
||||||
["warrior", "Fury", "warrior-fury"],
|
["warrior", "Fury", "warrior-fury"],
|
||||||
["paladin", "Retribution", "paladin-retribution"],
|
["paladin", "Retribution", "paladin-retribution"],
|
||||||
|
["hunter", "Beast Mastery", "hunter-beast-mastery"],
|
||||||
["hunter", "Marksmanship", "hunter-marksmanship"],
|
["hunter", "Marksmanship", "hunter-marksmanship"],
|
||||||
["hunter", "Survival", "hunter-survival"],
|
["hunter", "Survival", "hunter-survival"],
|
||||||
["rogue", "Combat", "rogue-combat"],
|
["rogue", "Combat", "rogue-combat"],
|
||||||
["rogue", "Assassination", "rogue-assassination"],
|
["rogue", "Assassination", "rogue-assassination"],
|
||||||
|
["rogue", "Subtlety", "rogue-subtlety"],
|
||||||
|
["priest", "Discipline", "priest-discipline"],
|
||||||
["priest", "Shadow", "priest-shadow"],
|
["priest", "Shadow", "priest-shadow"],
|
||||||
["death-knight", "Frost", "death-knight-frost"],
|
["death-knight", "Frost", "death-knight-frost"],
|
||||||
["death-knight", "Unholy", "death-knight-unholy"],
|
["death-knight", "Unholy", "death-knight-unholy"],
|
||||||
@@ -41,7 +45,9 @@ const EXPECTED_SPECIALIZATIONS = [
|
|||||||
["mage", "Frost", "mage-frost"],
|
["mage", "Frost", "mage-frost"],
|
||||||
["warlock", "Affliction", "warlock-affliction"],
|
["warlock", "Affliction", "warlock-affliction"],
|
||||||
["warlock", "Demonology", "warlock-demonology"],
|
["warlock", "Demonology", "warlock-demonology"],
|
||||||
|
["warlock", "Destruction", "warlock-destruction"],
|
||||||
["druid", "Balance", "druid-balance"],
|
["druid", "Balance", "druid-balance"],
|
||||||
|
["druid", "Feral (Bear)", "druid-feral-combat"],
|
||||||
["druid", "Feral (Cat)", "druid-feral-combat"],
|
["druid", "Feral (Cat)", "druid-feral-combat"],
|
||||||
] as const satisfies readonly (readonly [ClassId, string, string])[];
|
] as const satisfies readonly (readonly [ClassId, string, string])[];
|
||||||
|
|
||||||
@@ -87,6 +93,20 @@ describe("party talent specialization plans", () => {
|
|||||||
expect(selection.rank).toBeLessThanOrEqual(selection.maxRank);
|
expect(selection.rank).toBeLessThanOrEqual(selection.maxRank);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
it("reaches every primary-tree capstone before branching into a secondary tree", () => {
|
||||||
|
for (const { classId, specialization, primaryTreeId } of PARTY_TALENT_SPECIALIZATIONS) {
|
||||||
|
const level60 = learnedPartyTalentSelections(classId, specialization, 60);
|
||||||
|
const primary = level60.filter((selection) => selection.inPrimaryTree);
|
||||||
|
const deepestRow = Math.max(...talentNodesForTree(primaryTreeId).map((node) => node.row));
|
||||||
|
expect(primary).toHaveLength(51);
|
||||||
|
expect(
|
||||||
|
Math.max(...primary.map((selection) => talentNodeById(selection.nodeId)?.row ?? 0)),
|
||||||
|
`${classId}:${specialization}`,
|
||||||
|
)
|
||||||
|
.toBe(deepestRow);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("spends exactly the points available at every level from 1 through 80", () => {
|
it("spends exactly the points available at every level from 1 through 80", () => {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { ClassId } from "../app/characterCatalog";
|
import type { BaseClassId, ClassId } from "../app/characterCatalog";
|
||||||
import { clampPlayerLevel, MAX_PLAYER_LEVEL } from "./progression";
|
import { clampPlayerLevel, MAX_PLAYER_LEVEL } from "./progression";
|
||||||
import {
|
import {
|
||||||
allocateTalentPoint,
|
allocateTalentPoint,
|
||||||
|
spentTalentPointsInTree,
|
||||||
talentDescriptionForRank,
|
talentDescriptionForRank,
|
||||||
talentNodeById,
|
talentNodeById,
|
||||||
talentNodesForTree,
|
talentNodesForTree,
|
||||||
@@ -10,9 +11,10 @@ import {
|
|||||||
talentTreesForClass,
|
talentTreesForClass,
|
||||||
type TalentRanks,
|
type TalentRanks,
|
||||||
} from "./talentCatalog";
|
} from "./talentCatalog";
|
||||||
|
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
||||||
|
|
||||||
export interface PartyTalentSpecializationDefinition {
|
export interface PartyTalentSpecializationDefinition {
|
||||||
readonly classId: ClassId;
|
readonly classId: BaseClassId;
|
||||||
readonly specialization: string;
|
readonly specialization: string;
|
||||||
readonly primaryTreeId: string;
|
readonly primaryTreeId: string;
|
||||||
}
|
}
|
||||||
@@ -33,10 +35,13 @@ export const PARTY_TALENT_SPECIALIZATIONS = Object.freeze([
|
|||||||
{ classId: "warrior", specialization: "Arms", primaryTreeId: "warrior-arms" },
|
{ classId: "warrior", specialization: "Arms", primaryTreeId: "warrior-arms" },
|
||||||
{ classId: "warrior", specialization: "Fury", primaryTreeId: "warrior-fury" },
|
{ classId: "warrior", specialization: "Fury", primaryTreeId: "warrior-fury" },
|
||||||
{ classId: "paladin", specialization: "Retribution", primaryTreeId: "paladin-retribution" },
|
{ classId: "paladin", specialization: "Retribution", primaryTreeId: "paladin-retribution" },
|
||||||
|
{ classId: "hunter", specialization: "Beast Mastery", primaryTreeId: "hunter-beast-mastery" },
|
||||||
{ classId: "hunter", specialization: "Marksmanship", primaryTreeId: "hunter-marksmanship" },
|
{ classId: "hunter", specialization: "Marksmanship", primaryTreeId: "hunter-marksmanship" },
|
||||||
{ classId: "hunter", specialization: "Survival", primaryTreeId: "hunter-survival" },
|
{ classId: "hunter", specialization: "Survival", primaryTreeId: "hunter-survival" },
|
||||||
{ classId: "rogue", specialization: "Combat", primaryTreeId: "rogue-combat" },
|
{ classId: "rogue", specialization: "Combat", primaryTreeId: "rogue-combat" },
|
||||||
{ classId: "rogue", specialization: "Assassination", primaryTreeId: "rogue-assassination" },
|
{ classId: "rogue", specialization: "Assassination", primaryTreeId: "rogue-assassination" },
|
||||||
|
{ classId: "rogue", specialization: "Subtlety", primaryTreeId: "rogue-subtlety" },
|
||||||
|
{ classId: "priest", specialization: "Discipline", primaryTreeId: "priest-discipline" },
|
||||||
{ classId: "priest", specialization: "Shadow", primaryTreeId: "priest-shadow" },
|
{ classId: "priest", specialization: "Shadow", primaryTreeId: "priest-shadow" },
|
||||||
{ classId: "death-knight", specialization: "Frost", primaryTreeId: "death-knight-frost" },
|
{ classId: "death-knight", specialization: "Frost", primaryTreeId: "death-knight-frost" },
|
||||||
{ classId: "death-knight", specialization: "Unholy", primaryTreeId: "death-knight-unholy" },
|
{ classId: "death-knight", specialization: "Unholy", primaryTreeId: "death-knight-unholy" },
|
||||||
@@ -47,7 +52,9 @@ export const PARTY_TALENT_SPECIALIZATIONS = Object.freeze([
|
|||||||
{ classId: "mage", specialization: "Frost", primaryTreeId: "mage-frost" },
|
{ classId: "mage", specialization: "Frost", primaryTreeId: "mage-frost" },
|
||||||
{ classId: "warlock", specialization: "Affliction", primaryTreeId: "warlock-affliction" },
|
{ classId: "warlock", specialization: "Affliction", primaryTreeId: "warlock-affliction" },
|
||||||
{ classId: "warlock", specialization: "Demonology", primaryTreeId: "warlock-demonology" },
|
{ classId: "warlock", specialization: "Demonology", primaryTreeId: "warlock-demonology" },
|
||||||
|
{ classId: "warlock", specialization: "Destruction", primaryTreeId: "warlock-destruction" },
|
||||||
{ classId: "druid", specialization: "Balance", primaryTreeId: "druid-balance" },
|
{ classId: "druid", specialization: "Balance", primaryTreeId: "druid-balance" },
|
||||||
|
{ classId: "druid", specialization: "Feral (Bear)", primaryTreeId: "druid-feral-combat" },
|
||||||
{ classId: "druid", specialization: "Feral (Cat)", primaryTreeId: "druid-feral-combat" },
|
{ classId: "druid", specialization: "Feral (Cat)", primaryTreeId: "druid-feral-combat" },
|
||||||
] as const satisfies readonly PartyTalentSpecializationDefinition[]);
|
] as const satisfies readonly PartyTalentSpecializationDefinition[]);
|
||||||
|
|
||||||
@@ -98,31 +105,85 @@ function requireSpecialization(
|
|||||||
return definition;
|
return definition;
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderedCandidateNodeIds(definition: PartyTalentSpecializationDefinition): readonly string[] {
|
function orderedCandidateNodeIds(
|
||||||
|
definition: PartyTalentSpecializationDefinition,
|
||||||
|
ranks: TalentRanks,
|
||||||
|
): readonly string[] {
|
||||||
const trees = talentTreesForClass(definition.classId);
|
const trees = talentTreesForClass(definition.classId);
|
||||||
const primary = trees.find((tree) => tree.id === definition.primaryTreeId);
|
const primary = trees.find((tree) => tree.id === definition.primaryTreeId);
|
||||||
if (!primary) {
|
if (!primary) {
|
||||||
throw new Error(`Unknown primary talent tree ${definition.primaryTreeId} for ${definition.classId}`);
|
throw new Error(`Unknown primary talent tree ${definition.primaryTreeId} for ${definition.classId}`);
|
||||||
}
|
}
|
||||||
return [primary, ...trees.filter((tree) => tree.id !== primary.id)]
|
const primaryNodes = talentNodesForTree(primary.id);
|
||||||
|
const deepestPrimaryRow = Math.max(...primaryNodes.map((node) => node.row));
|
||||||
|
const reachedDeepestPrimaryRow = primaryNodes.some((node) => (
|
||||||
|
node.row === deepestPrimaryRow && (ranks[node.id] ?? 0) > 0
|
||||||
|
));
|
||||||
|
const primaryComplete = spentTalentPointsInTree(ranks, primary.id) >= 51
|
||||||
|
&& reachedDeepestPrimaryRow;
|
||||||
|
const eligibleTrees = primaryComplete
|
||||||
|
? trees.filter((tree) => tree.id !== primary.id)
|
||||||
|
: [primary];
|
||||||
|
const treeOrder = new Map(eligibleTrees.map((tree, index) => [tree.id, index]));
|
||||||
|
const activeTalentIds = new Set(
|
||||||
|
WOW335_ABILITIES_BY_CLASS[definition.classId]
|
||||||
|
.flatMap((ability) => ability.talentNodeId ? [ability.talentNodeId] : []),
|
||||||
|
);
|
||||||
|
const activePrerequisiteIds = new Set(
|
||||||
|
[...activeTalentIds].flatMap((nodeId) => {
|
||||||
|
const node = talentNodeById(nodeId);
|
||||||
|
return node?.prerequisites.map((entry) => entry.talentId) ?? [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const capstoneRequirements = new Map<string, number>();
|
||||||
|
const addRequirement = (nodeId: string, requiredRank: number): void => {
|
||||||
|
capstoneRequirements.set(nodeId, Math.max(requiredRank, capstoneRequirements.get(nodeId) ?? 0));
|
||||||
|
const node = talentNodeById(nodeId);
|
||||||
|
for (const prerequisite of node?.prerequisites ?? []) {
|
||||||
|
addRequirement(prerequisite.talentId, prerequisite.requiredRank);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const deepestNodes = primaryNodes.filter((node) => node.row === deepestPrimaryRow);
|
||||||
|
const capstoneCandidates = deepestNodes.some((node) => activeTalentIds.has(node.id))
|
||||||
|
? deepestNodes.filter((node) => activeTalentIds.has(node.id))
|
||||||
|
: deepestNodes;
|
||||||
|
const capstoneTarget = [...capstoneCandidates].sort((left, right) => left.index - right.index)[0];
|
||||||
|
if (capstoneTarget) addRequirement(capstoneTarget.id, 1);
|
||||||
|
const neededCapstonePathIds = new Set(
|
||||||
|
[...capstoneRequirements].filter(([nodeId, requiredRank]) => (
|
||||||
|
(ranks[nodeId] ?? 0) < requiredRank
|
||||||
|
)).map(([nodeId]) => nodeId),
|
||||||
|
);
|
||||||
|
return eligibleTrees
|
||||||
.flatMap((tree) => talentNodesForTree(tree.id))
|
.flatMap((tree) => talentNodesForTree(tree.id))
|
||||||
|
.sort((left, right) => (
|
||||||
|
Number(neededCapstonePathIds.has(right.id)) - Number(neededCapstonePathIds.has(left.id))
|
||||||
|
|| right.row - left.row
|
||||||
|
|| Number(activeTalentIds.has(right.id)) - Number(activeTalentIds.has(left.id))
|
||||||
|
|| Number(activePrerequisiteIds.has(right.id)) - Number(activePrerequisiteIds.has(left.id))
|
||||||
|
|| (treeOrder.get(left.treeId) ?? 0) - (treeOrder.get(right.treeId) ?? 0)
|
||||||
|
|| left.index - right.index
|
||||||
|
))
|
||||||
.map((node) => node.id);
|
.map((node) => node.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Produces a stable legal build without duplicating talent rules here. The
|
* Produces a stable legal build without duplicating talent rules here. The
|
||||||
* primary tree is exhausted in catalog row/index order before secondary trees;
|
* primary tree advances to its deepest tier with exactly the 51 points needed
|
||||||
* every individual rank is accepted by the shared allocator at its real level.
|
* for a Wrath capstone, then spends the remaining 20 points in one secondary
|
||||||
|
* tree. Within each unlocked tier, executable talents and their prerequisites
|
||||||
|
* are preferred. This mirrors the shape of the Playerbots PvE templates while
|
||||||
|
* keeping every rank validated against Healer Man's imported talent catalog.
|
||||||
*/
|
*/
|
||||||
function buildAllocationPlan(
|
function buildAllocationPlan(
|
||||||
definition: PartyTalentSpecializationDefinition,
|
definition: PartyTalentSpecializationDefinition,
|
||||||
): readonly PartyTalentSelection[] {
|
): readonly PartyTalentSelection[] {
|
||||||
const candidates = orderedCandidateNodeIds(definition);
|
|
||||||
let ranks: TalentRanks = {};
|
let ranks: TalentRanks = {};
|
||||||
const selections: PartyTalentSelection[] = [];
|
const selections: PartyTalentSelection[] = [];
|
||||||
|
|
||||||
for (let level = 10; level <= MAX_PLAYER_LEVEL; level += 1) {
|
for (let level = 10; level <= MAX_PLAYER_LEVEL; level += 1) {
|
||||||
let selected: PartyTalentSelection | null = null;
|
let selected: PartyTalentSelection | null = null;
|
||||||
|
const candidates = orderedCandidateNodeIds(definition, ranks);
|
||||||
for (const nodeId of candidates) {
|
for (const nodeId of candidates) {
|
||||||
const node = talentNodeById(nodeId);
|
const node = talentNodeById(nodeId);
|
||||||
if (!node || (ranks[nodeId] ?? 0) >= node.maxRank) continue;
|
if (!node || (ranks[nodeId] ?? 0) >= node.maxRank) continue;
|
||||||
|
|||||||
@@ -150,6 +150,26 @@ describe("Ascension-synchronized classic talent trees", () => {
|
|||||||
expect(allocateTalentPoint(maxed, "priest", rankNode.id, 80).reason).toBe("max-rank");
|
expect(allocateTalentPoint(maxed, "priest", rankNode.id, 80).reason).toBe("max-rank");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clamps stale stock prerequisite ranks to the patched talent maximum", () => {
|
||||||
|
for (const node of TALENT_NODES.filter((candidate) => candidate.system === "classic")) {
|
||||||
|
for (const prerequisite of node.prerequisites) {
|
||||||
|
expect(talentNodeById(prerequisite.talentId), `${node.name} prerequisite`).toBeDefined();
|
||||||
|
expect(
|
||||||
|
prerequisite.requiredRank,
|
||||||
|
`${node.name} requires ${prerequisite.talentId}`,
|
||||||
|
).toBeLessThanOrEqual(talentNodeById(prerequisite.talentId)?.maxRank ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(talentNodeById("talent-1917")?.prerequisites).toContainEqual({
|
||||||
|
talentId: "talent-1791",
|
||||||
|
requiredRank: 1,
|
||||||
|
});
|
||||||
|
expect(talentNodeById("talent-1791")?.prerequisites).toContainEqual({
|
||||||
|
talentId: "talent-1789",
|
||||||
|
requiredRank: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("deterministically trims invalid, legacy, and over-budget persisted allocations", () => {
|
it("deterministically trims invalid, legacy, and over-budget persisted allocations", () => {
|
||||||
const arcane = firstNode("mage-arcane");
|
const arcane = firstNode("mage-arcane");
|
||||||
const fire = firstNode("mage-fire");
|
const fire = firstNode("mage-fire");
|
||||||
|
|||||||
@@ -175,11 +175,33 @@ const CLASSIC_TALENT_TREES: readonly TalentTreeDefinition[] = WOW335_TALENT_TREE
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const CLASSIC_TALENT_NODES: readonly TalentNodeDefinition[] = WOW335_TALENT_NODES.map((node) => ({
|
const CLASSIC_MAX_RANK_BY_ID: ReadonlyMap<string, number> = new Map<string, number>(
|
||||||
|
WOW335_TALENT_NODES.map((node) => [node.id, node.rankSpellIds.length]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const CLASSIC_TALENT_NODES: readonly TalentNodeDefinition[] = WOW335_TALENT_NODES.map((node) => {
|
||||||
|
// Some patched 3.3.5a trees reduce a prerequisite talent's rank count while
|
||||||
|
// retaining the stock Talent.dbc requirement (for example 5/5 Nature's
|
||||||
|
// Rejuvenation becomes 3/3). Clamp that stale requirement to the imported
|
||||||
|
// prerequisite's real maximum so downstream talents remain reachable.
|
||||||
|
const prerequisites = node.prerequisites
|
||||||
|
.filter((prerequisite) => CLASSIC_MAX_RANK_BY_ID.has(prerequisite.talentId))
|
||||||
|
.map((prerequisite) => ({
|
||||||
|
...prerequisite,
|
||||||
|
requiredRank: Math.min(
|
||||||
|
prerequisite.requiredRank,
|
||||||
|
CLASSIC_MAX_RANK_BY_ID.get(prerequisite.talentId)!,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
...node,
|
...node,
|
||||||
|
prerequisiteId: prerequisites[0]?.talentId ?? null,
|
||||||
|
prerequisiteRank: prerequisites[0]?.requiredRank ?? null,
|
||||||
|
prerequisites,
|
||||||
maxRank: node.rankSpellIds.length,
|
maxRank: node.rankSpellIds.length,
|
||||||
system: "classic",
|
system: "classic",
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
function coaTreeId(classId: CoaClassId, tabId: number): string {
|
function coaTreeId(classId: CoaClassId, tabId: number): string {
|
||||||
return `coa-${classId}-${tabId}`;
|
return `coa-${classId}-${tabId}`;
|
||||||
|
|||||||
@@ -106,6 +106,37 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
|||||||
|
|
||||||
it("provides mechanics for previously silent class scripts and hidden proc spells", () => {
|
it("provides mechanics for previously silent class scripts and hidden proc spells", () => {
|
||||||
expect(effectsOfKind(byId("wow335-warrior-slam"), "damage")).not.toEqual([]);
|
expect(effectsOfKind(byId("wow335-warrior-slam"), "damage")).not.toEqual([]);
|
||||||
|
expect(effectsOfKind(byId("wow335-warrior-bloodthirst"), "damage")).toContainEqual(
|
||||||
|
expect.objectContaining({ coefficient: 1.2 }),
|
||||||
|
);
|
||||||
|
expect(effectsOfKind(byId("wow335-warrior-bloodrage"), "resource")).toEqual([
|
||||||
|
{ kind: "resource", resource: "rage", amount: 20 },
|
||||||
|
]);
|
||||||
|
expect(effectsOfKind(byId("wow335-warrior-bloodrage"), "heal")).toEqual([]);
|
||||||
|
expect(effectsOfKind(byId("wow335-warrior-execute"), "execute")).toContainEqual(
|
||||||
|
expect.objectContaining({ coefficient: 1.8 }),
|
||||||
|
);
|
||||||
|
expect(effectsOfKind(byId("wow335-warrior-execute"), "heal")).toEqual([]);
|
||||||
|
expect(effectsOfKind(byId("wow335-warrior-bladestorm"), "dot")).toEqual([
|
||||||
|
expect.objectContaining({ coefficient: 0.6, ticks: 6, intervalMs: 1_000 }),
|
||||||
|
]);
|
||||||
|
expect(byId("wow335-warrior-bladestorm")).toMatchObject({ target: "self", radius: 8 });
|
||||||
|
expect(effectsOfKind(byId("wow335-paladin-hammer-of-the-righteous"), "damage")).toEqual([
|
||||||
|
expect.objectContaining({ coefficient: 1.2 }),
|
||||||
|
]);
|
||||||
|
expect(effectsOfKind(byId("wow335-druid-swiftmend"), "heal")).toEqual([
|
||||||
|
expect.objectContaining({ coefficient: 2.2 }),
|
||||||
|
]);
|
||||||
|
expect(effectsOfKind(byId("wow335-rogue-mutilate"), "damage")).toContainEqual(
|
||||||
|
expect.objectContaining({ coefficient: 2 }),
|
||||||
|
);
|
||||||
|
expect(effectsOfKind(byId("wow335-rogue-mutilate"), "combo")).not.toEqual([]);
|
||||||
|
expect(effectsOfKind(byId("wow335-rogue-killing-spree"), "dot")).toContainEqual(
|
||||||
|
expect.objectContaining({ coefficient: 0.5, ticks: 5 }),
|
||||||
|
);
|
||||||
|
expect(effectsOfKind(byId("wow335-warlock-conflagrate"), "damage")).toEqual([
|
||||||
|
expect.objectContaining({ coefficient: 1.4 }),
|
||||||
|
]);
|
||||||
expect(effectsOfKind(byId("wow335-death-knight-death-grip"), "pull")).not.toEqual([]);
|
expect(effectsOfKind(byId("wow335-death-knight-death-grip"), "pull")).not.toEqual([]);
|
||||||
expect(effectsOfKind(byId("wow335-shaman-windfury-weapon"), "apply-aura")[0]?.aura.procs).not.toEqual([]);
|
expect(effectsOfKind(byId("wow335-shaman-windfury-weapon"), "apply-aura")[0]?.aura.procs).not.toEqual([]);
|
||||||
expect(effectsOfKind(byId("wow335-shaman-lightning-shield"), "apply-aura")[0]?.aura.procs).toEqual(expect.arrayContaining([
|
expect(effectsOfKind(byId("wow335-shaman-lightning-shield"), "apply-aura")[0]?.aura.procs).toEqual(expect.arrayContaining([
|
||||||
@@ -113,6 +144,15 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("models Penance as the Discipline rotation's friendly heal", () => {
|
||||||
|
const penance = byId("wow335-priest-penance");
|
||||||
|
expect(penance.target).toBe("friendly");
|
||||||
|
expect(effectsOfKind(penance, "heal")).toEqual([
|
||||||
|
expect.objectContaining({ coefficient: 1.8 }),
|
||||||
|
]);
|
||||||
|
expect(effectsOfKind(penance, "damage")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps triggered shield damage children out of learned Shaman ranks", () => {
|
it("keeps triggered shield damage children out of learned Shaman ranks", () => {
|
||||||
const lightningShield = abilityAtLevel(byId("wow335-shaman-lightning-shield"), 80);
|
const lightningShield = abilityAtLevel(byId("wow335-shaman-lightning-shield"), 80);
|
||||||
expect(lightningShield.dbcSpellId).toBe(49281);
|
expect(lightningShield.dbcSpellId).toBe(49281);
|
||||||
@@ -127,6 +167,27 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
|||||||
expect(effectsOfKind(waterShield, "apply-aura").every((effect) => (
|
expect(effectsOfKind(waterShield, "apply-aura").every((effect) => (
|
||||||
effect.aura.exclusiveGroup === "shaman:elemental-shield"
|
effect.aura.exclusiveGroup === "shaman:elemental-shield"
|
||||||
))).toBe(true);
|
))).toBe(true);
|
||||||
|
|
||||||
|
const earthShield = byId("wow335-shaman-earth-shield");
|
||||||
|
const earthProc = effectsOfKind(earthShield, "apply-aura")
|
||||||
|
.flatMap((effect) => effect.aura.procs ?? [])
|
||||||
|
.find((proc) => proc.id.endsWith(":earth-shield"));
|
||||||
|
expect(earthProc).toMatchObject({
|
||||||
|
triggers: "damage-taken",
|
||||||
|
charges: 9,
|
||||||
|
internalCooldownMs: 3_000,
|
||||||
|
action: { kind: "heal", target: "aura-target" },
|
||||||
|
});
|
||||||
|
expect(effectsOfKind(earthShield, "apply-aura").every((effect) => (
|
||||||
|
effect.aura.exclusiveGroup === "shaman:earth-shield"
|
||||||
|
))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("models both offensive Spirit Wolves instead of the inert DBC summon marker", () => {
|
||||||
|
expect(effectsOfKind(byId("wow335-shaman-feral-spirit"), "summon")).toEqual([
|
||||||
|
expect.objectContaining({ creatureId: 29264, coefficient: 0.35, durationMs: 45_000 }),
|
||||||
|
expect.objectContaining({ creatureId: 29264, coefficient: 0.35, durationMs: 45_000 }),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates source-named stat and avoidance auras", () => {
|
it("creates source-named stat and avoidance auras", () => {
|
||||||
|
|||||||
@@ -322,6 +322,9 @@ function exclusiveGroupForAbility(classId: BaseClassId, abilityName: string): st
|
|||||||
if (/\bArmor$/i.test(abilityName)) return `${classId}:armor`;
|
if (/\bArmor$/i.test(abilityName)) return `${classId}:armor`;
|
||||||
if (/\bPresence$/i.test(abilityName)) return "death-knight:presence";
|
if (/\bPresence$/i.test(abilityName)) return "death-knight:presence";
|
||||||
if (["Lightning Shield", "Water Shield"].includes(abilityName)) return "shaman:elemental-shield";
|
if (["Lightning Shield", "Water Shield"].includes(abilityName)) return "shaman:elemental-shield";
|
||||||
|
// Earth Shield is maintained on an ally while the Shaman keeps a personal
|
||||||
|
// Lightning/Water Shield, so it cannot share their caster-global group.
|
||||||
|
if (abilityName === "Earth Shield") return "shaman:earth-shield";
|
||||||
if (/ Weapon$/i.test(abilityName)) return "shaman:weapon-enchant";
|
if (/ Weapon$/i.test(abilityName)) return "shaman:weapon-enchant";
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -558,6 +561,20 @@ function namedAuraForEffect(
|
|||||||
action: { kind: "resource", resource: "mana", amount: Math.max(1, effect.basePoints), target: "aura-target" },
|
action: { kind: "resource", resource: "mana", amount: Math.max(1, effect.basePoints), target: "aura-target" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (abilityName === "Earth Shield" && effectIndex === 0) {
|
||||||
|
specialProcs.push({
|
||||||
|
id: `${auraId}:earth-shield`, triggers: "damage-taken", chance: 1,
|
||||||
|
internalCooldownMs: 3_000, charges: 9,
|
||||||
|
action: {
|
||||||
|
kind: "heal",
|
||||||
|
amount: Math.max(1, Math.round(
|
||||||
|
effect.basePoints
|
||||||
|
+ playerCombatPower(Math.max(1, rank.sourceLevel || rank.level)) * Math.max(0, effect.coefficient),
|
||||||
|
)),
|
||||||
|
target: "aura-target",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
if (["Frost Armor", "Ice Armor"].includes(abilityName) && effect.auraType === 42) {
|
if (["Frost Armor", "Ice Armor"].includes(abilityName) && effect.auraType === 42) {
|
||||||
specialProcs.push({
|
specialProcs.push({
|
||||||
id: `${auraId}:frost-slow`, triggers: "damage-taken", chance: 1,
|
id: `${auraId}:frost-slow`, triggers: "damage-taken", chance: 1,
|
||||||
@@ -724,6 +741,16 @@ function classScriptEffects(
|
|||||||
});
|
});
|
||||||
switch (`${classId}:${abilityName}`) {
|
switch (`${classId}:${abilityName}`) {
|
||||||
case "warrior:Slam": return [{ kind: "damage", coefficient: 1.5 }];
|
case "warrior:Slam": return [{ kind: "damage", coefficient: 1.5 }];
|
||||||
|
// The player spell delegates its weapon hit to a triggered child spell;
|
||||||
|
// retain the imported self-heal aura and restore the missing attack.
|
||||||
|
case "warrior:Bloodthirst": return [{ kind: "damage", coefficient: 1.2 }];
|
||||||
|
// The player-facing aura delegates each weapon strike to server-only child
|
||||||
|
// spells. Model the six one-second swings as a periodic damage payload.
|
||||||
|
case "warrior:Bladestorm": return [{
|
||||||
|
kind: "dot", coefficient: 0.6, ticks: 6, intervalMs: 1_000, tag: "bladestorm",
|
||||||
|
}];
|
||||||
|
case "warrior:Bloodrage": return [{ kind: "resource", resource: "rage", amount: 20 }];
|
||||||
|
case "warrior:Execute": return [{ kind: "execute", coefficient: 1.8 }];
|
||||||
case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }];
|
case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }];
|
||||||
case "warrior:Intercept": return [{ kind: "status", status: "stun", durationMs: 3_000 }];
|
case "warrior:Intercept": return [{ kind: "status", status: "stun", durationMs: 3_000 }];
|
||||||
case "warrior:Sunder Armor": return [{
|
case "warrior:Sunder Armor": return [{
|
||||||
@@ -742,6 +769,9 @@ function classScriptEffects(
|
|||||||
case "paladin:Judgement of Justice": return [{ kind: "status", status: "root", durationMs: 10_000 }];
|
case "paladin:Judgement of Justice": return [{ kind: "status", status: "root", durationMs: 10_000 }];
|
||||||
case "paladin:Righteous Defense": return [{ kind: "taunt", durationMs: 3_000, maxTargets: 3 }];
|
case "paladin:Righteous Defense": return [{ kind: "taunt", durationMs: 3_000, maxTargets: 3 }];
|
||||||
case "paladin:Hand of Salvation": return [{ kind: "threat", mode: "percent", amount: -20, recipient: "ability-target" }];
|
case "paladin:Hand of Salvation": return [{ kind: "threat", mode: "percent", amount: -20, recipient: "ability-target" }];
|
||||||
|
// The DBC parent carries a one-point trigger marker; its weapon hit lives in
|
||||||
|
// a child spell that is not present in the player-facing progression rows.
|
||||||
|
case "paladin:Hammer of the Righteous": return [{ kind: "damage", coefficient: 1.2 }];
|
||||||
case "paladin:Divine Intervention": return [{
|
case "paladin:Divine Intervention": return [{
|
||||||
kind: "apply-aura",
|
kind: "apply-aura",
|
||||||
aura: {
|
aura: {
|
||||||
@@ -758,6 +788,12 @@ function classScriptEffects(
|
|||||||
case "hunter:Freezing Arrow": return [{ kind: "status", status: "freeze", durationMs: 10_000 }];
|
case "hunter:Freezing Arrow": return [{ kind: "status", status: "freeze", durationMs: 10_000 }];
|
||||||
case "hunter:Feign Death": return [{ kind: "threat", mode: "percent", amount: -100 }];
|
case "hunter:Feign Death": return [{ kind: "threat", mode: "percent", amount: -100 }];
|
||||||
case "rogue:Shiv": return [{ kind: "damage", coefficient: 1 }];
|
case "rogue:Shiv": return [{ kind: "damage", coefficient: 1 }];
|
||||||
|
case "rogue:Killing Spree": return [{
|
||||||
|
kind: "dot", coefficient: 0.5, ticks: 5, intervalMs: 500, tag: "killing-spree",
|
||||||
|
}];
|
||||||
|
// Mutilate triggers separate main-hand and off-hand child attacks that are
|
||||||
|
// not player-facing SkillLine records. The DBC combo award remains intact.
|
||||||
|
case "rogue:Mutilate": return [{ kind: "damage", coefficient: 2 }];
|
||||||
case "rogue:Garrote": return [{ kind: "status", status: "silence", durationMs: 3_000 }];
|
case "rogue:Garrote": return [{ kind: "status", status: "silence", durationMs: 3_000 }];
|
||||||
case "rogue:Cloak of Shadows": return [{
|
case "rogue:Cloak of Shadows": return [{
|
||||||
kind: "dispel", mode: "cleanse", relationship: "friendly", categories: ["magic"], maxCount: 10,
|
kind: "dispel", mode: "cleanse", relationship: "friendly", categories: ["magic"], maxCount: 10,
|
||||||
@@ -766,7 +802,7 @@ function classScriptEffects(
|
|||||||
// trigger spells. Keep the imported cast/cost/rank data and supply only the
|
// trigger spells. Keep the imported cast/cost/rank data and supply only the
|
||||||
// missing executable payload.
|
// missing executable payload.
|
||||||
case "priest:Mind Flay": return [{ kind: "damage", coefficient: 0.9 }];
|
case "priest:Mind Flay": return [{ kind: "damage", coefficient: 0.9 }];
|
||||||
case "priest:Penance": return [{ kind: "damage", coefficient: 0.8 }];
|
case "priest:Penance": return [{ kind: "heal", coefficient: 1.8 }];
|
||||||
case "priest:Shadowfiend": return [{ kind: "summon", creatureId: 19668, coefficient: 0.5, durationMs: 15_000 }];
|
case "priest:Shadowfiend": return [{ kind: "summon", creatureId: 19668, coefficient: 0.5, durationMs: 15_000 }];
|
||||||
case "priest:Prayer of Mending": return [{
|
case "priest:Prayer of Mending": return [{
|
||||||
kind: "apply-aura",
|
kind: "apply-aura",
|
||||||
@@ -818,7 +854,13 @@ function classScriptEffects(
|
|||||||
id: `wow335:${rank.spellId}:windfury-proc`, triggers: "hit", chance: 0.2,
|
id: `wow335:${rank.spellId}:windfury-proc`, triggers: "hit", chance: 0.2,
|
||||||
action: { kind: "damage", amount: Math.max(1, Math.round(power * 0.75)), school: "physical", target: "event-other" },
|
action: { kind: "damage", amount: Math.max(1, Math.round(power * 0.75)), school: "physical", target: "event-other" },
|
||||||
}])];
|
}])];
|
||||||
|
case "shaman:Feral Spirit": return Array.from({ length: 2 }, () => ({
|
||||||
|
kind: "summon" as const, creatureId: 29264, coefficient: 0.35, durationMs: 45_000,
|
||||||
|
}));
|
||||||
case "warlock:Soulshatter": return [{ kind: "threat", mode: "percent", amount: -90 }];
|
case "warlock:Soulshatter": return [{ kind: "threat", mode: "percent", amount: -90 }];
|
||||||
|
// Conflagrate's imported parent row contains a one-point server marker;
|
||||||
|
// model the consumed-DoT burst rather than treating that marker as damage.
|
||||||
|
case "warlock:Conflagrate": return [{ kind: "damage", coefficient: 1.4 }];
|
||||||
case "warlock:Demonic Circle: Summon": return [{ kind: "summon", creatureId: 19175, coefficient: 0, durationMs: 360_000 }];
|
case "warlock:Demonic Circle: Summon": return [{ kind: "summon", creatureId: 19175, coefficient: 0, durationMs: 360_000 }];
|
||||||
case "warlock:Demonic Circle: Teleport": return [{ kind: "movement", movement: "teleport", toward: "away" }];
|
case "warlock:Demonic Circle: Teleport": return [{ kind: "movement", movement: "teleport", toward: "away" }];
|
||||||
case "hunter:Misdirection": return [{ kind: "threat", mode: "redirect", amount: 100 }];
|
case "hunter:Misdirection": return [{ kind: "threat", mode: "redirect", amount: 100 }];
|
||||||
@@ -847,6 +889,9 @@ function classScriptEffects(
|
|||||||
triggerSpellId: 0,
|
triggerSpellId: 0,
|
||||||
}];
|
}];
|
||||||
case "warlock:Inferno": return [{ kind: "summon", creatureId: 89, coefficient: 0.8, durationMs: 60_000 }];
|
case "warlock:Inferno": return [{ kind: "summon", creatureId: 89, coefficient: 0.8, durationMs: 60_000 }];
|
||||||
|
// Swiftmend's DBC row exposes only the one-point consume marker; supply the
|
||||||
|
// direct heal while leaving HoT consumption out until aura choice is modeled.
|
||||||
|
case "druid:Swiftmend": return [{ kind: "heal", coefficient: 2.2 }];
|
||||||
case "druid:Pounce": return [{ kind: "dot", coefficient: 0.18, ticks: 6, intervalMs: 3_000, tag: "pounce" }];
|
case "druid:Pounce": return [{ kind: "dot", coefficient: 0.18, ticks: 6, intervalMs: 3_000, tag: "pounce" }];
|
||||||
default: return [];
|
default: return [];
|
||||||
}
|
}
|
||||||
@@ -1137,6 +1182,19 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
|||||||
const scriptedFallbacks = classScriptEffects(classId, abilityName, rank);
|
const scriptedFallbacks = classScriptEffects(classId, abilityName, rank);
|
||||||
const executable = effects
|
const executable = effects
|
||||||
.filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported")
|
.filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported")
|
||||||
|
.filter((effect) => !(
|
||||||
|
(classId === "warlock" && abilityName === "Conflagrate" && effect.kind === "damage")
|
||||||
|
|| (classId === "warrior" && abilityName === "Execute"
|
||||||
|
&& (effect.kind === "damage" || effect.kind === "heal"))
|
||||||
|
|| (classId === "priest" && abilityName === "Penance"
|
||||||
|
&& (effect.kind === "damage" || effect.kind === "heal"))
|
||||||
|
|| (classId === "shaman" && abilityName === "Feral Spirit" && effect.kind === "summon")
|
||||||
|
|| (classId === "warrior" && abilityName === "Bladestorm" && effect.kind === "dot")
|
||||||
|
|| (classId === "paladin" && abilityName === "Hammer of the Righteous" && effect.kind === "damage")
|
||||||
|
|| (classId === "druid" && abilityName === "Swiftmend" && effect.kind === "heal")
|
||||||
|
|| (classId === "warrior" && abilityName === "Bloodrage"
|
||||||
|
&& (effect.kind === "resource" || effect.kind === "heal"))
|
||||||
|
))
|
||||||
.map((effect) => effect.kind === "utility" ? effect : effect);
|
.map((effect) => effect.kind === "utility" ? effect : effect);
|
||||||
const result = scriptedFallbacks.length
|
const result = scriptedFallbacks.length
|
||||||
? [...executable, ...scriptedFallbacks]
|
? [...executable, ...scriptedFallbacks]
|
||||||
@@ -1307,6 +1365,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
|||||||
}
|
}
|
||||||
|
|
||||||
function abilityNameRadius(abilityName: string): number {
|
function abilityNameRadius(abilityName: string): number {
|
||||||
|
if (abilityName === "Bladestorm") return 8;
|
||||||
if (abilityName === "Hellfire") return 10;
|
if (abilityName === "Hellfire") return 10;
|
||||||
if (abilityName === "Mind Sear") return 10;
|
if (abilityName === "Mind Sear") return 10;
|
||||||
if (abilityName === "Pestilence") return 10;
|
if (abilityName === "Pestilence") return 10;
|
||||||
@@ -1315,7 +1374,8 @@ function abilityNameRadius(abilityName: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
|
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
|
||||||
if (["Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
|
if (["Bladestorm", "Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
|
||||||
|
if (abilityName === "Penance") return "friendly";
|
||||||
if (abilityName === "Death Coil") return "hostile";
|
if (abilityName === "Death Coil") return "hostile";
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-22
@@ -3,11 +3,11 @@ import { sessionCanUseGmTools } from "../app/releaseFeatures";
|
|||||||
import { useShellStore } from "../app/shellStore";
|
import { useShellStore } from "../app/shellStore";
|
||||||
import {
|
import {
|
||||||
fetchBalanceLabArtifact,
|
fetchBalanceLabArtifact,
|
||||||
getBalanceLabJob,
|
|
||||||
getBalanceLabReport,
|
getBalanceLabReport,
|
||||||
getBalanceLabRuntime,
|
getBalanceLabRuntime,
|
||||||
listBalanceLabJobs,
|
listBalanceLabJobs,
|
||||||
startBalanceLabJob,
|
startBalanceLabJob,
|
||||||
|
startBalanceLabJobs,
|
||||||
type BalanceLabBossCategory,
|
type BalanceLabBossCategory,
|
||||||
type BalanceLabJob,
|
type BalanceLabJob,
|
||||||
type BalanceLabPartySource,
|
type BalanceLabPartySource,
|
||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
type BalanceLabSpecSelection,
|
type BalanceLabSpecSelection,
|
||||||
type BalanceLabSuiteReport,
|
type BalanceLabSuiteReport,
|
||||||
} from "../app/balanceLabClient";
|
} from "../app/balanceLabClient";
|
||||||
|
import {
|
||||||
|
balanceLabBracketLevels,
|
||||||
|
type BalanceLabBracketStep,
|
||||||
|
} from "../app/balanceLabSchedules";
|
||||||
import { classById } from "../app/characterCatalog";
|
import { classById } from "../app/characterCatalog";
|
||||||
import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
|
import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +29,7 @@ import { BalanceLabResults } from "./BalanceLabResults";
|
|||||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||||
|
|
||||||
type BossScope = BalanceLabBossCategory | "specific";
|
type BossScope = BalanceLabBossCategory | "specific";
|
||||||
|
type LevelCoverage = "single" | "brackets";
|
||||||
|
|
||||||
const ARTIFACT_LABELS: Readonly<Record<string, string>> = {
|
const ARTIFACT_LABELS: Readonly<Record<string, string>> = {
|
||||||
"suite-report.html": "Open full report",
|
"suite-report.html": "Open full report",
|
||||||
@@ -54,7 +59,7 @@ function progressPercent(job: BalanceLabJob): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function describeJob(job: BalanceLabJob): string {
|
function describeJob(job: BalanceLabJob): string {
|
||||||
if (job.status === "queued") return "Waiting for the current server job to finish.";
|
if (job.status === "queued") return "Waiting for an available server worker.";
|
||||||
if (job.status === "failed") return job.error ?? "The server job failed.";
|
if (job.status === "failed") return job.error ?? "The server job failed.";
|
||||||
if (job.status === "complete") return job.summary
|
if (job.status === "complete") return job.summary
|
||||||
? `Completed ${job.summary.runs.toLocaleString()} simulations across ${job.summary.bosses.toLocaleString()} bosses and ${job.summary.partyConfigurations.toLocaleString()} party configurations.`
|
? `Completed ${job.summary.runs.toLocaleString()} simulations across ${job.summary.bosses.toLocaleString()} bosses and ${job.summary.partyConfigurations.toLocaleString()} party configurations.`
|
||||||
@@ -77,6 +82,10 @@ function coveragePartyCount(builds: readonly SelectableBenchmarkBuildCatalogEntr
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildIdentity(build: SelectableBenchmarkBuildCatalogEntry): string {
|
||||||
|
return `${build.source}:${build.classId}:${build.role}:${build.orientation}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) {
|
export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) {
|
||||||
const session = useShellStore((state) => state.session);
|
const session = useShellStore((state) => state.session);
|
||||||
const token = session?.accessToken ?? "";
|
const token = session?.accessToken ?? "";
|
||||||
@@ -85,6 +94,8 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
|||||||
const [specSearch, setSpecSearch] = useState("");
|
const [specSearch, setSpecSearch] = useState("");
|
||||||
const [selectedBuildIds, setSelectedBuildIds] = useState<readonly string[]>([]);
|
const [selectedBuildIds, setSelectedBuildIds] = useState<readonly string[]>([]);
|
||||||
const [level, setLevel] = useState(60);
|
const [level, setLevel] = useState(60);
|
||||||
|
const [levelCoverage, setLevelCoverage] = useState<LevelCoverage>("single");
|
||||||
|
const [bracketStep, setBracketStep] = useState<BalanceLabBracketStep>(10);
|
||||||
const [partyConfigurations, setPartyConfigurations] = useState(10);
|
const [partyConfigurations, setPartyConfigurations] = useState(10);
|
||||||
const [simulationsPerParty, setSimulationsPerParty] = useState(3);
|
const [simulationsPerParty, setSimulationsPerParty] = useState(3);
|
||||||
const [durationSeconds, setDurationSeconds] = useState(60);
|
const [durationSeconds, setDurationSeconds] = useState(60);
|
||||||
@@ -113,14 +124,18 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
|||||||
&& (!query || `${job.requestedBy} ${job.spec.source} ${job.spec.level} ${job.spec.specSelection ?? "random"} ${job.id} ${job.status}`.toLowerCase().includes(query))
|
&& (!query || `${job.requestedBy} ${job.spec.source} ${job.spec.level} ${job.spec.specSelection ?? "random"} ${job.id} ${job.status}`.toLowerCase().includes(query))
|
||||||
));
|
));
|
||||||
}, [jobSearch, jobStatus, jobs]);
|
}, [jobSearch, jobStatus, jobs]);
|
||||||
|
const targetLevels = useMemo(() => levelCoverage === "single"
|
||||||
|
? [level]
|
||||||
|
: balanceLabBracketLevels(bracketStep), [bracketStep, level, levelCoverage]);
|
||||||
|
const catalogLevel = levelCoverage === "single" && Number.isFinite(level) ? level : 80;
|
||||||
const availableBuilds = useMemo(() => selectableBenchmarkBuildCatalog(
|
const availableBuilds = useMemo(() => selectableBenchmarkBuildCatalog(
|
||||||
Number.isFinite(level) ? level : 60,
|
catalogLevel,
|
||||||
).filter((build) => source === "all" || build.source === source).sort((left, right) => (
|
).filter((build) => source === "all" || build.source === source).sort((left, right) => (
|
||||||
left.source.localeCompare(right.source)
|
left.source.localeCompare(right.source)
|
||||||
|| classById(left.classId).name.localeCompare(classById(right.classId).name)
|
|| classById(left.classId).name.localeCompare(classById(right.classId).name)
|
||||||
|| left.orientation.localeCompare(right.orientation)
|
|| left.orientation.localeCompare(right.orientation)
|
||||||
|| left.role.localeCompare(right.role)
|
|| left.role.localeCompare(right.role)
|
||||||
)), [level, source]);
|
)), [catalogLevel, source]);
|
||||||
const selectedBuildSet = useMemo(() => new Set(selectedBuildIds), [selectedBuildIds]);
|
const selectedBuildSet = useMemo(() => new Set(selectedBuildIds), [selectedBuildIds]);
|
||||||
const selectedBuilds = useMemo(() => availableBuilds.filter((build) => selectedBuildSet.has(build.id)), [
|
const selectedBuilds = useMemo(() => availableBuilds.filter((build) => selectedBuildSet.has(build.id)), [
|
||||||
availableBuilds,
|
availableBuilds,
|
||||||
@@ -144,7 +159,9 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
|||||||
? partyConfigurations * (source === "all" ? 3 : 1)
|
? partyConfigurations * (source === "all" ? 3 : 1)
|
||||||
: coveragePartyCount(coverageBuilds);
|
: coveragePartyCount(coverageBuilds);
|
||||||
const coveredSpecCount = specSelection === "all" ? availableBuilds.length : selectedBuilds.length;
|
const coveredSpecCount = specSelection === "all" ? availableBuilds.length : selectedBuilds.length;
|
||||||
const estimatedRuns = partyCount * bossCount * simulationsPerParty;
|
const estimatedRunsPerLevel = partyCount * bossCount * simulationsPerParty;
|
||||||
|
const estimatedRuns = estimatedRunsPerLevel * targetLevels.length;
|
||||||
|
const hasPendingJobs = jobs.some((job) => job.status === "queued" || job.status === "running");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -165,18 +182,19 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
|||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !selectedJob || (selectedJob.status !== "queued" && selectedJob.status !== "running")) return;
|
if (!token || !hasPendingJobs) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
void getBalanceLabJob(selectedJob.id, token).then((updated) => {
|
void Promise.all([listBalanceLabJobs(token), getBalanceLabRuntime(token)]).then(([nextJobs, runtime]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setJobs((current) => [updated, ...current.filter((job) => job.id !== updated.id)]);
|
setJobs(nextJobs);
|
||||||
|
setRuntimeStatus(runtime);
|
||||||
}).catch((reason) => {
|
}).catch((reason) => {
|
||||||
if (!cancelled) setError(errorMessage(reason));
|
if (!cancelled) setError(errorMessage(reason));
|
||||||
});
|
});
|
||||||
}, 1_500);
|
}, 1_500);
|
||||||
return () => { cancelled = true; window.clearInterval(timer); };
|
return () => { cancelled = true; window.clearInterval(timer); };
|
||||||
}, [selectedJob, token]);
|
}, [hasPendingJobs, token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !selectedJob || selectedJob.status !== "complete" || selectedReport
|
if (!token || !selectedJob || selectedJob.status !== "complete" || selectedReport
|
||||||
@@ -235,20 +253,35 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const job = await startBalanceLabJob({
|
const selectedIdentities = new Set(selectedBuilds.map(buildIdentity));
|
||||||
|
const specs = targetLevels.map((targetLevel) => {
|
||||||
|
const levelBuilds = selectableBenchmarkBuildCatalog(targetLevel)
|
||||||
|
.filter((build) => source === "all" || build.source === source);
|
||||||
|
const chosenBuilds = specSelection === "specific"
|
||||||
|
? levelBuilds.filter((build) => selectedIdentities.has(buildIdentity(build)))
|
||||||
|
: specSelection === "all" ? levelBuilds : [];
|
||||||
|
if (specSelection === "specific" && chosenBuilds.length !== selectedIdentities.size) {
|
||||||
|
throw new Error(`One or more selected class/spec builds are unavailable at level ${targetLevel}.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
source,
|
source,
|
||||||
specSelection,
|
specSelection,
|
||||||
selectedBuildIds: specSelection === "specific" ? selectedBuilds.map((build) => build.id) : [],
|
selectedBuildIds: specSelection === "specific" ? chosenBuilds.map((build) => build.id) : [],
|
||||||
level,
|
level: targetLevel,
|
||||||
partyConfigurations: specSelection === "random" ? partyConfigurations : partyCount,
|
partyConfigurations: specSelection === "random" ? partyConfigurations : coveragePartyCount(chosenBuilds),
|
||||||
simulationsPerParty,
|
simulationsPerParty,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
partySeed,
|
partySeed,
|
||||||
bossCategory: bossScope === "specific" ? "all" : bossScope,
|
bossCategory: bossScope === "specific" ? "all" as const : bossScope,
|
||||||
bossKeys: bossScope === "specific" ? selectedBossKeys : [],
|
bossKeys: bossScope === "specific" ? selectedBossKeys : [],
|
||||||
}, token);
|
};
|
||||||
setJobs((current) => [job, ...current.filter((candidate) => candidate.id !== job.id)]);
|
});
|
||||||
setSelectedJobId(job.id);
|
const submittedJobs = specs.length === 1
|
||||||
|
? [await startBalanceLabJob(specs[0], token)]
|
||||||
|
: await startBalanceLabJobs(specs, token);
|
||||||
|
const submittedIds = new Set(submittedJobs.map((job) => job.id));
|
||||||
|
setJobs((current) => [...submittedJobs, ...current.filter((job) => !submittedIds.has(job.id))]);
|
||||||
|
setSelectedJobId(submittedJobs[0]?.id ?? null);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(errorMessage(reason));
|
setError(errorMessage(reason));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -292,7 +325,9 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
|||||||
<p className="balance-lab-help">This mode runs on the game server. The browser submits the job and may be closed while the server continues.</p>
|
<p className="balance-lab-help">This mode runs on the game server. The browser submits the job and may be closed while the server continues.</p>
|
||||||
{runtimeStatus && <div className={`balance-lab-runtime ${runtimeStatus.available ? "is-ready" : "is-unavailable"}`} role={runtimeStatus.available ? undefined : "alert"}>
|
{runtimeStatus && <div className={`balance-lab-runtime ${runtimeStatus.available ? "is-ready" : "is-unavailable"}`} role={runtimeStatus.available ? undefined : "alert"}>
|
||||||
<strong>{runtimeStatus.available ? "Server runtime ready" : "Server runtime unavailable"}</strong>
|
<strong>{runtimeStatus.available ? "Server runtime ready" : "Server runtime unavailable"}</strong>
|
||||||
<span>{runtimeStatus.available ? runtimeStatus.version : `${runtimeStatus.error} Install Python 3 in the server container, set BALANCE_LAB_PYTHON=python3, and restart the app.`}</span>
|
<span>{runtimeStatus.available
|
||||||
|
? `${runtimeStatus.version} · ${runtimeStatus.running}/${runtimeStatus.maxConcurrent} workers active · ${runtimeStatus.queued} queued`
|
||||||
|
: `${runtimeStatus.error} Install Python 3 in the server container, set BALANCE_LAB_PYTHON=python3, and restart the app.`}</span>
|
||||||
</div>}
|
</div>}
|
||||||
<div className="balance-lab-form-grid">
|
<div className="balance-lab-form-grid">
|
||||||
<label><span>Party source</span><select value={source} onChange={(event) => {
|
<label><span>Party source</span><select value={source} onChange={(event) => {
|
||||||
@@ -302,10 +337,19 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
|||||||
}}>
|
}}>
|
||||||
<option value="all">WoW + CoA + RoM</option><option value="wow">WoW only</option><option value="coa">CoA only</option><option value="rom">RoM only</option>
|
<option value="all">WoW + CoA + RoM</option><option value="wow">WoW only</option><option value="coa">CoA only</option><option value="rom">RoM only</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
<label><span>Level</span><input type="number" min={1} max={80} value={level} onChange={(event) => {
|
<label><span>Level coverage</span><select value={levelCoverage} onChange={(event) => {
|
||||||
|
setLevelCoverage(event.currentTarget.value as LevelCoverage);
|
||||||
|
setSelectedBuildIds([]);
|
||||||
|
}}><option value="single">Single level</option><option value="brackets">Run all level brackets</option></select></label>
|
||||||
|
{levelCoverage === "single"
|
||||||
|
? <label><span>Level</span><input type="number" min={1} max={80} value={level} onChange={(event) => {
|
||||||
setLevel(event.currentTarget.valueAsNumber);
|
setLevel(event.currentTarget.valueAsNumber);
|
||||||
setSelectedBuildIds([]);
|
setSelectedBuildIds([]);
|
||||||
}} /></label>
|
}} /></label>
|
||||||
|
: <label><span>Bracket spacing</span><select value={bracketStep} onChange={(event) => {
|
||||||
|
setBracketStep(Number(event.currentTarget.value) as BalanceLabBracketStep);
|
||||||
|
setSelectedBuildIds([]);
|
||||||
|
}}><option value={10}>Every 10 levels</option><option value={5}>Every 5 levels</option><option value={1}>Every 1 level</option></select></label>}
|
||||||
<label className="balance-lab-form-grid__wide"><span>Class/spec selection</span><select value={specSelection} onChange={(event) => setSpecSelection(event.currentTarget.value as BalanceLabSpecSelection)}>
|
<label className="balance-lab-form-grid__wide"><span>Class/spec selection</span><select value={specSelection} onChange={(event) => setSpecSelection(event.currentTarget.value as BalanceLabSpecSelection)}>
|
||||||
<option value="random">Random parties</option><option value="all">All specs</option><option value="specific">Specific specs</option>
|
<option value="random">Random parties</option><option value="all">All specs</option><option value="specific">Specific specs</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
@@ -316,9 +360,12 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
|||||||
<label><span>Fight limit (seconds)</span><input type="number" min={5} max={600} value={durationSeconds} onChange={(event) => setDurationSeconds(event.currentTarget.valueAsNumber)} /></label>
|
<label><span>Fight limit (seconds)</span><input type="number" min={5} max={600} value={durationSeconds} onChange={(event) => setDurationSeconds(event.currentTarget.valueAsNumber)} /></label>
|
||||||
<label><span>Party seed</span><input type="number" value={partySeed} onChange={(event) => setPartySeed(event.currentTarget.valueAsNumber)} /></label>
|
<label><span>Party seed</span><input type="number" value={partySeed} onChange={(event) => setPartySeed(event.currentTarget.valueAsNumber)} /></label>
|
||||||
<label className="balance-lab-form-grid__wide"><span>Bosses</span><select value={bossScope} onChange={(event) => setBossScope(event.currentTarget.value as BossScope)}>
|
<label className="balance-lab-form-grid__wide"><span>Bosses</span><select value={bossScope} onChange={(event) => setBossScope(event.currentTarget.value as BossScope)}>
|
||||||
<option value="all">All bosses</option><option value="wow">WoW bosses</option><option value="rom">RoM bosses</option><option value="specific">Specific selection</option>
|
<option value="all">Every boss</option><option value="wow">Every WoW boss</option><option value="rom">Every RoM boss</option><option value="specific">Pick specific bosses</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
</div>
|
</div>
|
||||||
|
{levelCoverage === "brackets" && <p className="balance-lab-coverage-note">
|
||||||
|
{targetLevels.length} separate level jobs: {bracketStep === 1 ? "levels 1 through 80" : targetLevels.join(", ")}. Up to {runtimeStatus?.maxConcurrent ?? 4} run at the same time; each keeps its own report.
|
||||||
|
</p>}
|
||||||
{specSelection !== "random" && <p className="balance-lab-coverage-note">
|
{specSelection !== "random" && <p className="balance-lab-coverage-note">
|
||||||
{specSelection === "all"
|
{specSelection === "all"
|
||||||
? `${coveredSpecCount} class/spec builds will be covered at least once across the minimum valid party set.`
|
? `${coveredSpecCount} class/spec builds will be covered at least once across the minimum valid party set.`
|
||||||
@@ -349,8 +396,8 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
<div className="balance-lab-estimate"><span>Party configurations <b>{partyCount.toLocaleString()}</b></span><span>Class/spec coverage <b>{specSelection === "random" ? "Random" : coveredSpecCount.toLocaleString()}</b></span><span>Bosses <b>{bossCount.toLocaleString()}</b></span><span>Total simulations <b>{estimatedRuns.toLocaleString()}</b></span></div>
|
<div className="balance-lab-estimate"><span>Level jobs <b>{targetLevels.length.toLocaleString()}</b></span><span>Parties / level <b>{partyCount.toLocaleString()}</b></span><span>Bosses / level <b>{bossCount.toLocaleString()}</b></span><span>Total simulations <b>{estimatedRuns.toLocaleString()}</b></span></div>
|
||||||
<button className="front-button front-button--primary balance-lab-run" type="button" disabled={submitting || !runtimeStatus?.available || !Number.isFinite(estimatedRuns) || estimatedRuns < 1} onClick={() => void run()}>{submitting ? "Queuing…" : runtimeStatus && !runtimeStatus.available ? "Server runtime unavailable" : "Run on server"}</button>
|
<button className="front-button front-button--primary balance-lab-run" type="button" disabled={submitting || !runtimeStatus?.available || !Number.isFinite(estimatedRuns) || estimatedRuns < 1} onClick={() => void run()}>{submitting ? `Queuing ${targetLevels.length} job${targetLevels.length === 1 ? "" : "s"}…` : runtimeStatus && !runtimeStatus.available ? "Server runtime unavailable" : targetLevels.length === 1 ? "Run on server" : `Queue ${targetLevels.length} level jobs`}</button>
|
||||||
<p className="balance-lab-local-note">To spend this device's CPU instead, run <code>npm run balance:lab</code> from the project checkout.</p>
|
<p className="balance-lab-local-note">To spend this device's CPU instead, run <code>npm run balance:lab</code> from the project checkout.</p>
|
||||||
{error && <p className="front-notice" role="alert">{error}</p>}
|
{error && <p className="front-notice" role="alert">{error}</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in new issue
Block a user