Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dbb0acfc1 | ||
|
|
d6976e6ef6 | ||
|
|
d0a02487db | ||
|
|
dd583023ac |
No files matched your search
@@ -3561,3 +3561,61 @@ Vite's existing large-chunk advisory remains informational.
|
||||
effective healing as 57.8 HPS done and the Blood Death Knight's same 867 as
|
||||
57.8 HPS received. A matching automatic-suite run verified the new JSON and
|
||||
CSV fields under `playtest-artifacts/balance-lab-healing-suite/`.
|
||||
|
||||
## Balance Lab deterministic class/spec coverage (2026-09-17)
|
||||
|
||||
- Automatic sweeps now offer **Random parties**, **All specs**, and **Specific
|
||||
specs**. All-spec mode derives the minimum valid 1-tank/1-healer/3-damage
|
||||
party set that covers every available build. Specific mode guarantees every
|
||||
selected build appears and uses distinct compatible fillers for missing
|
||||
roles; selected builds are not reused as fillers when alternatives exist.
|
||||
- A real level-20 WoW all-spec sweep against Eck the Ferocious generated eight
|
||||
distinct parties and placed all 31 catalog builds in `suite-report.json`
|
||||
with zero missing. The combined catalog audit found 70 CoA builds requiring
|
||||
16 coverage parties and 16 RoM builds requiring four; every source exposes
|
||||
enough tank, healer, and damage builds for valid coverage parties.
|
||||
- Eight Python controller tests, nine Balance Lab service tests, two server
|
||||
integration tests, and the production build passed. Production Edge
|
||||
automation verified the All Specs estimate, selected Paladin Holy through the
|
||||
Specific Specs picker, completed the server job, rendered all seven overview
|
||||
graphs, and passed desktop plus 480×900 overflow checks without browser
|
||||
errors. Evidence is in `playtest-artifacts/balance-lab/02-server-job-config.png`
|
||||
and `playtest-artifacts/balance-lab/03-server-job-complete.png`.
|
||||
|
||||
## Balance Lab class/spec ability damage drill-down (2026-09-17)
|
||||
|
||||
- Every class/spec row now exposes **View ability damage** when ability telemetry
|
||||
exists. It opens the Abilities tab on that exact source/build, clears
|
||||
conflicting search and role filters, and defaults to abilities with positive
|
||||
effective damage sorted highest first.
|
||||
- The drill-down graph and table retain ability name/ID, casts, casts per minute,
|
||||
hits, effective/raw damage, damage share, DPS, and damage per hit. Healing,
|
||||
overheal, absorbs, usage, and unused-ability filters remain available.
|
||||
- The production build passed. A real five-second level-20 WoW run against Eck
|
||||
the Ferocious opened a Death Knight Unholy row through the new action and
|
||||
verified positive effective ability damage plus the expected cast, damage
|
||||
share, and DPS columns. Desktop and 480×900 checks passed with no browser
|
||||
errors. Evidence is in
|
||||
`playtest-artifacts/balance-lab/04b-ability-breakdown.png` and
|
||||
`playtest-artifacts/balance-lab/06-mobile-abilities.png`.
|
||||
|
||||
## WoW full-spec mechanics and rotation pass (2026-09-18)
|
||||
|
||||
- `playerbots-inspired-v3` now gives every one of the 31 Wrath specs a legal,
|
||||
level-scaled talent plan and executable profile. The pass repaired stale
|
||||
talent prerequisites, companion Rage/Runic Power persistence, short melee
|
||||
reach, warrior rage banking, and explicit profile ordering.
|
||||
- Scripted payloads now make Bloodthirst, Bloodrage, Execute, Mutilate, Killing
|
||||
Spree, Penance, Feral Spirit, Conflagrate, and Earth Shield mechanically
|
||||
meaningful. Frost/Unholy disease setup, Arms Rend/Mortal Strike order, Arcane
|
||||
Barrage, and healer maintenance behavior have direct regression coverage.
|
||||
- The exhaustive level-80 headless audit completed 2,672 fights with no
|
||||
simulation failures under `artifacts/balance-fixes-20260918-level80/`.
|
||||
The final 40-run Stockades confirmation under
|
||||
`artifacts/balance-fixes-20260918-stockade-level80-final/` exercised all 31
|
||||
specs: Feral Spirit, Bloodrage, and Earth Shield were active in every eligible
|
||||
run, while the repaired damage signatures produced damage in their applicable
|
||||
runs. Low-level boss floors were recalibrated so ordinary/final Stockades
|
||||
encounters still retain their 25/45-second mechanic windows. These are
|
||||
structured combat checks, so no scene screenshot is applicable. Remaining
|
||||
cross-spec DPS/HPS outliers require a separate coefficient-tuning pass.
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
+63
-17
@@ -25,10 +25,28 @@ The prompt selects:
|
||||
1. one random party, five manual members, or an automatic multi-party sweep;
|
||||
2. a WoW, CoA, or RoM party catalog; automatic mode can select all three;
|
||||
3. a level from 1 through 80;
|
||||
4. how many unique 1-tank/1-healer/3-damage configurations to generate per source;
|
||||
5. all bosses, WoW bosses, RoM bosses, or a searchable multi-boss selection;
|
||||
6. how many deterministic simulations to run for every party/boss pairing; and
|
||||
7. the maximum duration of each fight.
|
||||
4. random parties, guaranteed coverage of all class/spec builds, or a
|
||||
searchable selection of specific class/spec builds;
|
||||
5. how many unique 1-tank/1-healer/3-damage configurations to generate per
|
||||
source when random selection is used;
|
||||
6. all bosses, WoW bosses, RoM bosses, or a searchable multi-boss selection;
|
||||
7. how many deterministic simulations to run for every party/boss pairing; and
|
||||
8. the maximum duration of each fight.
|
||||
|
||||
Automatic class/spec selection has three strategies:
|
||||
|
||||
- **Random parties** preserves the original seeded behavior and generates the
|
||||
requested number of unique parties per source.
|
||||
- **All specs** creates the minimum deterministic set of valid parties needed
|
||||
to include every available class/spec build for the selected WoW, CoA, or
|
||||
RoM source at least once.
|
||||
- **Specific specs** treats each selected build as required coverage and fills
|
||||
the remaining tank, healer, and damage slots with distinct compatible builds.
|
||||
When alternatives exist, required builds are not reused as fillers.
|
||||
|
||||
Coverage parties always contain one tank, one healer, and three different
|
||||
damage builds. Their composition is deterministic for the party seed, and no
|
||||
build is duplicated inside one party.
|
||||
|
||||
The active member is driven by the shared benchmark policy while the other four
|
||||
use the live companion AI. The healer is the default active member. Use
|
||||
@@ -38,8 +56,10 @@ 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
|
||||
the main menu. That screen exposes the automatic sweep controls: party source,
|
||||
level, unique parties per source, simulations per party/boss, duration, random
|
||||
seed, boss category, and a searchable multi-boss picker.
|
||||
level, class/spec selection strategy, a searchable spec picker, simulations per
|
||||
party/boss, duration, random seed, boss category, and a searchable multi-boss
|
||||
picker. 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
|
||||
GM API. The server places it in a one-at-a-time queue, launches the same Python
|
||||
@@ -86,7 +106,8 @@ The dashboard includes:
|
||||
defeat/wipe rates, deaths, and health;
|
||||
- complete class/spec tables with total and boss DPS, healing done/received per
|
||||
second, DTPS, absolute damage taken, healing done/received and absorb totals,
|
||||
deaths, and source/role-median tuning flags; and
|
||||
deaths, source/role-median tuning flags, and a direct ability-damage
|
||||
drill-down for every build; and
|
||||
- a complete Abilities tab with learned/available abilities (including zero-use
|
||||
rows), exact runtime IDs, AI categories, active-run coverage, casts, hits,
|
||||
casts per minute, effective/raw damage, DPS, damage share, damage per hit,
|
||||
@@ -98,7 +119,10 @@ The overview graphs intentionally show at most the 15 highest visible values so
|
||||
labels remain readable. They never discard report data: the Bosses, Class/spec,
|
||||
and Parties tabs retain every matching row. Search, party-source, boss-source,
|
||||
role, metric, and sort-direction controls filter the display without modifying
|
||||
the saved report.
|
||||
the saved report. **View ability damage** on a class/spec row opens the
|
||||
Abilities tab with that exact build selected, damaging abilities ranked by
|
||||
effective damage, and its casts, hits, DPS, damage share, and damage-per-hit
|
||||
details visible in the table. **Show all specs** clears that drill-down.
|
||||
|
||||
Server requirements:
|
||||
|
||||
@@ -258,18 +282,22 @@ under `parties/<party-id>/` and adds these suite-level files:
|
||||
`totalHealing` and `totalHealingReceived` are the corresponding absolute
|
||||
values across the included runs. Reports created before target attribution
|
||||
was added remain readable but require a rerun to populate healing received.
|
||||
- Ability `activeRuns` counts simulations with at least one cast, hit, damage,
|
||||
healing, overheal, or absorb 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,
|
||||
- Ability `activeRuns` counts simulations with at least one cast, completed
|
||||
non-throughput release, hit, damage, healing, overheal, or absorb event.
|
||||
Completed resource, aura, pet, and summon actions are recorded as casts even
|
||||
though they create no immediate throughput event. `usageRate` divides that by
|
||||
every run for that build; `damageShare` divides the ability's effective
|
||||
damage by the class/spec's total effective damage. `available · unused` means
|
||||
the ability was learned and executable at the tested level/talent setup but
|
||||
emitted no combat event. This is a review signal, not proof of an AI defect:
|
||||
resurrection,
|
||||
execute, interrupt, dispel, emergency, and long-cooldown abilities can be
|
||||
legitimately unused when their condition never occurs.
|
||||
- Companion AI currently applies several instant attacks directly and therefore
|
||||
may emit hits/damage without a separate cast event. Use **Active runs** and
|
||||
**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 for that exact party. Suite boss flags compare across all generated
|
||||
parties. Suite class/spec flags compare DPS with every observed spec from the
|
||||
@@ -305,6 +333,24 @@ member. When a suspected class outlier could be policy-sensitive, rerun with a
|
||||
different `--active-slot`; do not compare active and companion results without
|
||||
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-v3` 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.
|
||||
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, and short-range melee abilities use companion combat reach.
|
||||
|
||||
## Code map
|
||||
|
||||
- `scripts/balance_lab.py`: prompts, command-line parsing, random/manual/automatic
|
||||
@@ -327,8 +373,8 @@ noting that control-policy difference.
|
||||
- `src/game/benchmark/benchmarkBuilds.ts`: selectable spec builds and legal
|
||||
talent allocation. The older canonical cohort remains unchanged.
|
||||
- `src/game/partyStore.ts`: `createConfiguredPartyMember`, the narrow factory
|
||||
that preserves production stats, equipment, resources, and appearance for an
|
||||
exact companion.
|
||||
that preserves production stats, equipment, all learned-ability resource
|
||||
pools, and appearance for an exact companion.
|
||||
- `src/game/bossTesting.ts`: the authoritative all-boss catalog and isolated
|
||||
room construction.
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 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 |
|
||||
| --- | --- |
|
||||
@@ -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. |
|
||||
| Major cooldowns | Offensive cooldowns of 60 seconds or longer are held for bosses or durable targets. |
|
||||
|
||||
`playerbots-inspired-v3` also generates legal level-scaled talent plans for all
|
||||
31 classic specializations. Each level-80 plan spends 51 points in the selected
|
||||
tree and 20 in a compatible secondary tree, prefers executable active talents
|
||||
and their prerequisites, and sanitizes obsolete imported prerequisite links.
|
||||
Runtime resource generation is actor-authoritative without erasing the action's
|
||||
cooldown/GCD, and companion melee abilities share the formation combat-reach
|
||||
leeway already used by weapon swings.
|
||||
|
||||
CoA and Runes of Magic builds use the same semantic rules when no classic profile applies. Runes of Magic candidates include primary, secondary-general, and primary/secondary elite skills through `abilitiesForCharacter`.
|
||||
|
||||
## Classic specialization profiles
|
||||
@@ -24,9 +34,9 @@ The leftmost available action wins after reactive rules, learned-talent gating,
|
||||
|
||||
| Class | Specialization | Maintenance and core priority |
|
||||
| --- | --- | --- |
|
||||
| Warrior | Arms | Battle Stance; Mortal Strike; Rend; Overpower; Execute; Slam; Heroic Strike |
|
||||
| Warrior | Fury | Berserker Stance; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike |
|
||||
| Warrior | Protection | Defensive Stance; Shield Slam; Revenge; Devastate; Thunder Clap; Sunder Armor; Heroic Strike |
|
||||
| Warrior | Arms | Battle Stance; Bloodrage; Rend; Mortal Strike; Overpower; Execute; Slam; Heroic Strike |
|
||||
| Warrior | Fury | Berserker/Battle Stance; Bloodrage; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike |
|
||||
| Warrior | Protection | Defensive/Battle Stance; Bloodrage; Shield Slam; Revenge; Devastate; Thunder Clap; Sunder Armor; Heroic Strike |
|
||||
| Paladin | Holy | Concentration Aura; Blessing of Might; Holy Shock; Flash of Light; Holy Light; Sacred Shield; Judgement of Light |
|
||||
| Paladin | Protection | Devotion Aura; Blessing of Might; Hammer of the Righteous; Shield of Righteousness; Holy Shield; Consecration; Judgement of Wisdom |
|
||||
| Paladin | Retribution | Retribution Aura; Blessing of Might; Divine Storm; Crusader Strike; Judgement of Wisdom; Consecration; Exorcism; Hammer of Wrath |
|
||||
@@ -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 | Shadow | Shadowform; Inner Fire; Vampiric Touch; Devouring Plague; Shadow Word: Pain; Mind Blast; Mind Flay; Shadow Word: Death |
|
||||
| Death Knight | Blood | Frost Presence (Blood Presence fallback); Heart Strike; Death Strike; Icy Touch; Plague Strike; Death Coil |
|
||||
| Death Knight | Frost | Blood Presence; Howling Blast; Obliterate; Frost Strike; Icy Touch; Plague Strike |
|
||||
| Death Knight | Unholy | Unholy Presence (Blood Presence fallback); Raise Dead; Scourge Strike; Death and Decay; Plague Strike; Icy Touch; Death Coil |
|
||||
| Death Knight | Frost | Blood Presence; Icy Touch; Plague Strike; Howling Blast; Frost Strike; Obliterate |
|
||||
| Death Knight | Unholy | Unholy Presence (Blood Presence fallback); Raise Dead; Plague Strike; Icy Touch; Scourge Strike; Death Coil; Death and Decay |
|
||||
| Shaman | Elemental | Flametongue Weapon (Rockbiter fallback); Lightning Shield; Flame Shock; Lava Burst; Chain Lightning; Lightning Bolt |
|
||||
| Shaman | Enhancement | Windfury Weapon (Rockbiter fallback); Lightning Shield; Feral Spirit; Stormstrike; Lava Lash; Earth Shock; Lightning Bolt |
|
||||
| Shaman | Restoration | Water Shield; Rockbiter Weapon; Lightning Shield; Riptide; Chain Heal; Lesser Healing Wave; Healing Wave; Earth Shield |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.54",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.54",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.54",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.50",
|
||||
"versionCode": 1050
|
||||
"versionName": "0.1.54",
|
||||
"versionCode": 1054
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../../src/app/characterCatalog";
|
||||
import type { ContentCategoryId } from "../../src/app/contentCategories";
|
||||
import {
|
||||
abilityById,
|
||||
abilitiesForCharacter,
|
||||
learnedAbilityForBuild,
|
||||
preloadAbilityCatalogsForClasses,
|
||||
@@ -17,6 +18,7 @@ import { aiHintsForAbility } from "../../src/game/abilityRules";
|
||||
import { PLAYER_AGGRO_ID } from "../../src/game/aggro";
|
||||
import { installCombatClock } from "../../src/game/combatClock";
|
||||
import { subscribeCombatEvents } from "../../src/game/combatEvents";
|
||||
import { subscribeCombatPresentation } from "../../src/game/combatPresentation";
|
||||
import { resetCombatSpatialProvider } from "../../src/game/combatSpatial";
|
||||
import { useCombatStore } from "../../src/game/combatStore";
|
||||
import { dungeonEnemyScaling } from "../../src/game/dungeonBossScaling";
|
||||
@@ -238,6 +240,13 @@ function abilityMetrics(member: MutableMemberMetrics, abilityId: string | undefi
|
||||
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] {
|
||||
const combat = useCombatStore.getState();
|
||||
if (actorId === PLAYER_AGGRO_ID) return [combat.health, combat.maxHealth];
|
||||
@@ -371,10 +380,22 @@ async function runBoss(
|
||||
}));
|
||||
const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics]));
|
||||
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 source = byActorId.get(event.sourceActorId);
|
||||
const target = byActorId.get(event.targetActorId);
|
||||
if (event.kind === "cast" && source) {
|
||||
if (!abilityProducesAttributedCombatEvent(event.abilityId)) return;
|
||||
source.casts += 1;
|
||||
abilityMetrics(source, event.abilityId).casts += 1;
|
||||
} else if (event.kind === "damage") {
|
||||
@@ -531,6 +552,7 @@ async function runBoss(
|
||||
|| left.abilityId.localeCompare(right.abilityId)),
|
||||
};
|
||||
} finally {
|
||||
stopPresentation();
|
||||
stopEvents();
|
||||
restoreClock();
|
||||
clearMobRuntimeRegistry();
|
||||
|
||||
+155
-3
@@ -195,6 +195,92 @@ def automatic_parties(
|
||||
return configurations
|
||||
|
||||
|
||||
def coverage_parties(
|
||||
builds: Sequence[dict[str, Any]],
|
||||
sources: Sequence[str],
|
||||
selected_build_ids: Sequence[str] | None,
|
||||
seed: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the minimum deterministic party set that covers every requested build."""
|
||||
requested = set(selected_build_ids) if selected_build_ids is not None else None
|
||||
offsets = {"wow": 100_000, "coa": 200_000, "rom": 300_000}
|
||||
configurations: list[dict[str, Any]] = []
|
||||
for source in sources:
|
||||
source_builds = [build for build in builds if build["source"] == source]
|
||||
targets = source_builds if requested is None else [
|
||||
build for build in source_builds if build["id"] in requested
|
||||
]
|
||||
if not targets:
|
||||
continue
|
||||
available_by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
targets_by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for build in source_builds:
|
||||
available_by_role[build["role"]].append(build)
|
||||
for build in targets:
|
||||
targets_by_role[build["role"]].append(build)
|
||||
if not available_by_role["tank"] or not available_by_role["healer"] or len(available_by_role["damage"]) < 3:
|
||||
raise SystemExit(
|
||||
f"The {source} catalog does not expose enough tank, healer, and damage builds for a 1/1/3 party."
|
||||
)
|
||||
party_count = max(
|
||||
1,
|
||||
len(targets_by_role["tank"]),
|
||||
len(targets_by_role["healer"]),
|
||||
math.ceil(len(targets_by_role["damage"]) / 3),
|
||||
)
|
||||
generator = random.Random(seed + offsets[source])
|
||||
for rows in (*available_by_role.values(), *targets_by_role.values()):
|
||||
rows.sort(key=lambda build: build["id"])
|
||||
generator.shuffle(rows)
|
||||
|
||||
def filler(role: str, party_index: int, slot_index: int, used: set[str]) -> dict[str, Any]:
|
||||
pool = available_by_role[role]
|
||||
start = (party_index * (3 if role == "damage" else 1) + slot_index) % len(pool)
|
||||
for avoid_requested in (True, False):
|
||||
for offset in range(len(pool)):
|
||||
candidate = pool[(start + offset) % len(pool)]
|
||||
if candidate["id"] in used:
|
||||
continue
|
||||
if avoid_requested and requested is not None and candidate["id"] in requested:
|
||||
continue
|
||||
return candidate
|
||||
raise SystemExit(f"Could not fill a unique {role} slot for a {source} coverage party.")
|
||||
|
||||
for party_index in range(party_count):
|
||||
used: set[str] = set()
|
||||
tank = targets_by_role["tank"][party_index] if party_index < len(targets_by_role["tank"]) else filler(
|
||||
"tank", party_index, 0, used
|
||||
)
|
||||
used.add(tank["id"])
|
||||
healer = targets_by_role["healer"][party_index] if party_index < len(targets_by_role["healer"]) else filler(
|
||||
"healer", party_index, 0, used
|
||||
)
|
||||
used.add(healer["id"])
|
||||
damage = list(targets_by_role["damage"][party_index * 3:(party_index + 1) * 3])
|
||||
used.update(build["id"] for build in damage)
|
||||
while len(damage) < 3:
|
||||
build = filler("damage", party_index, len(damage), used)
|
||||
damage.append(build)
|
||||
used.add(build["id"])
|
||||
party = [tank, healer, *damage]
|
||||
configurations.append({
|
||||
"id": f"{source}-party-{party_index + 1:03d}",
|
||||
"source": source,
|
||||
"party": party,
|
||||
"activeSlot": 1,
|
||||
})
|
||||
if not configurations:
|
||||
raise SystemExit("No class/spec builds matched the requested coverage selection.")
|
||||
return configurations
|
||||
|
||||
|
||||
def search_builds(query: str, builds: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
needle = normalized(query)
|
||||
return [build for build in builds if needle in normalized(
|
||||
f"{build['id']} {build['label']} {build['source']} {build['role']}"
|
||||
)]
|
||||
|
||||
|
||||
def search_bosses(query: str, bosses: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
needle = normalized(query)
|
||||
return [boss for boss in bosses if needle in normalized(
|
||||
@@ -273,6 +359,43 @@ def prompt_specific_bosses(bosses: Sequence[dict[str, Any]]) -> list[str]:
|
||||
print(f"Selected bosses: {len(selected)}")
|
||||
|
||||
|
||||
def prompt_specific_builds(builds: Sequence[dict[str, Any]]) -> list[str]:
|
||||
selected: dict[str, dict[str, Any]] = {}
|
||||
print("\nClass/spec picker: search by class, specialization, role, source, or build ID.")
|
||||
while True:
|
||||
suffix = f" ({len(selected)} selected; type 'done' to finish)" if selected else ""
|
||||
query = input(f"Class/spec search{suffix}: ").strip()
|
||||
if query.casefold() == "done":
|
||||
if selected:
|
||||
return list(selected)
|
||||
print("Select at least one class/spec first.")
|
||||
continue
|
||||
matches = search_builds(query, builds)
|
||||
if not matches:
|
||||
print("No class/spec builds matched that search.")
|
||||
continue
|
||||
if len(matches) > 50:
|
||||
print(f"{len(matches)} builds matched. Use a more specific search (50 maximum per result page).")
|
||||
continue
|
||||
for index, build in enumerate(matches, 1):
|
||||
marker = " [selected]" if build["id"] in selected else ""
|
||||
print(f" {index:>2}. {console_text(build['label'])} [{build['source']}]{marker}")
|
||||
raw = input("Add numbers separated by commas, or 'all': ").strip().casefold()
|
||||
if raw == "all":
|
||||
indexes = list(range(len(matches)))
|
||||
else:
|
||||
try:
|
||||
indexes = [int(entry.strip()) - 1 for entry in raw.split(",") if entry.strip()]
|
||||
except ValueError:
|
||||
indexes = []
|
||||
if not indexes or any(index < 0 or index >= len(matches) for index in indexes):
|
||||
print("Choose valid result numbers.")
|
||||
continue
|
||||
for index in indexes:
|
||||
selected[matches[index]["id"]] = matches[index]
|
||||
print(f"Selected class/spec builds: {len(selected)}")
|
||||
|
||||
|
||||
def interactive_arguments(
|
||||
args: argparse.Namespace,
|
||||
workspace: Path,
|
||||
@@ -288,7 +411,11 @@ def interactive_arguments(
|
||||
if args.party_mode == "random":
|
||||
args.party_seed = prompt_integer("Random party seed", 1, -2_147_483_648, 2_147_483_647)
|
||||
elif args.party_mode == "automatic":
|
||||
args.party_configurations = prompt_integer("Unique party configurations per source", 10, 1, 1_000)
|
||||
args.spec_selection = prompt_choice("Class/spec selection", ("random", "all", "specific"), "all")
|
||||
if args.spec_selection == "random":
|
||||
args.party_configurations = prompt_integer("Unique party configurations per source", 10, 1, 1_000)
|
||||
elif args.spec_selection == "specific":
|
||||
args.spec = prompt_specific_builds(catalog["builds"])
|
||||
args.party_seed = prompt_integer("Automatic party seed", 1, -2_147_483_648, 2_147_483_647)
|
||||
else:
|
||||
print("\nAvailable members:")
|
||||
@@ -1159,6 +1286,10 @@ def parser() -> argparse.ArgumentParser:
|
||||
result.add_argument("--source", choices=("wow", "coa", "rom", "all"), help="party catalog; all requires automatic mode")
|
||||
result.add_argument("--level", type=int, default=60, help="party level (default: 60)")
|
||||
result.add_argument("--party-mode", choices=("random", "manual", "automatic"), default="random")
|
||||
result.add_argument("--spec-selection", choices=("random", "all", "specific"), default="random",
|
||||
help="automatic party strategy (default: random)")
|
||||
result.add_argument("--spec", action="append", default=[],
|
||||
help="required build ID or class:spec selector for --spec-selection specific")
|
||||
result.add_argument("--party-seed", type=int, default=1, help="random party seed")
|
||||
result.add_argument("--party-configurations", type=int, default=10, help="automatic parties per selected source")
|
||||
result.add_argument("--member", action="append", default=[], help="manual build ID or class:spec selector; repeat five times")
|
||||
@@ -1215,7 +1346,25 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
seeds = args.seeds or list(range(1, args.simulations_per_party + 1))
|
||||
if args.party_mode == "automatic":
|
||||
sources = ("wow", "coa", "rom") if args.source == "all" else (args.source,)
|
||||
configurations = automatic_parties(builds, sources, args.party_configurations, args.party_seed)
|
||||
if args.spec_selection != "specific" and args.spec:
|
||||
session.close()
|
||||
raise SystemExit("--spec values require --spec-selection specific")
|
||||
if args.spec_selection == "random":
|
||||
configurations = automatic_parties(builds, sources, args.party_configurations, args.party_seed)
|
||||
selected_build_ids: list[str] = []
|
||||
else:
|
||||
if args.spec_selection == "specific" and not args.spec:
|
||||
session.close()
|
||||
raise SystemExit("--spec-selection specific requires at least one --spec value")
|
||||
selected_build_ids = list(dict.fromkeys(
|
||||
resolve_build(selector, builds)["id"] for selector in args.spec
|
||||
))
|
||||
configurations = coverage_parties(
|
||||
builds,
|
||||
sources,
|
||||
None if args.spec_selection == "all" else selected_build_ids,
|
||||
args.party_seed,
|
||||
)
|
||||
if args.active_slot is not None:
|
||||
if not 1 <= args.active_slot <= 5:
|
||||
session.close()
|
||||
@@ -1258,10 +1407,13 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
"policyId": catalog["policyId"],
|
||||
"rotationVersion": catalog["rotationVersion"],
|
||||
"mode": "automatic",
|
||||
"specSelection": args.spec_selection,
|
||||
"selectedBuildIds": selected_build_ids,
|
||||
"source": args.source,
|
||||
"level": args.level,
|
||||
"partySeed": args.party_seed,
|
||||
"partyConfigurationsPerSource": args.party_configurations,
|
||||
"partyConfigurationsPerSource": args.party_configurations if args.spec_selection == "random" else None,
|
||||
"partyConfigurations": len(configurations),
|
||||
"simulationsPerParty": len(seeds),
|
||||
"seeds": seeds,
|
||||
"durationSeconds": args.duration,
|
||||
|
||||
@@ -56,7 +56,12 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
|
||||
assert.match(await page.locator('.balance-lab-runtime').innerText(), /Python 3\./);
|
||||
await page.getByLabel('Party source').selectOption('wow');
|
||||
await page.getByLabel('Level').fill('20');
|
||||
await page.getByLabel('Parties per source').fill('1');
|
||||
await page.getByLabel('Class/spec selection').selectOption('all');
|
||||
assert(Number(await page.getByLabel('Coverage parties').inputValue()) > 1, 'all-spec coverage should derive multiple parties');
|
||||
await page.getByLabel('Class/spec selection').selectOption('specific');
|
||||
await page.getByLabel('Search Balance Lab class specs').fill('Paladin Holy');
|
||||
await page.getByRole('checkbox', { name: /Paladin · Holy/ }).check();
|
||||
assert.equal(await page.getByLabel('Coverage parties').inputValue(), '1');
|
||||
await page.getByLabel('Runs per party/boss').fill('1');
|
||||
await page.getByLabel('Fight limit (seconds)').fill('5');
|
||||
await page.getByLabel('Bosses').selectOption('specific');
|
||||
@@ -75,7 +80,7 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
|
||||
|
||||
await page.getByRole('button', { name: 'View graphs and filterable data' }).click();
|
||||
await page.locator('.balance-results').waitFor();
|
||||
assert.equal(await page.locator('.balance-results-chart').count(), 5);
|
||||
assert.equal(await page.locator('.balance-results-chart').count(), 7);
|
||||
assert.match(await page.locator('.balance-results-chart').filter({ hasText: 'Boss damage to party' }).innerText(), /Eck the Ferocious/);
|
||||
await capture('04-run-analytics');
|
||||
|
||||
@@ -91,9 +96,23 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
|
||||
const damageRows = await page.locator('.balance-results-table-panel tbody tr').count();
|
||||
assert(damageRows > 0 && damageRows < allClassRows, 'role filtering should narrow the complete class/spec table');
|
||||
await page.getByLabel('Class role').selectOption('all');
|
||||
await page.getByRole('button', { name: 'abilities', exact: true }).click();
|
||||
const damageSpecRow = page.locator('.balance-results-table-panel tbody tr').filter({
|
||||
has: page.locator('td:nth-child(3)').getByText('damage', { exact: true }),
|
||||
}).first();
|
||||
const damageSpecClass = await damageSpecRow.locator('td:first-child strong').innerText();
|
||||
const damageSpecName = await damageSpecRow.locator('td:first-child small').innerText();
|
||||
await damageSpecRow.getByRole('button', { name: `View ${damageSpecClass} ${damageSpecName} ability damage` }).click();
|
||||
await page.getByRole('heading', { name: `${damageSpecClass} · ${damageSpecName} ability damage` }).waitFor();
|
||||
assert.notEqual(await page.getByLabel('Filter ability class spec').inputValue(), 'all');
|
||||
assert.equal(await page.getByLabel('Filter ability usage').inputValue(), 'damage');
|
||||
const abilityRows = page.locator('.balance-results-abilities tbody tr');
|
||||
assert(await abilityRows.count() > 0, 'ability breakdown should retain observed and available abilities');
|
||||
assert(await abilityRows.count() > 0, 'spec drill-down should show abilities that dealt damage');
|
||||
assert(Number((await abilityRows.first().locator('td:nth-child(12)').innerText()).replaceAll(',', '')) > 0, 'drilled ability rows should include effective damage');
|
||||
const abilityHeaders = await page.locator('.balance-results-abilities thead').innerText();
|
||||
assert.match(abilityHeaders, /CASTS/);
|
||||
assert.match(abilityHeaders, /EFFECTIVE DAMAGE/);
|
||||
assert.match(abilityHeaders, /DAMAGE SHARE/);
|
||||
assert.match(abilityHeaders, /DPS/);
|
||||
await page.getByLabel('Filter ability usage').selectOption('unused');
|
||||
assert(await abilityRows.count() > 0, 'available but unused abilities should remain visible');
|
||||
await page.getByLabel('Filter ability usage').selectOption('all');
|
||||
|
||||
+132
-13
@@ -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 app must be stopped and started from the TrueNAS Apps UI around that block.
|
||||
@@ -18,6 +18,7 @@ import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import webbrowser
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable, Sequence
|
||||
@@ -59,6 +60,17 @@ FORBIDDEN_SUFFIXES = {
|
||||
ALLOWED_GENERATED_FILES = {
|
||||
"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):
|
||||
@@ -69,6 +81,23 @@ class ReleaseCancelled(ReleaseError):
|
||||
"""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)
|
||||
class ReleaseVersion:
|
||||
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]:
|
||||
match = SEMVER_PATTERN.fullmatch(value.strip())
|
||||
if not match:
|
||||
@@ -1116,7 +1175,25 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
)
|
||||
|
||||
for command, label in full_release_checks(npm):
|
||||
self._run_command(command, label)
|
||||
try:
|
||||
self._run_command(command, label)
|
||||
except ReleaseCommandError as failure:
|
||||
if not can_override_release_check(label):
|
||||
raise
|
||||
approved = self._request_approval(
|
||||
format_failed_test_warning(failure),
|
||||
title=f"Warning: {label} failed",
|
||||
heading="Tests failed — continue the release?",
|
||||
approve_label="Continue Anyway",
|
||||
cancel_label="Stop Release",
|
||||
)
|
||||
if not approved:
|
||||
raise ReleaseCancelled(
|
||||
f"Release stopped because {label} failed."
|
||||
) from failure
|
||||
self._log_event(
|
||||
f"⚠ Continuing after failed {label}; override approved by user."
|
||||
)
|
||||
self._run_command(["git", "add", "-A"], "Stage release")
|
||||
staged = True
|
||||
|
||||
@@ -1267,31 +1344,66 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
)
|
||||
self.current_process = process
|
||||
assert process.stdout is not None
|
||||
output_lines: deque[str] = deque(maxlen=FAILURE_OUTPUT_LINE_LIMIT)
|
||||
try:
|
||||
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()
|
||||
finally:
|
||||
self.current_process = None
|
||||
if self.cancel_event.is_set():
|
||||
raise ReleaseCancelled("Release cancelled.")
|
||||
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")
|
||||
|
||||
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] = {}
|
||||
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):
|
||||
if self.cancel_event.is_set():
|
||||
return 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}
|
||||
dialog = tk.Toplevel(self.root)
|
||||
dialog.title("Review staged release")
|
||||
dialog.title(title)
|
||||
dialog.geometry("820x640")
|
||||
dialog.minsize(640, 420)
|
||||
dialog.configure(bg=self.BG)
|
||||
@@ -1301,7 +1413,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
dialog.columnconfigure(0, weight=1)
|
||||
ttk.Label(
|
||||
dialog,
|
||||
text="Inspect every staged path before publishing",
|
||||
text=heading,
|
||||
style="Title.TLabel",
|
||||
).grid(row=0, column=0, sticky="w", padx=18, pady=(16, 10))
|
||||
review_text = scrolledtext.ScrolledText(
|
||||
@@ -1326,12 +1438,12 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
dialog.grab_release()
|
||||
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"
|
||||
)
|
||||
ttk.Button(
|
||||
buttons,
|
||||
text="Commit, Push, and Publish",
|
||||
text=approve_label,
|
||||
style="Accent.TButton",
|
||||
command=lambda: finish(True),
|
||||
).pack(side="right")
|
||||
@@ -1375,8 +1487,15 @@ def launch_gui(*, smoke_test: bool = False) -> int:
|
||||
if kind == "log":
|
||||
self._append_log(str(event[1]))
|
||||
elif kind == "approval":
|
||||
review, response, ready = event[1], event[2], event[3]
|
||||
approved = self._show_staged_review(str(review))
|
||||
review, title, heading, approve_label, cancel_label = event[1:6]
|
||||
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)
|
||||
response["approved"] = approved
|
||||
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", "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):
|
||||
def test_checks_untracked_files_without_changing_the_real_index(self):
|
||||
|
||||
@@ -4,6 +4,7 @@ from scripts.balance_lab import (
|
||||
aggregate_class_abilities,
|
||||
aggregate_class_specs,
|
||||
automatic_parties,
|
||||
coverage_parties,
|
||||
random_party,
|
||||
search_bosses,
|
||||
tuning_label,
|
||||
@@ -57,6 +58,44 @@ class BalanceLabControllerTests(unittest.TestCase):
|
||||
"tank", "healer", "damage", "damage", "damage",
|
||||
])
|
||||
|
||||
def test_coverage_parties_include_every_build_with_minimum_unique_parties(self) -> None:
|
||||
builds = [
|
||||
{"id": "tank-a", "source": "wow", "role": "tank"},
|
||||
{"id": "tank-b", "source": "wow", "role": "tank"},
|
||||
{"id": "healer-a", "source": "wow", "role": "healer"},
|
||||
{"id": "healer-b", "source": "wow", "role": "healer"},
|
||||
*({"id": f"damage-{index}", "source": "wow", "role": "damage"} for index in range(7)),
|
||||
]
|
||||
first = coverage_parties(builds, ("wow",), None, 19)
|
||||
second = coverage_parties(builds, ("wow",), None, 19)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(len(first), 3)
|
||||
covered = {member["id"] for configuration in first for member in configuration["party"]}
|
||||
self.assertEqual(covered, {build["id"] for build in builds})
|
||||
signatures = {tuple(member["id"] for member in configuration["party"]) for configuration in first}
|
||||
self.assertEqual(len(signatures), 3)
|
||||
for configuration in first:
|
||||
self.assertEqual([member["role"] for member in configuration["party"]], [
|
||||
"tank", "healer", "damage", "damage", "damage",
|
||||
])
|
||||
self.assertEqual(len({member["id"] for member in configuration["party"]}), 5)
|
||||
|
||||
def test_specific_coverage_uses_fillers_but_guarantees_selected_builds(self) -> None:
|
||||
builds = [
|
||||
{"id": "tank-a", "source": "wow", "role": "tank"},
|
||||
{"id": "tank-b", "source": "wow", "role": "tank"},
|
||||
{"id": "healer-a", "source": "wow", "role": "healer"},
|
||||
{"id": "healer-b", "source": "wow", "role": "healer"},
|
||||
*({"id": f"damage-{index}", "source": "wow", "role": "damage"} for index in range(5)),
|
||||
]
|
||||
selected = ["tank-a", "tank-b", "damage-4"]
|
||||
configurations = coverage_parties(builds, ("wow",), selected, 7)
|
||||
self.assertEqual(len(configurations), 2)
|
||||
observed = [member["id"] for configuration in configurations for member in configuration["party"]]
|
||||
covered = set(observed)
|
||||
self.assertTrue(set(selected).issubset(covered))
|
||||
self.assertTrue(all(observed.count(build_id) == 1 for build_id in selected))
|
||||
|
||||
def test_boss_search_matches_name_dungeon_key_and_source(self) -> None:
|
||||
bosses = [
|
||||
{"key": "gundrak/eck", "name": "Eck the Ferocious", "dungeonTitle": "Gundrak", "category": "wow"},
|
||||
|
||||
@@ -15,6 +15,7 @@ import { GameDatabaseError } from "./database.mjs";
|
||||
|
||||
const SOURCES = new Set(["wow", "coa", "rom", "all"]);
|
||||
const BOSS_CATEGORIES = new Set(["all", "wow", "rom"]);
|
||||
const SPEC_SELECTIONS = new Set(["random", "all", "specific"]);
|
||||
const JOB_STATUSES = new Set(["queued", "running", "complete", "failed"]);
|
||||
const JOB_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;
|
||||
@@ -43,12 +44,16 @@ function integer(value, name, minimum, maximum) {
|
||||
export function normalizeBalanceLabJobSpec(input) {
|
||||
const source = String(input?.source ?? "all").toLowerCase();
|
||||
const bossCategory = String(input?.bossCategory ?? "all").toLowerCase();
|
||||
const specSelection = String(input?.specSelection ?? "random").toLowerCase();
|
||||
if (!SOURCES.has(source)) {
|
||||
throw new GameDatabaseError("Party source must be wow, coa, rom, or all.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
if (!BOSS_CATEGORIES.has(bossCategory)) {
|
||||
throw new GameDatabaseError("Boss category must be all, wow, or rom.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
if (!SPEC_SELECTIONS.has(specSelection)) {
|
||||
throw new GameDatabaseError("Class/spec selection must be random, all, or specific.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
const rawBossKeys = Array.isArray(input?.bossKeys) ? input.bossKeys : [];
|
||||
if (rawBossKeys.length > 500) {
|
||||
throw new GameDatabaseError("Select no more than 500 bosses.", 400, "invalid_balance_lab_job");
|
||||
@@ -57,8 +62,21 @@ export function normalizeBalanceLabJobSpec(input) {
|
||||
if (bossKeys.some((key) => key.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(key))) {
|
||||
throw new GameDatabaseError("A selected boss key is invalid.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
const rawBuildIds = Array.isArray(input?.selectedBuildIds) ? input.selectedBuildIds : [];
|
||||
if (rawBuildIds.length > 200) {
|
||||
throw new GameDatabaseError("Select no more than 200 class/spec builds.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
const selectedBuildIds = [...new Set(rawBuildIds.map((value) => String(value).trim()).filter(Boolean))];
|
||||
if (selectedBuildIds.some((id) => id.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(id))) {
|
||||
throw new GameDatabaseError("A selected class/spec build ID is invalid.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
if (specSelection === "specific" && selectedBuildIds.length === 0) {
|
||||
throw new GameDatabaseError("Select at least one class/spec build.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
const spec = Object.freeze({
|
||||
source,
|
||||
specSelection,
|
||||
selectedBuildIds: Object.freeze(specSelection === "specific" ? selectedBuildIds : []),
|
||||
level: integer(input?.level ?? 60, "Level", 1, 80),
|
||||
partyConfigurations: integer(input?.partyConfigurations ?? 10, "Party configurations", 1, 100),
|
||||
simulationsPerParty: integer(input?.simulationsPerParty ?? 3, "Simulations per party", 1, 100),
|
||||
@@ -69,7 +87,12 @@ export function normalizeBalanceLabJobSpec(input) {
|
||||
});
|
||||
const sourceCount = spec.source === "all" ? 3 : 1;
|
||||
const bossEstimate = Math.max(1, spec.bossKeys.length || (spec.bossCategory === "all" ? 500 : 300));
|
||||
const estimatedRuns = sourceCount * spec.partyConfigurations * spec.simulationsPerParty * bossEstimate;
|
||||
const partyEstimate = spec.specSelection === "random"
|
||||
? sourceCount * spec.partyConfigurations
|
||||
: spec.specSelection === "specific"
|
||||
? Math.max(spec.partyConfigurations, spec.selectedBuildIds.length)
|
||||
: Math.max(spec.partyConfigurations, spec.source === "all" ? 100 : 50);
|
||||
const estimatedRuns = partyEstimate * spec.simulationsPerParty * bossEstimate;
|
||||
if (estimatedRuns > 250_000) {
|
||||
throw new GameDatabaseError(
|
||||
"That job is too large for one server request. Reduce parties, simulations, or selected bosses.",
|
||||
@@ -90,6 +113,7 @@ export function balanceLabCommand(spec, outputDir, options = {}) {
|
||||
"--source", spec.source,
|
||||
"--level", String(spec.level),
|
||||
"--party-mode", "automatic",
|
||||
"--spec-selection", spec.specSelection,
|
||||
"--party-seed", String(spec.partySeed),
|
||||
"--party-configurations", String(spec.partyConfigurations),
|
||||
"--simulations-per-party", String(spec.simulationsPerParty),
|
||||
@@ -97,6 +121,7 @@ export function balanceLabCommand(spec, outputDir, options = {}) {
|
||||
"--boss-category", spec.bossCategory,
|
||||
"--output-dir", outputDir,
|
||||
];
|
||||
for (const buildId of spec.selectedBuildIds) args.push("--spec", buildId);
|
||||
for (const bossKey of spec.bossKeys) args.push("--boss", bossKey);
|
||||
return { executable, args, cwd: projectRoot };
|
||||
}
|
||||
@@ -167,6 +192,12 @@ function reportSummary(outputDir) {
|
||||
}
|
||||
}
|
||||
|
||||
function expectedPartyConfigurations(spec) {
|
||||
return spec.specSelection === "random"
|
||||
? spec.partyConfigurations * (spec.source === "all" ? 3 : 1)
|
||||
: spec.partyConfigurations;
|
||||
}
|
||||
|
||||
function lineCollector(onLine) {
|
||||
let pending = "";
|
||||
return {
|
||||
@@ -236,8 +267,12 @@ export function createBalanceLabJobService(options = {}) {
|
||||
requestedBy: "unknown (legacy run)",
|
||||
spec: {
|
||||
source: legacyConfig.source,
|
||||
specSelection: legacyConfig.specSelection ?? "random",
|
||||
selectedBuildIds: legacyConfig.selectedBuildIds ?? [],
|
||||
level: legacyConfig.level,
|
||||
partyConfigurations: legacyConfig.partyConfigurationsPerSource,
|
||||
partyConfigurations: legacyConfig.specSelection && legacyConfig.specSelection !== "random"
|
||||
? legacyConfig.partyConfigurations
|
||||
: legacyConfig.partyConfigurationsPerSource,
|
||||
simulationsPerParty: legacyConfig.simulationsPerParty,
|
||||
durationSeconds: legacyConfig.durationSeconds,
|
||||
partySeed: legacyConfig.partySeed,
|
||||
@@ -252,7 +287,8 @@ export function createBalanceLabJobService(options = {}) {
|
||||
const hasReport = existsSync(reportPath);
|
||||
const storedStatus = JOB_STATUSES.has(stored.status) ? stored.status : "failed";
|
||||
const interrupted = !hasReport && (storedStatus === "queued" || storedStatus === "running");
|
||||
const totalParties = spec.partyConfigurations * (spec.source === "all" ? 3 : 1);
|
||||
const restoredSummary = hasReport ? reportSummary(outputDir) : null;
|
||||
const totalParties = restoredSummary?.partyConfigurations || expectedPartyConfigurations(spec);
|
||||
const createdAt = typeof stored.createdAt === "string"
|
||||
? stored.createdAt
|
||||
: statSync(outputDir).birthtime.toISOString();
|
||||
@@ -282,7 +318,7 @@ export function createBalanceLabJobService(options = {}) {
|
||||
logTail: Array.isArray(stored.logTail)
|
||||
? stored.logTail.filter((line) => typeof line === "string").slice(-80)
|
||||
: [],
|
||||
summary: hasReport ? reportSummary(outputDir) : null,
|
||||
summary: restoredSummary,
|
||||
outputDir,
|
||||
};
|
||||
jobs.set(job.id, job);
|
||||
@@ -401,7 +437,7 @@ export function createBalanceLabJobService(options = {}) {
|
||||
const id = randomUUID();
|
||||
const outputDir = path.join(jobsDir, id);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const totalParties = spec.partyConfigurations * (spec.source === "all" ? 3 : 1);
|
||||
const totalParties = expectedPartyConfigurations(spec);
|
||||
const job = {
|
||||
id,
|
||||
status: "queued",
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
test("normalizes and bounds server Balance Lab requests", () => {
|
||||
const spec = normalizeBalanceLabJobSpec({
|
||||
source: "ALL",
|
||||
specSelection: "specific",
|
||||
selectedBuildIds: ["balance-wow-priest-healer-holy-level-40", "balance-wow-priest-healer-holy-level-40"],
|
||||
level: 40,
|
||||
partyConfigurations: 2,
|
||||
simulationsPerParty: 3,
|
||||
@@ -24,6 +26,8 @@ test("normalizes and bounds server Balance Lab requests", () => {
|
||||
bossKeys: ["gundrak/entry-29932", "gundrak/entry-29932"],
|
||||
});
|
||||
assert.equal(spec.source, "all");
|
||||
assert.equal(spec.specSelection, "specific");
|
||||
assert.deepEqual(spec.selectedBuildIds, ["balance-wow-priest-healer-holy-level-40"]);
|
||||
assert.deepEqual(spec.bossKeys, ["gundrak/entry-29932"]);
|
||||
assert.throws(
|
||||
() => normalizeBalanceLabJobSpec({ source: "wow", level: 81 }),
|
||||
@@ -33,12 +37,17 @@ test("normalizes and bounds server Balance Lab requests", () => {
|
||||
() => normalizeBalanceLabJobSpec({ source: "wow", bossKeys: ["boss; shutdown"] }),
|
||||
/boss key is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeBalanceLabJobSpec({ source: "wow", specSelection: "specific", selectedBuildIds: [] }),
|
||||
/Select at least one class\/spec build/,
|
||||
);
|
||||
});
|
||||
|
||||
test("builds an argument-array command without a shell", () => {
|
||||
const spec = normalizeBalanceLabJobSpec({
|
||||
source: "rom", level: 60, partyConfigurations: 4, simulationsPerParty: 2,
|
||||
durationSeconds: 30, partySeed: 9, bossCategory: "rom", bossKeys: ["abbey/boss-1"],
|
||||
specSelection: "specific", selectedBuildIds: ["balance-rom-mage-damage-primary-level-60"],
|
||||
});
|
||||
const command = balanceLabCommand(spec, "C:/reports/job", {
|
||||
projectRoot: "C:/app",
|
||||
@@ -47,6 +56,8 @@ test("builds an argument-array command without a shell", () => {
|
||||
assert.equal(command.executable, "python-custom");
|
||||
assert.equal(command.args.includes("--party-mode"), true);
|
||||
assert.equal(command.args.includes("automatic"), true);
|
||||
assert.equal(command.args.includes("specific"), true);
|
||||
assert.equal(command.args.includes("balance-rom-mage-damage-primary-level-60"), true);
|
||||
assert.equal(command.args.includes("abbey/boss-1"), true);
|
||||
assert.equal(command.cwd, path.resolve("C:/app"));
|
||||
});
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ async function derivePassword(password, salt) {
|
||||
return Buffer.from(await scrypt(password, salt, 64));
|
||||
}
|
||||
|
||||
const GM_USERNAME_KEYS = new Set(["phenom"]);
|
||||
const GM_USERNAME_KEYS = new Set(["phenom", "codex_dev_gm"]);
|
||||
|
||||
function accountRoles(username) {
|
||||
return GM_USERNAME_KEYS.has(String(username ?? "").trim().toLowerCase()) ? ["gm"] : [];
|
||||
|
||||
@@ -56,12 +56,15 @@ test("persists opaque cloud roster data with increasing revisions", () => withDa
|
||||
assert.equal(database.getCloudSave(registered.account.id).data.characters[0].name, "Mendbetter");
|
||||
}));
|
||||
|
||||
test("derives GM authority from the hardcoded Phenom account", () => withDatabase(async (database) => {
|
||||
test("derives GM authority from the hardcoded developer accounts", () => withDatabase(async (database) => {
|
||||
const gm = await database.register("Phenom", "storm-admin");
|
||||
const developerGm = await database.register("codex_dev_gm", "developer-admin");
|
||||
const player = await database.register("Ordinary", "storm-player");
|
||||
assert.deepEqual(gm.account.roles, ["gm"]);
|
||||
assert.deepEqual((await database.login("pHeNoM", "storm-admin")).account.roles, ["gm"]);
|
||||
assert.deepEqual(developerGm.account.roles, ["gm"]);
|
||||
assert.equal(database.isGameMaster(database.authenticate(gm.token)), true);
|
||||
assert.equal(database.isGameMaster(database.authenticate(developerGm.token)), true);
|
||||
assert.equal(database.isGameMaster(database.authenticate(player.token)), false);
|
||||
assert.equal(database.isGameMaster({ ...player.account, roles: ["gm"] }), false);
|
||||
}));
|
||||
|
||||
@@ -2,6 +2,7 @@ import { onlineApiOrigin, requestOnlineJson } from "./onlineAccountClient";
|
||||
|
||||
export type BalanceLabPartySource = "wow" | "coa" | "rom" | "all";
|
||||
export type BalanceLabBossCategory = "all" | "wow" | "rom";
|
||||
export type BalanceLabSpecSelection = "random" | "all" | "specific";
|
||||
export type BalanceLabJobStatus = "queued" | "running" | "complete" | "failed";
|
||||
|
||||
export interface BalanceLabRuntimeStatus {
|
||||
@@ -13,6 +14,8 @@ export interface BalanceLabRuntimeStatus {
|
||||
|
||||
export interface BalanceLabJobSpec {
|
||||
readonly source: BalanceLabPartySource;
|
||||
readonly specSelection?: BalanceLabSpecSelection;
|
||||
readonly selectedBuildIds?: readonly string[];
|
||||
readonly level: number;
|
||||
readonly partyConfigurations: number;
|
||||
readonly simulationsPerParty: number;
|
||||
|
||||
@@ -219,6 +219,10 @@ export class BenchmarkController {
|
||||
comboTargetId: combat.comboTargetId,
|
||||
runes: combat.deathKnightRunes,
|
||||
abilities: availableAbilities,
|
||||
knownDamagingAbilityNames: new Set(availableAbilities.filter((ability) => {
|
||||
const categories = aiHintsForAbility(ability).categories;
|
||||
return categories.includes("damage") || categories.includes("execute");
|
||||
}).map((ability) => ability.name)),
|
||||
hostileTarget,
|
||||
friendlyTargets: [
|
||||
{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, fallen: combat.health <= 0 },
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("Boss Testing", () => {
|
||||
expect(scale(1, 10).maxHealth).toBe(nativeHealth * bossDifficultyMultipliers(10).health);
|
||||
const campaign = dungeonEnemyScaling(maraudon, maraudon.entities[celebras.entityId], celebras.spawn.id,
|
||||
1, maraudon.defaultDifficultyId, 1);
|
||||
expect(campaign.maxHealth).toBe(950);
|
||||
expect(campaign.maxHealth).toBe(1050);
|
||||
expect(scale(1).damageMultiplier).toBe(campaign.damageMultiplier);
|
||||
expect(entity.combat?.attacks).toEqual(maraudon.entities[celebras.entityId].combat?.attacks);
|
||||
});
|
||||
@@ -113,7 +113,7 @@ describe("Boss Testing", () => {
|
||||
* (entity.combat?.healthMultiplier ?? 1),
|
||||
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", () => {
|
||||
|
||||
@@ -9,7 +9,10 @@ import { deriveCharacterStats } from "./wotlkStats";
|
||||
|
||||
export function companionResourceMaximum(member: PartyMember, id: string): number {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
import { createDeathKnightRunes } from "./combatActors";
|
||||
import { aiHintsForAbility } from "./abilityRules";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
import { partyTalentPrimaryTreeId } from "./partyTalents";
|
||||
import { specializationsForClass } from "./specializationCatalog";
|
||||
import { talentNodeById } from "./talentCatalog";
|
||||
|
||||
const SIGNATURES = Object.freeze({
|
||||
warrior: "Mortal Strike",
|
||||
@@ -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", () => {
|
||||
const furyTalents = runtimePartyTalentRanksForLevel("warrior", "Fury", 80);
|
||||
const armsTalents = runtimePartyTalentRanksForLevel("warrior", "Arms", 80);
|
||||
@@ -285,6 +307,73 @@ describe("shared combat rotations", () => {
|
||||
.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("never falls a full-health friendly maintenance heal through to a hostile target", () => {
|
||||
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
|
||||
const prayer = semanticAbility("prayer", [
|
||||
@@ -371,6 +460,45 @@ describe("shared combat rotations", () => {
|
||||
.toBe(lightningBolt.id);
|
||||
});
|
||||
|
||||
it("maintains Earth Shield on a valid full-health ally", () => {
|
||||
const earthShield = ability("shaman", "Earth Shield");
|
||||
const shaman = context({
|
||||
classId: "shaman",
|
||||
specializationId: "shaman:restoration",
|
||||
role: "healer",
|
||||
selfId: "shaman",
|
||||
primaryResource: "mana",
|
||||
resources: { mana: 10_000 },
|
||||
maximumResources: { mana: 10_000 },
|
||||
abilities: [earthShield],
|
||||
friendlyTargets: [{ id: "tank", health: 1_000, maxHealth: 1_000 }],
|
||||
maintainedAbilityIds: new Set(),
|
||||
activeAuraExclusiveGroups: new Set(),
|
||||
});
|
||||
|
||||
expect(evaluateCombatRotation(shaman)).toMatchObject({
|
||||
ability: { id: earthShield.id },
|
||||
targetId: "tank",
|
||||
});
|
||||
expect(evaluateCombatRotation({ ...shaman, friendlyTargets: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it("uses Feral Spirit as an Enhancement major cooldown on a durable target", () => {
|
||||
const feralSpirit = ability("shaman", "Feral Spirit");
|
||||
const shaman = context({
|
||||
classId: "shaman",
|
||||
specializationId: "shaman:enhancement",
|
||||
primaryResource: "mana",
|
||||
resources: { mana: 10_000 },
|
||||
maximumResources: { mana: 10_000 },
|
||||
abilities: [feralSpirit],
|
||||
hostileTarget: { id: "boss", health: 100_000, maxHealth: 100_000, boss: true },
|
||||
activeSummonAbilityIds: new Set(),
|
||||
});
|
||||
|
||||
expect(evaluateCombatRotation(shaman)?.ability.id).toBe(feralSpirit.id);
|
||||
});
|
||||
|
||||
it("enters a learned Death Knight presence before attacking", () => {
|
||||
const bloodPresence = learnedAbilityForBuild(ability("death-knight", "Blood Presence"), 1, {})!;
|
||||
const icyTouch = learnedAbilityForBuild(ability("death-knight", "Icy Touch"), 1, {})!;
|
||||
|
||||
+86
-17
@@ -7,7 +7,7 @@ import { spendDeathKnightRunes } from "./combatResources";
|
||||
import { healingAbilityCanAidOthers } from "./healingAbilityRules";
|
||||
import type { PartyRole } from "./partyRoles";
|
||||
|
||||
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v1";
|
||||
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v3";
|
||||
|
||||
export interface RotationProfile {
|
||||
readonly id: string;
|
||||
@@ -51,6 +51,8 @@ export interface RotationContext {
|
||||
readonly comboTargetId: string | null;
|
||||
readonly runes: readonly DeathKnightRuneState[];
|
||||
readonly abilities: readonly AbilityDefinition[];
|
||||
/** Learned actions capable of hostile damage, including unavailable ones. */
|
||||
readonly knownDamagingAbilityNames?: ReadonlySet<string>;
|
||||
readonly hostileTarget: RotationHostileTarget | null;
|
||||
readonly friendlyTargets?: readonly RotationFriendlyTarget[];
|
||||
readonly hostileCount?: number;
|
||||
@@ -62,6 +64,8 @@ export interface RotationContext {
|
||||
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
|
||||
readonly activeSummonAbilityIds?: ReadonlySet<string>;
|
||||
readonly hasActivePet?: boolean;
|
||||
/** Proc/state-gated actions that the caller has positively made available. */
|
||||
readonly availableConditionalAbilityNames?: ReadonlySet<string>;
|
||||
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
|
||||
}
|
||||
|
||||
@@ -86,9 +90,9 @@ const profile = (
|
||||
|
||||
/** Playerbots priorities translated to the executable Healer Man spell model. */
|
||||
export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([
|
||||
profile("warrior:arms", "warrior", ["Battle Stance", "Mortal Strike", "Rend", "Overpower", "Execute", "Slam", "Heroic Strike"]),
|
||||
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "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:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Overpower", "Execute", "Slam", "Heroic Strike"]),
|
||||
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodrage", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "Heroic Strike"]),
|
||||
profile("warrior:protection", "warrior", ["Defensive Stance", "Battle Stance", "Bloodrage", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "Sunder Armor", "Heroic Strike"]),
|
||||
profile("paladin:holy", "paladin", ["Concentration Aura", "Devotion Aura", "Blessing of Might", "Seal of Wisdom", "Seal of Light", "Seal of Righteousness", "Holy Shock", "Flash of Light", "Holy Light", "Sacred Shield", "Judgement of Light"]),
|
||||
profile("paladin:protection", "paladin", ["Devotion Aura", "Blessing of Might", "Seal of Vengeance", "Seal of Corruption", "Seal of Wisdom", "Seal of Righteousness", "Hammer of the Righteous", "Shield of Righteousness", "Holy Shield", "Consecration", "Judgement of Wisdom"]),
|
||||
profile("paladin:retribution", "paladin", ["Retribution Aura", "Devotion Aura", "Blessing of Might", "Seal of Command", "Seal of Vengeance", "Seal of Corruption", "Seal of Righteousness", "Divine Storm", "Crusader Strike", "Judgement of Wisdom", "Consecration", "Exorcism", "Hammer of Wrath"]),
|
||||
@@ -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:shadow", "priest", ["Shadowform", "Inner Fire", "Vampiric Touch", "Devouring Plague", "Shadow Word: Pain", "Mind Blast", "Mind Flay", "Shadow Word: Death"]),
|
||||
profile("death-knight:blood", "death-knight", ["Frost Presence", "Blood Presence", "Heart Strike", "Death Strike", "Icy Touch", "Plague Strike", "Death Coil"]),
|
||||
profile("death-knight:frost", "death-knight", ["Blood Presence", "Howling Blast", "Obliterate", "Frost Strike", "Icy Touch", "Plague Strike"]),
|
||||
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:frost", "death-knight", ["Blood Presence", "Icy Touch", "Plague Strike", "Howling Blast", "Frost Strike", "Obliterate"]),
|
||||
profile("death-knight:unholy", "death-knight", ["Unholy Presence", "Blood Presence", "Raise Dead", "Plague Strike", "Icy Touch", "Scourge Strike", "Death Coil", "Death and Decay"]),
|
||||
profile("shaman:elemental", "shaman", ["Flametongue Weapon", "Rockbiter Weapon", "Lightning Shield", "Flame Shock", "Lava Burst", "Chain Lightning", "Lightning Bolt"]),
|
||||
profile("shaman:enhancement", "shaman", ["Windfury Weapon", "Rockbiter Weapon", "Lightning Shield", "Feral Spirit", "Stormstrike", "Lava Lash", "Earth Shock", "Lightning Bolt"]),
|
||||
profile("shaman:restoration", "shaman", ["Water Shield", "Rockbiter Weapon", "Lightning Shield", "Riptide", "Chain Heal", "Lesser Healing Wave", "Healing Wave", "Earth Shield"]),
|
||||
profile("mage:arcane", "mage", ["Mage Armor", "Ice Armor", "Frost Armor", "Arcane Barrage", "Arcane Blast", "Arcane Missiles"]),
|
||||
profile("mage:fire", "mage", ["Molten Armor", "Mage Armor", "Ice Armor", "Frost Armor", "Living Bomb", "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("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"]),
|
||||
@@ -123,6 +127,45 @@ const PROFILE_BY_SPECIALIZATION = new Map(
|
||||
CLASSIC_ROTATION_PROFILES.map((entry) => [entry.specializationId, entry]),
|
||||
);
|
||||
|
||||
const CONDITIONAL_ABILITY_NAMES = new Set([
|
||||
"Overpower",
|
||||
"Revenge",
|
||||
"Victory Rush",
|
||||
]);
|
||||
|
||||
const TARGET_HEALTH_GATES = new Map<string, number>([
|
||||
["Drain Soul", 0.25],
|
||||
["Hammer of Wrath", 0.2],
|
||||
["Kill Shot", 0.2],
|
||||
]);
|
||||
|
||||
const BEAR_FORM_ACTION_NAMES = new Set([
|
||||
"Bash",
|
||||
"Challenging Roar",
|
||||
"Demoralizing Roar",
|
||||
"Frenzied Regeneration",
|
||||
"Growl",
|
||||
"Lacerate",
|
||||
"Mangle (Bear)",
|
||||
"Maul",
|
||||
"Swipe (Bear)",
|
||||
]);
|
||||
|
||||
const CAT_FORM_ACTION_NAMES = new Set([
|
||||
"Claw",
|
||||
"Cower",
|
||||
"Ferocious Bite",
|
||||
"Mangle (Cat)",
|
||||
"Pounce",
|
||||
"Rake",
|
||||
"Ravage",
|
||||
"Rip",
|
||||
"Savage Roar",
|
||||
"Shred",
|
||||
"Swipe (Cat)",
|
||||
"Tiger's Fury",
|
||||
]);
|
||||
|
||||
function slug(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
@@ -303,32 +346,52 @@ function scoreAbility(
|
||||
|| (context.hostileTarget?.maxHealth ?? 0) >= context.maxHealth * 5,
|
||||
);
|
||||
const profileIndex = rotationProfile?.abilityPriority.findIndex((name) => name === ability.name) ?? -1;
|
||||
const knownDamagingAbilityNames = context.knownDamagingAbilityNames
|
||||
?? new Set(context.abilities.filter((candidate) => {
|
||||
const candidateCategories = aiHintsForAbility(candidate).categories;
|
||||
return candidateCategories.includes("damage") || candidateCategories.includes("execute");
|
||||
}).map((candidate) => candidate.name));
|
||||
const hasKnownProfileDamageAction = Boolean(rotationProfile?.abilityPriority.some((name) => (
|
||||
knownDamagingAbilityNames.has(name)
|
||||
)));
|
||||
|
||||
const targetHealthGate = TARGET_HEALTH_GATES.get(ability.name);
|
||||
if (targetHealthGate !== undefined && targetHealth > targetHealthGate) return null;
|
||||
if (CONDITIONAL_ABILITY_NAMES.has(ability.name)
|
||||
&& !context.availableConditionalAbilityNames?.has(ability.name)) return null;
|
||||
if (context.classId === "druid") {
|
||||
const specializationId = rotationProfile?.specializationId;
|
||||
if (specializationId === "druid:feral-bear" && CAT_FORM_ACTION_NAMES.has(ability.name)) return null;
|
||||
if (specializationId === "druid:feral-cat" && BEAR_FORM_ACTION_NAMES.has(ability.name)) return null;
|
||||
if (specializationId && !specializationId.startsWith("druid:feral-")
|
||||
&& (BEAR_FORM_ACTION_NAMES.has(ability.name) || CAT_FORM_ACTION_NAMES.has(ability.name))) return null;
|
||||
}
|
||||
|
||||
if (categories.has("resurrection")) {
|
||||
const fallen = (context.friendlyTargets ?? []).find((target) => target.fallen);
|
||||
return fallen ? { ability, score: 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")) {
|
||||
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 (categories.has("interrupt")) {
|
||||
if (!context.hostileTarget?.activeCast?.interruptible) {
|
||||
if (!damaging) return null;
|
||||
} 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 (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 (context.role === "tank" || !context.hasDangerousThreat) return null;
|
||||
return {
|
||||
ability,
|
||||
score: 15_000,
|
||||
score: 1_015_000,
|
||||
ruleId: "reactive-threat-drop",
|
||||
reason: "non-tank has enemy attention",
|
||||
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
|
||||
// forever because those auras also look like maintenance buffs.
|
||||
const actionableHealing = healingCategory
|
||||
&& !(maintenanceBuff && categories.has("resource"))
|
||||
&& !maintenanceBuff
|
||||
&& !petSummon
|
||||
&& !offensiveSummon;
|
||||
if (maintenanceBuff && healingCategory
|
||||
&& (ability.target === "friendly" || ability.target === "any") && !lowest) return null;
|
||||
if (actionableHealing && !damaging) {
|
||||
const healingTargetHealth = ability.target === "self" ? selfHealth : lowestHealth;
|
||||
if ((ability.target === "friendly" || ability.target === "any") && !lowest) return null;
|
||||
@@ -356,15 +421,17 @@ function scoreAbility(
|
||||
// summons are valid player buttons but are not autonomous combat actions.
|
||||
if (categories.has("summon") && !damaging && !petSummon && !offensiveSummon && !maintenanceBuff) return null;
|
||||
if (categories.has("buff") && !damaging && !defensive && !maintenanceBuff
|
||||
&& !petSummon && !offensiveSummon) return null;
|
||||
&& !categories.has("resource") && !petSummon && !offensiveSummon) return null;
|
||||
if (categories.has("movement") && !damaging && !context.moving) return null;
|
||||
if (context.requiredControl && categories.has("control")) {
|
||||
return { ability, score: 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 (maintained && (categories.has("debuff") || categories.has("buff"))) return null;
|
||||
if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null;
|
||||
if (rotationProfile && profileIndex < 0 && !damaging && (maintenanceBuff || petSummon || offensiveSummon)) return null;
|
||||
if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction
|
||||
&& context.role !== "healer" && profileIndex < 0 && damaging) return null;
|
||||
if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) return null;
|
||||
if (isComboBuilder(ability) && context.comboPoints >= 5 && context.comboTargetId === context.hostileTarget?.id) return null;
|
||||
|
||||
@@ -374,7 +441,9 @@ function scoreAbility(
|
||||
const maintenanceScore = rotationProfile === null ? 5_200 : profileIndex >= 0 ? 7_000 : 2_000;
|
||||
const summonScore = rotationProfile === null ? 4_800 : profileIndex >= 0 ? 6_500 : 1_800;
|
||||
if (profileIndex >= 0) {
|
||||
score += 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)}`;
|
||||
reason = `${rotationProfile!.specializationId} priority ${profileIndex + 1}`;
|
||||
}
|
||||
@@ -407,7 +476,7 @@ function scoreAbility(
|
||||
} else {
|
||||
if (categories.has("mitigation")) {
|
||||
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 (targetHealth > 0.2) return null;
|
||||
|
||||
@@ -100,9 +100,9 @@ describe("dungeon boss durability and difficulty", () => {
|
||||
|
||||
it("gives each level-three Stockades party its source-calibrated health minimum", () => {
|
||||
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)
|
||||
.toBe(1365 * bossDifficultyMultipliers(10).health);
|
||||
.toBe(1500 * bossDifficultyMultipliers(10).health);
|
||||
// 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, "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(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", () => {
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { ContentCategoryId } from "../app/contentCategories";
|
||||
const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [
|
||||
// The starter anchors include enough room for authentic presence, imbue,
|
||||
// disease, and shock rotations to retain the 25/45-second mechanic window.
|
||||
[2, 950],
|
||||
[4, 1_365],
|
||||
[2, 1_050],
|
||||
[4, 1_500],
|
||||
[9, 7_000],
|
||||
[21, 11_000],
|
||||
[41, 23_000],
|
||||
@@ -25,6 +25,7 @@ const WOW_BOSS_TEST_HEALTH_FLOOR: readonly (readonly [level: number, health: num
|
||||
[80, 180_000],
|
||||
];
|
||||
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 {
|
||||
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;
|
||||
// Match durability to the character's game, including when crossing dungeon catalogs.
|
||||
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
|
||||
// finale. A reduced floor still prevents high-level source creatures with
|
||||
// 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 { usePartyStore, type PartyMember } from "./partyStore";
|
||||
import { deriveCharacterStats } from "./wotlkStats";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
|
||||
function memberFor(classId: PartyMember["classId"], role: PartyMember["role"]): PartyMember {
|
||||
const base = usePartyStore.getState().members[0];
|
||||
@@ -88,6 +89,44 @@ describe("catalog-driven party rotations", () => {
|
||||
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", () => {
|
||||
const member = { ...memberFor("warrior", "tank"), level: 5, gearStats: EMPTY_ITEM_STATS };
|
||||
const ability = abilitiesForClass("warrior").find((candidate) => candidate.id === "warrior-heroic-strike")!;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
|
||||
import {
|
||||
evaluateCombatRotation,
|
||||
rotationProfileFor,
|
||||
type RotationDecision,
|
||||
type RotationFriendlyTarget,
|
||||
} from "./combatRotation";
|
||||
@@ -60,11 +61,46 @@ export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinit
|
||||
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)
|
||||
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
|
||||
.filter((ability): ability is AbilityDefinition => ability !== null)
|
||||
.filter((ability) => partyAbilityIsReady(member, ability, now));
|
||||
.filter((ability): ability is AbilityDefinition => ability !== null);
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -85,7 +121,8 @@ export function choosePartyRotationDecision(
|
||||
[...new Set([...Object.keys(member.resourcePools), member.resourceType])]
|
||||
.map((resource) => [resource, companionResourceMaximum(member, resource)]),
|
||||
);
|
||||
return evaluateCombatRotation({
|
||||
const learned = learnedAbilities(member);
|
||||
const decision = evaluateCombatRotation({
|
||||
now: context.now,
|
||||
classId: member.classId,
|
||||
secondaryClassId: member.secondaryClassId,
|
||||
@@ -102,7 +139,11 @@ export function choosePartyRotationDecision(
|
||||
comboPoints: member.comboPoints,
|
||||
comboTargetId: member.comboTargetId,
|
||||
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,
|
||||
friendlyTargets,
|
||||
hostileCount: context.hostileCount,
|
||||
@@ -115,6 +156,9 @@ export function choosePartyRotationDecision(
|
||||
hasActivePet: context.hasActivePet,
|
||||
canUseAbility: context.canUseAbility,
|
||||
});
|
||||
return decision && shouldReserveForHigherPriority(member, learned, decision, context.now)
|
||||
? null
|
||||
: decision;
|
||||
}
|
||||
|
||||
export function choosePartyAbility(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
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);
|
||||
});
|
||||
|
||||
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]) {
|
||||
it(`executes damaging abilities for every party template at level ${level}`, () => {
|
||||
const templates = partyTemplates(level);
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
partyUsesRangedAttacks,
|
||||
resetPartyRuntimeClock,
|
||||
} from "./partyRuntime";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
@@ -72,6 +72,74 @@ describe("party combat runtime", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
|
||||
registerEngagedMob();
|
||||
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
||||
|
||||
+105
-24
@@ -18,7 +18,7 @@ import {
|
||||
} from "./abilityRules";
|
||||
import type { MobCombatState } from "./combatStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { memberFromCompanionActor } from "./combatActorAuthority";
|
||||
import { companionResourceMaximum, memberFromCompanionActor } from "./combatActorAuthority";
|
||||
import type { MobVector3 } from "./mobPopulation";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
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",
|
||||
delivery: baseClassFor(member.classId) === "hunter" ? "projectile" : identity.delivery, origin, targetPosition, instantCast: true });
|
||||
}
|
||||
if (member.resourceType !== "rage" || result.amount <= 0) return member;
|
||||
const resource = Math.min(member.maxResource, member.resource + 6);
|
||||
return { ...member, resource, resourcePools: { ...member.resourcePools, rage: resource } };
|
||||
if (member.resourcePools.rage === undefined || result.amount <= 0) return member;
|
||||
const rage = Math.min(companionResourceMaximum(member, "rage"), member.resourcePools.rage + 6);
|
||||
return {
|
||||
...member,
|
||||
resource: member.resourceType === "rage" ? rage : member.resource,
|
||||
resourcePools: { ...member.resourcePools, rage },
|
||||
};
|
||||
}
|
||||
|
||||
function partyFriendlyPosition(actorId: string): MobVector3 | null {
|
||||
@@ -741,9 +745,16 @@ function partyAbilityTargetIsValid(
|
||||
&& !ability.effects.some((effect) => effect.kind === "resurrection")) return false;
|
||||
const distance = Math.sqrt(distanceSquared(origin, targetPosition));
|
||||
const targeting = targetingRulesForAbility(ability);
|
||||
const maximumRange = hostile && ability.target === "self"
|
||||
const catalogMaximumRange = hostile && ability.target === "self"
|
||||
? targeting.radius ?? ability.radius ?? 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 (targeting.requiresLineOfSight && !combatHasLineOfSight(origin, targetPosition)) return false;
|
||||
if (hostile && targeting.requiresFacing) {
|
||||
@@ -901,6 +912,45 @@ function choosePartySupportIntent(
|
||||
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 = [
|
||||
...(!simulatedPartyExcludesPlayer() && combat.health > 0 && combat.health < combat.maxHealth
|
||||
? [{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, role: "healer" as const }]
|
||||
@@ -1085,6 +1135,23 @@ function executePartyAbility(
|
||||
ability.id,
|
||||
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") {
|
||||
const current = runtimeMember.comboTargetId === targetId ? runtimeMember.comboPoints : 0;
|
||||
@@ -1207,26 +1274,37 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
);
|
||||
members = members.map((member) => {
|
||||
const profile = resourceProfileForClass(member.classId);
|
||||
const regeneration = (profile.type === "rage" || profile.type === "runic-power")
|
||||
? 0
|
||||
: profile.regenerationPerSecond + member.gearStats.mp5 / 5 + member.gearStats.spirit * 0.02;
|
||||
const pool = advanceCombatResourcePool(normalizeActorResourcePool(
|
||||
member.resourceType,
|
||||
member.resource,
|
||||
member.maxResource,
|
||||
regeneration,
|
||||
member.lastResourceSpentAt[member.resourceType] ?? 0,
|
||||
), {
|
||||
inCombat: Boolean(targetId),
|
||||
actorLevel: member.level,
|
||||
now,
|
||||
deltaSeconds,
|
||||
manaSpiritRegenerationPerSecond: member.gearStats.spirit * 0.02,
|
||||
});
|
||||
const passiveRates: Partial<Record<ResourceType, number>> = {
|
||||
mana: 4,
|
||||
rage: 0,
|
||||
energy: 10,
|
||||
focus: 6,
|
||||
"runic-power": 0,
|
||||
};
|
||||
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,
|
||||
member.lastResourceSpentAt[type] ?? 0,
|
||||
), {
|
||||
inCombat: Boolean(targetId),
|
||||
actorLevel: member.level,
|
||||
now,
|
||||
deltaSeconds,
|
||||
manaSpiritRegenerationPerSecond: type === "mana" ? member.gearStats.spirit * 0.02 : 0,
|
||||
}).current;
|
||||
}
|
||||
return {
|
||||
...member,
|
||||
resource: pool.current,
|
||||
resourcePools: { ...member.resourcePools, [member.resourceType]: pool.current },
|
||||
resource: resourcePools[member.resourceType] ?? member.resource,
|
||||
resourcePools,
|
||||
cooldowns: Object.fromEntries(Object.entries(member.cooldowns).filter(([, readyAt]) => readyAt > now)),
|
||||
schoolLockedUntil: Object.fromEntries(Object.entries(member.schoolLockedUntil).filter(([, readyAt]) => (readyAt ?? 0) > now)),
|
||||
};
|
||||
@@ -1291,7 +1369,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
const currentCombat = useCombatStore.getState();
|
||||
if (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;
|
||||
if (!castingAbility || !castTargetId) {
|
||||
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null };
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../app/characterCatalog";
|
||||
import {
|
||||
createDungeonParty,
|
||||
createConfiguredPartyMember,
|
||||
isHealerSpecialization,
|
||||
isTankSpecialization,
|
||||
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", () => {
|
||||
const first = 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 { ContentCategoryId } from "../app/contentCategories";
|
||||
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";
|
||||
|
||||
export {
|
||||
@@ -222,6 +227,43 @@ function memberMaxResource(
|
||||
: 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 {
|
||||
const maxHealth = memberMaxHealth(
|
||||
member.level,
|
||||
@@ -237,7 +279,7 @@ function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMe
|
||||
const resource = Math.max(0, Math.round(
|
||||
member.resource / Math.max(1, member.maxResource) * maxResource,
|
||||
));
|
||||
return {
|
||||
return withMemberAbilityResourcePools({
|
||||
...member,
|
||||
gearStats,
|
||||
health,
|
||||
@@ -245,7 +287,7 @@ function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMe
|
||||
resource,
|
||||
maxResource,
|
||||
resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createMember(
|
||||
@@ -286,7 +328,7 @@ function createMember(
|
||||
const resource = Math.round(
|
||||
maxResource * (resourceProfile.maximum > 0 ? resourceProfile.initial / resourceProfile.maximum : 0),
|
||||
);
|
||||
return {
|
||||
return withMemberAbilityResourcePools({
|
||||
id,
|
||||
name: template.name,
|
||||
raceId: race.id,
|
||||
@@ -323,7 +365,7 @@ function createMember(
|
||||
tauntReadyAt: 0,
|
||||
controlledUntil: 0,
|
||||
controlMechanic: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates one exact companion while retaining the live party stat/equipment path. */
|
||||
@@ -339,12 +381,12 @@ export function createConfiguredPartyMember(
|
||||
specialization: configuration.specialization,
|
||||
talentTreeId: configuration.talentTreeId ?? configuration.specializationId ?? "",
|
||||
}, configuration.role, level, index, seed, configuration.talentRanks);
|
||||
return Object.freeze({
|
||||
return Object.freeze(withMemberAbilityResourcePools({
|
||||
...member,
|
||||
secondaryClassId: configuration.secondaryClassId ?? null,
|
||||
specializationId: configuration.specializationId ?? member.specializationId,
|
||||
talentRanks: configuration.talentRanks ?? member.talentRanks,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -571,7 +613,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
|
||||
const resource = Math.max(0, Math.round(
|
||||
member.resource / Math.max(1, member.maxResource) * maxResource,
|
||||
));
|
||||
return {
|
||||
return withMemberAbilityResourcePools({
|
||||
...member,
|
||||
level: safeLevel,
|
||||
health,
|
||||
@@ -582,7 +624,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
|
||||
talentRanks: isRomClassId(member.classId)
|
||||
? Object.freeze({})
|
||||
: runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel),
|
||||
};
|
||||
});
|
||||
}),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -14,6 +14,5 @@ describe("generated party talent runtime plans", () => {
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
normalizeTalentRanks,
|
||||
spentTalentPoints,
|
||||
talentNodeById,
|
||||
talentNodesForTree,
|
||||
talentPointsForLevel,
|
||||
talentTreeById,
|
||||
} from "./talentCatalog";
|
||||
@@ -27,10 +28,13 @@ const EXPECTED_SPECIALIZATIONS = [
|
||||
["warrior", "Arms", "warrior-arms"],
|
||||
["warrior", "Fury", "warrior-fury"],
|
||||
["paladin", "Retribution", "paladin-retribution"],
|
||||
["hunter", "Beast Mastery", "hunter-beast-mastery"],
|
||||
["hunter", "Marksmanship", "hunter-marksmanship"],
|
||||
["hunter", "Survival", "hunter-survival"],
|
||||
["rogue", "Combat", "rogue-combat"],
|
||||
["rogue", "Assassination", "rogue-assassination"],
|
||||
["rogue", "Subtlety", "rogue-subtlety"],
|
||||
["priest", "Discipline", "priest-discipline"],
|
||||
["priest", "Shadow", "priest-shadow"],
|
||||
["death-knight", "Frost", "death-knight-frost"],
|
||||
["death-knight", "Unholy", "death-knight-unholy"],
|
||||
@@ -41,7 +45,9 @@ const EXPECTED_SPECIALIZATIONS = [
|
||||
["mage", "Frost", "mage-frost"],
|
||||
["warlock", "Affliction", "warlock-affliction"],
|
||||
["warlock", "Demonology", "warlock-demonology"],
|
||||
["warlock", "Destruction", "warlock-destruction"],
|
||||
["druid", "Balance", "druid-balance"],
|
||||
["druid", "Feral (Bear)", "druid-feral-combat"],
|
||||
["druid", "Feral (Cat)", "druid-feral-combat"],
|
||||
] as const satisfies readonly (readonly [ClassId, string, string])[];
|
||||
|
||||
@@ -87,6 +93,20 @@ describe("party talent specialization plans", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -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 {
|
||||
allocateTalentPoint,
|
||||
spentTalentPointsInTree,
|
||||
talentDescriptionForRank,
|
||||
talentNodeById,
|
||||
talentNodesForTree,
|
||||
@@ -10,9 +11,10 @@ import {
|
||||
talentTreesForClass,
|
||||
type TalentRanks,
|
||||
} from "./talentCatalog";
|
||||
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
||||
|
||||
export interface PartyTalentSpecializationDefinition {
|
||||
readonly classId: ClassId;
|
||||
readonly classId: BaseClassId;
|
||||
readonly specialization: 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: "Fury", primaryTreeId: "warrior-fury" },
|
||||
{ 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: "Survival", primaryTreeId: "hunter-survival" },
|
||||
{ classId: "rogue", specialization: "Combat", primaryTreeId: "rogue-combat" },
|
||||
{ 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: "death-knight", specialization: "Frost", primaryTreeId: "death-knight-frost" },
|
||||
{ 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: "warlock", specialization: "Affliction", primaryTreeId: "warlock-affliction" },
|
||||
{ classId: "warlock", specialization: "Demonology", primaryTreeId: "warlock-demonology" },
|
||||
{ classId: "warlock", specialization: "Destruction", primaryTreeId: "warlock-destruction" },
|
||||
{ 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" },
|
||||
] as const satisfies readonly PartyTalentSpecializationDefinition[]);
|
||||
|
||||
@@ -98,31 +105,85 @@ function requireSpecialization(
|
||||
return definition;
|
||||
}
|
||||
|
||||
function orderedCandidateNodeIds(definition: PartyTalentSpecializationDefinition): readonly string[] {
|
||||
function orderedCandidateNodeIds(
|
||||
definition: PartyTalentSpecializationDefinition,
|
||||
ranks: TalentRanks,
|
||||
): readonly string[] {
|
||||
const trees = talentTreesForClass(definition.classId);
|
||||
const primary = trees.find((tree) => tree.id === definition.primaryTreeId);
|
||||
if (!primary) {
|
||||
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))
|
||||
.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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a stable legal build without duplicating talent rules here. The
|
||||
* primary tree is exhausted in catalog row/index order before secondary trees;
|
||||
* every individual rank is accepted by the shared allocator at its real level.
|
||||
* primary tree advances to its deepest tier with exactly the 51 points needed
|
||||
* 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(
|
||||
definition: PartyTalentSpecializationDefinition,
|
||||
): readonly PartyTalentSelection[] {
|
||||
const candidates = orderedCandidateNodeIds(definition);
|
||||
let ranks: TalentRanks = {};
|
||||
const selections: PartyTalentSelection[] = [];
|
||||
|
||||
for (let level = 10; level <= MAX_PLAYER_LEVEL; level += 1) {
|
||||
let selected: PartyTalentSelection | null = null;
|
||||
const candidates = orderedCandidateNodeIds(definition, ranks);
|
||||
for (const nodeId of candidates) {
|
||||
const node = talentNodeById(nodeId);
|
||||
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");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const arcane = firstNode("mage-arcane");
|
||||
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) => ({
|
||||
...node,
|
||||
maxRank: node.rankSpellIds.length,
|
||||
system: "classic",
|
||||
}));
|
||||
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,
|
||||
prerequisiteId: prerequisites[0]?.talentId ?? null,
|
||||
prerequisiteRank: prerequisites[0]?.requiredRank ?? null,
|
||||
prerequisites,
|
||||
maxRank: node.rankSpellIds.length,
|
||||
system: "classic",
|
||||
};
|
||||
});
|
||||
|
||||
function coaTreeId(classId: CoaClassId, tabId: number): string {
|
||||
return `coa-${classId}-${tabId}`;
|
||||
|
||||
@@ -106,6 +106,27 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
||||
|
||||
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-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-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-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([
|
||||
@@ -113,6 +134,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", () => {
|
||||
const lightningShield = abilityAtLevel(byId("wow335-shaman-lightning-shield"), 80);
|
||||
expect(lightningShield.dbcSpellId).toBe(49281);
|
||||
@@ -127,6 +157,27 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
||||
expect(effectsOfKind(waterShield, "apply-aura").every((effect) => (
|
||||
effect.aura.exclusiveGroup === "shaman:elemental-shield"
|
||||
))).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", () => {
|
||||
|
||||
@@ -322,6 +322,9 @@ function exclusiveGroupForAbility(classId: BaseClassId, abilityName: string): st
|
||||
if (/\bArmor$/i.test(abilityName)) return `${classId}:armor`;
|
||||
if (/\bPresence$/i.test(abilityName)) return "death-knight:presence";
|
||||
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";
|
||||
return undefined;
|
||||
}
|
||||
@@ -558,6 +561,20 @@ function namedAuraForEffect(
|
||||
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) {
|
||||
specialProcs.push({
|
||||
id: `${auraId}:frost-slow`, triggers: "damage-taken", chance: 1,
|
||||
@@ -724,6 +741,11 @@ function classScriptEffects(
|
||||
});
|
||||
switch (`${classId}:${abilityName}`) {
|
||||
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 }];
|
||||
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:Intercept": return [{ kind: "status", status: "stun", durationMs: 3_000 }];
|
||||
case "warrior:Sunder Armor": return [{
|
||||
@@ -758,6 +780,12 @@ function classScriptEffects(
|
||||
case "hunter:Freezing Arrow": return [{ kind: "status", status: "freeze", durationMs: 10_000 }];
|
||||
case "hunter:Feign Death": return [{ kind: "threat", mode: "percent", amount: -100 }];
|
||||
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:Cloak of Shadows": return [{
|
||||
kind: "dispel", mode: "cleanse", relationship: "friendly", categories: ["magic"], maxCount: 10,
|
||||
@@ -766,7 +794,7 @@ function classScriptEffects(
|
||||
// trigger spells. Keep the imported cast/cost/rank data and supply only the
|
||||
// missing executable payload.
|
||||
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:Prayer of Mending": return [{
|
||||
kind: "apply-aura",
|
||||
@@ -818,7 +846,13 @@ function classScriptEffects(
|
||||
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" },
|
||||
}])];
|
||||
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 }];
|
||||
// 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: Teleport": return [{ kind: "movement", movement: "teleport", toward: "away" }];
|
||||
case "hunter:Misdirection": return [{ kind: "threat", mode: "redirect", amount: 100 }];
|
||||
@@ -1137,6 +1171,16 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
||||
const scriptedFallbacks = classScriptEffects(classId, abilityName, rank);
|
||||
const executable = effects
|
||||
.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 === "Bloodrage"
|
||||
&& (effect.kind === "resource" || effect.kind === "heal"))
|
||||
))
|
||||
.map((effect) => effect.kind === "utility" ? effect : effect);
|
||||
const result = scriptedFallbacks.length
|
||||
? [...executable, ...scriptedFallbacks]
|
||||
@@ -1316,6 +1360,7 @@ function abilityNameRadius(abilityName: string): number {
|
||||
|
||||
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
|
||||
if (["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";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+15
-1
@@ -3236,6 +3236,9 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
.balance-lab-boss-picker { margin-top: 10px; border: 1px solid var(--front-line); border-radius: 8px; overflow: hidden; }
|
||||
.balance-lab-boss-picker > header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid var(--front-line); }
|
||||
.balance-lab-boss-picker > header strong { color: #d7c68c; font-size: 8px; white-space: nowrap; }
|
||||
.balance-lab-picker-actions { display: flex; align-items: center; gap: 5px; }
|
||||
.balance-lab-picker-actions button { padding: 5px 7px; border: 1px solid var(--front-line); border-radius: 5px; color: var(--front-dim); background: rgba(149, 190, 118, .06); cursor: pointer; font: inherit; font-size: 7px; white-space: nowrap; }
|
||||
.balance-lab-picker-actions button:hover { color: var(--front-cream); background: rgba(149, 190, 118, .12); }
|
||||
.balance-lab-boss-picker > div { display: grid; max-height: 220px; overflow: auto; }
|
||||
.balance-lab-boss-picker label { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8px; padding: 7px 8px; border-bottom: 1px solid rgba(190, 213, 172, .08); cursor: pointer; }
|
||||
.balance-lab-boss-picker label:hover, .balance-lab-boss-picker label.is-selected { background: rgba(149, 190, 118, .1); }
|
||||
@@ -3243,7 +3246,8 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
.balance-lab-boss-picker label span, .balance-lab-boss-picker label strong, .balance-lab-boss-picker label small { display: block; min-width: 0; }
|
||||
.balance-lab-boss-picker label strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.balance-lab-boss-picker label small { margin-top: 2px; color: var(--front-dim); font-size: 7px; }
|
||||
.balance-lab-estimate { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; margin-top: 10px; }
|
||||
.balance-lab-coverage-note { margin: 8px 0 0; color: var(--front-dim); font-size: 7px; line-height: 1.45; }
|
||||
.balance-lab-estimate { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 5px; margin-top: 10px; }
|
||||
.balance-lab-estimate span { display: grid; gap: 3px; padding: 8px; border: 1px solid rgba(190, 213, 172, .11); border-radius: 6px; color: var(--front-dim); font-size: 7px; }
|
||||
.balance-lab-estimate b { color: var(--front-cream); font-size: 11px; }
|
||||
.balance-lab-run { width: 100%; margin-top: 10px; }
|
||||
@@ -3338,12 +3342,21 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
.balance-results td strong, .balance-results td small { display: block; }
|
||||
.balance-results td strong { color: #dfe7d7; font-size: 8px; }
|
||||
.balance-results td small { max-width: 180px; margin-top: 2px; overflow: hidden; color: var(--front-dim); font-size: 6px; text-overflow: ellipsis; }
|
||||
.balance-results-drilldown { display: block; margin-top: 6px; padding: 4px 6px; border: 1px solid rgba(111, 170, 196, .34); border-radius: 5px; color: #b8d9e7; background: rgba(57, 94, 118, .18); cursor: pointer; font: inherit; font-size: 6px; white-space: nowrap; }
|
||||
.balance-results-drilldown:hover:not(:disabled), .balance-results-drilldown:focus-visible { border-color: rgba(125, 199, 231, .7); background: rgba(57, 94, 118, .34); }
|
||||
.balance-results-drilldown:disabled { border-color: var(--front-line); color: var(--front-dim); background: transparent; cursor: not-allowed; }
|
||||
.balance-results td.balance-results-members { min-width: 300px; max-width: 540px; white-space: normal; line-height: 1.5; }
|
||||
.balance-results-flag { display: inline-block; padding: 3px 5px; border-radius: 4px; color: #cbd7c5; background: rgba(116, 138, 107, .14); }
|
||||
.balance-results-flag.is-high { color: #efb8aa; background: rgba(155, 65, 50, .18); }
|
||||
.balance-results-flag.is-low { color: #b8d6ee; background: rgba(60, 111, 147, .18); }
|
||||
.balance-results-abilities { display: grid; min-width: 0; gap: 10px; }
|
||||
.balance-results-abilities > .balance-results-chart { width: 100%; }
|
||||
.balance-results-drilldown-banner { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid rgba(111, 170, 196, .28); border-radius: 8px; background: linear-gradient(100deg, rgba(57, 94, 118, .2), rgba(8, 19, 16, .45)); }
|
||||
.balance-results-drilldown-banner > div, .balance-results-drilldown-banner span, .balance-results-drilldown-banner strong, .balance-results-drilldown-banner small { display: block; }
|
||||
.balance-results-drilldown-banner span { color: #79abc1; font-size: 6px; letter-spacing: .09em; text-transform: uppercase; }
|
||||
.balance-results-drilldown-banner strong { margin-top: 2px; color: #e1edf0; font: 15px/1.25 Georgia, serif; }
|
||||
.balance-results-drilldown-banner small { margin-top: 2px; color: var(--front-dim); font-size: 7px; }
|
||||
.balance-results-drilldown-banner button { padding: 7px 9px; border: 1px solid rgba(111, 170, 196, .34); border-radius: 6px; color: #c8e1e9; background: rgba(1, 7, 5, .62); cursor: pointer; font: inherit; font-size: 7px; white-space: nowrap; }
|
||||
.balance-results-summary--abilities article:nth-child(3) strong { color: #efb8aa; }
|
||||
.balance-results-ability-controls { display: grid; grid-template-columns: minmax(170px, 1.15fr) minmax(145px, .9fr) minmax(145px, .9fr) auto; gap: 6px; min-width: min(680px, 60vw); }
|
||||
.balance-results-ability-controls select { width: 100%; min-width: 0; padding: 7px 8px; border: 1px solid var(--front-line); border-radius: 6px; outline: 0; color: var(--front-cream); background: rgba(1, 7, 5, .8); font: inherit; font-size: 7px; }
|
||||
@@ -3376,6 +3389,7 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
.balance-results__tabs button { white-space: nowrap; }
|
||||
.balance-results-table-panel > header { display: grid; }
|
||||
.balance-results-sort { width: 100%; min-width: 0; }
|
||||
.balance-results-drilldown-banner { align-items: start; flex-direction: column; }
|
||||
.balance-results-ability-controls { width: 100%; min-width: 0; grid-template-columns: 1fr; }
|
||||
.balance-results-bar { grid-template-columns: minmax(90px, 40%) minmax(55px, 1fr) minmax(40px, auto); gap: 5px; }
|
||||
}
|
||||
|
||||
@@ -129,6 +129,8 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite
|
||||
|| left[1].specialization.localeCompare(right[1].specialization)
|
||||
));
|
||||
}, [allAbilities]);
|
||||
const abilityBuildKeys = useMemo(() => new Set(abilityBuildOptions.map(([key]) => key)), [abilityBuildOptions]);
|
||||
const selectedAbilityBuild = abilityBuildOptions.find(([key]) => key === abilityBuild)?.[1] ?? null;
|
||||
const abilities = useMemo(() => allAbilities.filter((row) => {
|
||||
const buildKey = `${row.source}/${row.buildId}`;
|
||||
const matchesStatus = abilityStatus === "all"
|
||||
@@ -184,6 +186,19 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite
|
||||
const usedAbilityCount = abilities.filter((row) => row.activeRuns > 0).length;
|
||||
const filteredAbilityDamage = abilities.reduce((total, row) => total + row.effectiveDamage, 0);
|
||||
|
||||
const openAbilityDrilldown = (row: BalanceLabClassSpecResult) => {
|
||||
const buildKey = `${row.source}/${row.buildId}`;
|
||||
setPartySource(row.source === "wow" || row.source === "coa" || row.source === "rom" ? row.source : "all");
|
||||
setRole(row.role);
|
||||
setSearch("");
|
||||
setAbilityBuild(buildKey);
|
||||
setAbilityStatus("damage");
|
||||
setAbilitySort("effectiveDamage");
|
||||
setAscending(false);
|
||||
setTab("abilities");
|
||||
window.requestAnimationFrame(() => document.querySelector(".balance-results")?.scrollIntoView({ behavior: "smooth", block: "start" }));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="balance-results" aria-label="Balance Lab report analysis">
|
||||
<header className="balance-results__header">
|
||||
@@ -229,18 +244,23 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite
|
||||
|
||||
{tab === "classes" && <div className="balance-results-table-panel">
|
||||
<header><div><h3>Every class and specialization</h3><p>{sortedClasses.length} matching builds · output, healing, mitigation, survivability, and role-relative tuning.</p></div><div className="balance-results-sort"><select aria-label="Sort class specs" value={classSort} onChange={(event) => setClassSort(event.currentTarget.value as keyof BalanceLabClassSpecResult)}><option value="bossDps">Boss DPS</option><option value="dps">Total DPS</option><option value="hps">Healing done / sec</option><option value="hpsReceived">Healing received / sec</option><option value="dtps">Damage taken / sec</option><option value="totalDamageTaken">Total damage taken</option><option value="totalDamage">Total damage dealt</option><option value="totalHealing">Total healing done</option><option value="totalHealingReceived">Total healing received</option><option value="totalAbsorbs">Total absorbs</option><option value="deathEvents">Deaths</option></select><button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button></div></header>
|
||||
<div className="balance-results-table-wrap"><table><thead><tr><th>Class / specialization</th><th>Source</th><th>Role</th><th>Parties</th><th>Runs</th><th>Boss DPS</th><th>Total DPS</th><th>Healing done / sec</th><th>Healing received / sec</th><th>Damage taken / sec</th><th>Total damage taken</th><th>Role median</th><th>Total damage dealt</th><th>Total healing done</th><th>Total healing received</th><th>Absorbs</th><th>Deaths</th></tr></thead><tbody>{sortedClasses.map((row) => <tr key={`${row.source}/${row.buildId}`}><td><strong>{row.className}</strong><small>{row.specialization}</small></td><td>{row.source.toUpperCase()}</td><td>{row.role}</td><td>{row.partyConfigurations.toLocaleString()}</td><td>{row.runs.toLocaleString()}</td><td>{formatNumber(row.bossDps)}</td><td>{formatNumber(row.dps)}</td><td>{formatNumber(row.hps)}</td><td>{formatNumber(row.hpsReceived)}</td><td>{formatNumber(row.dtps)}</td><td>{formatWhole(row.totalDamageTaken)}</td><td><span className={`balance-results-flag ${flagClass(row.damageFlag)}`}>{formatRatio(row.damageVsSourceRoleMedian)} · {row.damageFlag}</span></td><td>{formatWhole(row.totalDamage)}</td><td>{formatWhole(row.totalHealing)}</td><td>{formatWhole(row.totalHealingReceived)}</td><td>{formatWhole(row.totalAbsorbs)}</td><td>{row.deathEvents.toLocaleString()}</td></tr>)}</tbody></table></div>
|
||||
<div className="balance-results-table-wrap"><table><thead><tr><th>Class / specialization</th><th>Source</th><th>Role</th><th>Parties</th><th>Runs</th><th>Boss DPS</th><th>Total DPS</th><th>Healing done / sec</th><th>Healing received / sec</th><th>Damage taken / sec</th><th>Total damage taken</th><th>Role median</th><th>Total damage dealt</th><th>Total healing done</th><th>Total healing received</th><th>Absorbs</th><th>Deaths</th></tr></thead><tbody>{sortedClasses.map((row) => {
|
||||
const buildKey = `${row.source}/${row.buildId}`;
|
||||
const hasAbilityData = abilityBuildKeys.has(buildKey);
|
||||
return <tr key={buildKey}><td><strong>{row.className}</strong><small>{row.specialization}</small><button className="balance-results-drilldown" type="button" disabled={!hasAbilityData} onClick={() => openAbilityDrilldown(row)} aria-label={`View ${row.className} ${row.specialization} ability damage`}>{hasAbilityData ? "View ability damage" : "No ability data"}</button></td><td>{row.source.toUpperCase()}</td><td>{row.role}</td><td>{row.partyConfigurations.toLocaleString()}</td><td>{row.runs.toLocaleString()}</td><td>{formatNumber(row.bossDps)}</td><td>{formatNumber(row.dps)}</td><td>{formatNumber(row.hps)}</td><td>{formatNumber(row.hpsReceived)}</td><td>{formatNumber(row.dtps)}</td><td>{formatWhole(row.totalDamageTaken)}</td><td><span className={`balance-results-flag ${flagClass(row.damageFlag)}`}>{formatRatio(row.damageVsSourceRoleMedian)} · {row.damageFlag}</span></td><td>{formatWhole(row.totalDamage)}</td><td>{formatWhole(row.totalHealing)}</td><td>{formatWhole(row.totalHealingReceived)}</td><td>{formatWhole(row.totalAbsorbs)}</td><td>{row.deathEvents.toLocaleString()}</td></tr>;
|
||||
})}</tbody></table></div>
|
||||
</div>}
|
||||
|
||||
{tab === "abilities" && <div className="balance-results-abilities">
|
||||
{allAbilities.length === 0 ? <p className="balance-results-legacy-note">This saved run predates ability-level reporting. Run the same configuration again to populate ability use, unused abilities, damage share, healing, and absorbs.</p> : <>
|
||||
{selectedAbilityBuild && <div className="balance-results-drilldown-banner"><div><span>Spec damage drill-down</span><strong>{selectedAbilityBuild.className} · {selectedAbilityBuild.specialization}</strong><small>{selectedAbilityBuild.source.toUpperCase()} · {selectedAbilityBuild.role} · ability damage ranked by effective damage</small></div><button type="button" onClick={() => { setAbilityBuild("all"); setAbilityStatus("all"); setPartySource("all"); setRole("all"); }}>Show all specs</button></div>}
|
||||
<div className="balance-results-summary balance-results-summary--abilities">
|
||||
<article><span>Matching abilities</span><strong>{abilities.length.toLocaleString()}</strong></article>
|
||||
<article><span>Used in at least one run</span><strong>{usedAbilityCount.toLocaleString()}</strong></article>
|
||||
<article><span>Available but unused</span><strong>{unusedAbilityCount.toLocaleString()}</strong></article>
|
||||
<article><span>Filtered effective damage</span><strong>{formatWhole(filteredAbilityDamage)}</strong></article>
|
||||
</div>
|
||||
<BarChart title="Ability damage contribution" description="Effective damage by ability for the selected source, role, class/spec, status, and text filters." rows={abilityChart("effectiveDamage")} tone="class" />
|
||||
<BarChart title={selectedAbilityBuild ? `${selectedAbilityBuild.className} · ${selectedAbilityBuild.specialization} ability damage` : "Ability damage contribution"} description="Effective damage by ability for the selected source, role, class/spec, status, and text filters. Casts, hits, DPS, damage share, and damage per hit are listed below." rows={abilityChart("effectiveDamage")} tone="class" />
|
||||
<div className="balance-results-table-panel">
|
||||
<header><div><h3>Every class/spec ability</h3><p>{sortedAbilities.length} matching abilities · {availableAbilityCount} are available to the tested builds. Zero-use rows are deliberately retained.</p></div><div className="balance-results-ability-controls">
|
||||
<select aria-label="Filter ability class spec" value={abilityBuild} onChange={(event) => setAbilityBuild(event.currentTarget.value)}><option value="all">All class/specs</option>{abilityBuildOptions.map(([key, row]) => <option key={key} value={key}>{row.source.toUpperCase()} · {row.className} — {row.specialization}</option>)}</select>
|
||||
|
||||
@@ -12,9 +12,15 @@ import {
|
||||
type BalanceLabJob,
|
||||
type BalanceLabPartySource,
|
||||
type BalanceLabRuntimeStatus,
|
||||
type BalanceLabSpecSelection,
|
||||
type BalanceLabSuiteReport,
|
||||
} from "../app/balanceLabClient";
|
||||
import { classById } from "../app/characterCatalog";
|
||||
import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
|
||||
import {
|
||||
selectableBenchmarkBuildCatalog,
|
||||
type SelectableBenchmarkBuildCatalogEntry,
|
||||
} from "../game/benchmark/benchmarkBuilds";
|
||||
import { BalanceLabResults } from "./BalanceLabResults";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
|
||||
@@ -60,10 +66,24 @@ function describeJob(job: BalanceLabJob): string {
|
||||
return `Party ${party}/${job.progress.partiesTotal}${simulation}`;
|
||||
}
|
||||
|
||||
function coveragePartyCount(builds: readonly SelectableBenchmarkBuildCatalogEntry[]): number {
|
||||
const bySource = new Map<string, SelectableBenchmarkBuildCatalogEntry[]>();
|
||||
for (const build of builds) bySource.set(build.source, [...(bySource.get(build.source) ?? []), build]);
|
||||
return [...bySource.values()].reduce((total, rows) => {
|
||||
const tanks = rows.filter((build) => build.role === "tank").length;
|
||||
const healers = rows.filter((build) => build.role === "healer").length;
|
||||
const damage = rows.filter((build) => build.role === "damage").length;
|
||||
return total + Math.max(1, tanks, healers, Math.ceil(damage / 3));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) {
|
||||
const session = useShellStore((state) => state.session);
|
||||
const token = session?.accessToken ?? "";
|
||||
const [source, setSource] = useState<BalanceLabPartySource>("all");
|
||||
const [specSelection, setSpecSelection] = useState<BalanceLabSpecSelection>("random");
|
||||
const [specSearch, setSpecSearch] = useState("");
|
||||
const [selectedBuildIds, setSelectedBuildIds] = useState<readonly string[]>([]);
|
||||
const [level, setLevel] = useState(60);
|
||||
const [partyConfigurations, setPartyConfigurations] = useState(10);
|
||||
const [simulationsPerParty, setSimulationsPerParty] = useState(3);
|
||||
@@ -90,9 +110,26 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
const query = jobSearch.trim().toLowerCase();
|
||||
return jobs.filter((job) => (
|
||||
(jobStatus === "all" || job.status === jobStatus)
|
||||
&& (!query || `${job.requestedBy} ${job.spec.source} ${job.spec.level} ${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]);
|
||||
const availableBuilds = useMemo(() => selectableBenchmarkBuildCatalog(
|
||||
Number.isFinite(level) ? level : 60,
|
||||
).filter((build) => source === "all" || build.source === source).sort((left, right) => (
|
||||
left.source.localeCompare(right.source)
|
||||
|| classById(left.classId).name.localeCompare(classById(right.classId).name)
|
||||
|| left.orientation.localeCompare(right.orientation)
|
||||
|| left.role.localeCompare(right.role)
|
||||
)), [level, source]);
|
||||
const selectedBuildSet = useMemo(() => new Set(selectedBuildIds), [selectedBuildIds]);
|
||||
const selectedBuilds = useMemo(() => availableBuilds.filter((build) => selectedBuildSet.has(build.id)), [
|
||||
availableBuilds,
|
||||
selectedBuildSet,
|
||||
]);
|
||||
const filteredBuilds = useMemo(() => {
|
||||
const query = specSearch.trim().toLowerCase();
|
||||
return availableBuilds.filter((build) => !query || `${classById(build.classId).name} ${build.orientation} ${build.role} ${build.source} ${build.id}`.toLowerCase().includes(query));
|
||||
}, [availableBuilds, specSearch]);
|
||||
const filteredBosses = useMemo(() => {
|
||||
const query = bossSearch.trim().toLowerCase();
|
||||
return BOSS_TESTING_CATALOG.filter((boss) => !query
|
||||
@@ -102,7 +139,11 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
const bossCount = bossScope === "specific"
|
||||
? selectedBossKeys.length
|
||||
: BOSS_TESTING_CATALOG.filter((boss) => bossScope === "all" || boss.category === bossScope).length;
|
||||
const partyCount = partyConfigurations * (source === "all" ? 3 : 1);
|
||||
const coverageBuilds = specSelection === "all" ? availableBuilds : selectedBuilds;
|
||||
const partyCount = specSelection === "random"
|
||||
? partyConfigurations * (source === "all" ? 3 : 1)
|
||||
: coveragePartyCount(coverageBuilds);
|
||||
const coveredSpecCount = specSelection === "all" ? availableBuilds.length : selectedBuilds.length;
|
||||
const estimatedRuns = partyCount * bossCount * simulationsPerParty;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -172,6 +213,12 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
: [...current, key]);
|
||||
};
|
||||
|
||||
const toggleBuild = (id: string) => {
|
||||
setSelectedBuildIds((current) => current.includes(id)
|
||||
? current.filter((candidate) => candidate !== id)
|
||||
: [...current, id]);
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
if (!runtimeStatus?.available) {
|
||||
setError(runtimeStatus?.error ?? "The server Balance Lab runtime is unavailable.");
|
||||
@@ -181,13 +228,19 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
setError("Select at least one boss for a specific-boss run.");
|
||||
return;
|
||||
}
|
||||
if (specSelection === "specific" && selectedBuilds.length === 0) {
|
||||
setError("Select at least one class/spec for a specific-spec run.");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const job = await startBalanceLabJob({
|
||||
source,
|
||||
specSelection,
|
||||
selectedBuildIds: specSelection === "specific" ? selectedBuilds.map((build) => build.id) : [],
|
||||
level,
|
||||
partyConfigurations,
|
||||
partyConfigurations: specSelection === "random" ? partyConfigurations : partyCount,
|
||||
simulationsPerParty,
|
||||
durationSeconds,
|
||||
partySeed,
|
||||
@@ -242,11 +295,23 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
<span>{runtimeStatus.available ? runtimeStatus.version : `${runtimeStatus.error} Install Python 3 in the server container, set BALANCE_LAB_PYTHON=python3, and restart the app.`}</span>
|
||||
</div>}
|
||||
<div className="balance-lab-form-grid">
|
||||
<label><span>Party source</span><select value={source} onChange={(event) => setSource(event.currentTarget.value as BalanceLabPartySource)}>
|
||||
<label><span>Party source</span><select value={source} onChange={(event) => {
|
||||
setSource(event.currentTarget.value as BalanceLabPartySource);
|
||||
setSelectedBuildIds([]);
|
||||
setSpecSearch("");
|
||||
}}>
|
||||
<option value="all">WoW + CoA + RoM</option><option value="wow">WoW only</option><option value="coa">CoA only</option><option value="rom">RoM only</option>
|
||||
</select></label>
|
||||
<label><span>Level</span><input type="number" min={1} max={80} value={level} onChange={(event) => setLevel(event.currentTarget.valueAsNumber)} /></label>
|
||||
<label><span>Parties per source</span><input type="number" min={1} max={100} value={partyConfigurations} onChange={(event) => setPartyConfigurations(event.currentTarget.valueAsNumber)} /></label>
|
||||
<label><span>Level</span><input type="number" min={1} max={80} value={level} onChange={(event) => {
|
||||
setLevel(event.currentTarget.valueAsNumber);
|
||||
setSelectedBuildIds([]);
|
||||
}} /></label>
|
||||
<label className="balance-lab-form-grid__wide"><span>Class/spec selection</span><select value={specSelection} onChange={(event) => setSpecSelection(event.currentTarget.value as BalanceLabSpecSelection)}>
|
||||
<option value="random">Random parties</option><option value="all">All specs</option><option value="specific">Specific specs</option>
|
||||
</select></label>
|
||||
{specSelection === "random"
|
||||
? <label><span>Parties per source</span><input type="number" min={1} max={100} value={partyConfigurations} onChange={(event) => setPartyConfigurations(event.currentTarget.valueAsNumber)} /></label>
|
||||
: <label><span>Coverage parties</span><input type="number" readOnly value={partyCount} /></label>}
|
||||
<label><span>Runs per party/boss</span><input type="number" min={1} max={100} value={simulationsPerParty} onChange={(event) => setSimulationsPerParty(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>
|
||||
@@ -254,6 +319,25 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
<option value="all">All bosses</option><option value="wow">WoW bosses</option><option value="rom">RoM bosses</option><option value="specific">Specific selection</option>
|
||||
</select></label>
|
||||
</div>
|
||||
{specSelection !== "random" && <p className="balance-lab-coverage-note">
|
||||
{specSelection === "all"
|
||||
? `${coveredSpecCount} class/spec builds will be covered at least once across the minimum valid party set.`
|
||||
: `${coveredSpecCount} selected class/spec builds will be covered; unselected specs fill required party roles.`}
|
||||
</p>}
|
||||
{specSelection === "specific" && (
|
||||
<section className="balance-lab-boss-picker balance-lab-spec-picker">
|
||||
<header>
|
||||
<input aria-label="Search Balance Lab class specs" value={specSearch} onChange={(event) => setSpecSearch(event.currentTarget.value)} placeholder="Search class, spec, role, or source" />
|
||||
<span className="balance-lab-picker-actions"><button type="button" onClick={() => setSelectedBuildIds((current) => [...new Set([...current, ...filteredBuilds.map((build) => build.id)])])}>Select visible</button><button type="button" onClick={() => setSelectedBuildIds([])}>Clear</button><strong>{selectedBuilds.length} selected</strong></span>
|
||||
</header>
|
||||
<div role="listbox" aria-label="Balance Lab class specs" aria-multiselectable="true">
|
||||
{filteredBuilds.map((build) => <label key={build.id} role="option" aria-selected={selectedBuildSet.has(build.id)} className={selectedBuildSet.has(build.id) ? "is-selected" : ""}>
|
||||
<input type="checkbox" checked={selectedBuildSet.has(build.id)} onChange={() => toggleBuild(build.id)} />
|
||||
<span><strong>{classById(build.classId).name} · {build.orientation}</strong><small>{build.source.toUpperCase()} · {build.role}</small></span>
|
||||
</label>)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{bossScope === "specific" && (
|
||||
<section className="balance-lab-boss-picker">
|
||||
<header><input aria-label="Search Balance Lab bosses" value={bossSearch} onChange={(event) => setBossSearch(event.currentTarget.value)} placeholder="Search boss, dungeon, or key" /><strong>{selectedBossKeys.length} selected</strong></header>
|
||||
@@ -265,7 +349,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<div className="balance-lab-estimate"><span>Party configurations <b>{partyCount.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>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>
|
||||
<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>
|
||||
<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>}
|
||||
@@ -279,7 +363,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
<div className="balance-lab-history-filters"><input type="search" aria-label="Search Balance Lab run history" placeholder="Find source, level, GM…" value={jobSearch} onChange={(event) => setJobSearch(event.currentTarget.value)} /><select aria-label="Filter Balance Lab run status" value={jobStatus} onChange={(event) => setJobStatus(event.currentTarget.value as "all" | BalanceLabJob["status"])}><option value="all">All statuses</option><option value="complete">Complete</option><option value="running">Running</option><option value="queued">Queued</option><option value="failed">Failed</option></select></div>
|
||||
<div className="balance-lab-job-list">
|
||||
{visibleJobs.map((job) => <button key={job.id} type="button" className={job.id === selectedJob?.id ? "is-selected" : ""} onClick={() => setSelectedJobId(job.id)}>
|
||||
<span><strong>{job.spec.source.toUpperCase()} level {job.spec.level}</strong><small>{new Date(job.createdAt).toLocaleString()} · {job.requestedBy}</small>{job.summary && <small>{job.summary.bosses.toLocaleString()} bosses · {job.summary.runs.toLocaleString()} simulations</small>}</span><em className={`is-${job.status}`}>{job.status}</em>
|
||||
<span><strong>{job.spec.source.toUpperCase()} level {job.spec.level}</strong><small>{new Date(job.createdAt).toLocaleString()} · {job.requestedBy} · {job.spec.specSelection ?? "random"} specs</small>{job.summary && <small>{job.summary.bosses.toLocaleString()} bosses · {job.summary.runs.toLocaleString()} simulations</small>}</span><em className={`is-${job.status}`}>{job.status}</em>
|
||||
</button>)}
|
||||
{!loading && visibleJobs.length === 0 && <p className="balance-lab-empty">No saved runs match these filters.</p>}
|
||||
</div>
|
||||
@@ -291,7 +375,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
<p>{describeJob(selectedJob)}</p>
|
||||
<div className="balance-lab-progress" aria-label={`${progressPercent(selectedJob).toFixed(0)} percent complete`}><span style={{ width: `${progressPercent(selectedJob)}%` }} /></div>
|
||||
<dl>
|
||||
<div><dt>Status</dt><dd>{selectedJob.status}</dd></div><div><dt>Parties</dt><dd>{selectedJob.progress.partiesTotal}</dd></div><div><dt>Runs per pairing</dt><dd>{selectedJob.spec.simulationsPerParty}</dd></div><div><dt>Boss filter</dt><dd>{selectedJob.spec.bossKeys.length ? `${selectedJob.spec.bossKeys.length} selected` : selectedJob.spec.bossCategory}</dd></div><div><dt>Fight limit</dt><dd>{selectedJob.spec.durationSeconds}s</dd></div>
|
||||
<div><dt>Status</dt><dd>{selectedJob.status}</dd></div><div><dt>Parties</dt><dd>{selectedJob.progress.partiesTotal}</dd></div><div><dt>Spec selection</dt><dd>{selectedJob.spec.specSelection ?? "random"}{selectedJob.spec.specSelection === "specific" ? ` · ${selectedJob.spec.selectedBuildIds?.length ?? 0} selected` : ""}</dd></div><div><dt>Runs per pairing</dt><dd>{selectedJob.spec.simulationsPerParty}</dd></div><div><dt>Boss filter</dt><dd>{selectedJob.spec.bossKeys.length ? `${selectedJob.spec.bossKeys.length} selected` : selectedJob.spec.bossCategory}</dd></div><div><dt>Fight limit</dt><dd>{selectedJob.spec.durationSeconds}s</dd></div>
|
||||
</dl>
|
||||
{selectedJob.status === "complete" && <div className="balance-lab-artifacts"><button type="button" className="front-button front-button--primary" onClick={() => document.getElementById("balance-lab-analysis")?.scrollIntoView({ behavior: "smooth", block: "start" })}>View graphs and filterable data</button>{selectedJob.artifacts.map((name) => <button key={name} type="button" className="front-button" onClick={() => void openArtifact(selectedJob, name)}>{ARTIFACT_LABELS[name] ?? name}</button>)}</div>}
|
||||
{selectedJob.logTail.length > 0 && <details className="balance-lab-log"><summary>Server progress log</summary><pre>{selectedJob.logTail.slice(-20).join("\n")}</pre></details>}
|
||||
|
||||
Reference in new issue
Block a user