Compare commits

...
2 Commits
Author SHA1 Message Date
phenom d6976e6ef6 Release Healer Man 0.1.53 2026-09-18 12:32:51 -04:00
phenom d0a02487db Release Healer Man 0.1.52 2026-09-18 09:34:59 -04:00
23 changed files with 631 additions and 77 deletions

No files matched your search

+17
View File
@@ -3581,3 +3581,20 @@ Vite's existing large-chunk advisory remains informational.
graphs, and passed desktop plus 480×900 overflow checks without browser graphs, and passed desktop plus 480×900 overflow checks without browser
errors. Evidence is in `playtest-artifacts/balance-lab/02-server-job-config.png` errors. Evidence is in `playtest-artifacts/balance-lab/02-server-job-config.png`
and `playtest-artifacts/balance-lab/03-server-job-complete.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`.
+8
View File
@@ -18,6 +18,14 @@ approval before it commits, pushes `main`, builds the signed APK, and publishes
the Gitea release. Signing passwords and the Gitea token are passed to the the Gitea release. Signing passwords and the Gitea token are passed to the
child process in memory rather than on its command line. child process in memory rather than on its command line.
If a content, server, normal-application, or shelved-application test command
fails, the visual manager pauses with the failed command, exit code, and recent
test output. **Stop Release** remains the safe default, but **Continue Anyway**
allows an intentional override and records that decision in the release log.
Build, content-publication, Git, signing, and APK-publication failures are not
overridable and still stop the workflow. The manual procedure below remains
fail-closed.
Before changing versions or running the longer test/build sequence, the manager Before changing versions or running the longer test/build sequence, the manager
runs an isolated release-candidate check. It validates tracked and untracked runs an isolated release-candidate check. It validates tracked and untracked
files without changing the real Git index, so extra blank lines at EOF and files without changing the real Git index, so extra blank lines at EOF and
+23 -4
View File
@@ -106,7 +106,8 @@ The dashboard includes:
defeat/wipe rates, deaths, and health; defeat/wipe rates, deaths, and health;
- complete class/spec tables with total and boss DPS, healing done/received per - complete class/spec tables with total and boss DPS, healing done/received per
second, DTPS, absolute damage taken, healing done/received and absorb totals, 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 - a complete Abilities tab with learned/available abilities (including zero-use
rows), exact runtime IDs, AI categories, active-run coverage, casts, hits, rows), exact runtime IDs, AI categories, active-run coverage, casts, hits,
casts per minute, effective/raw damage, DPS, damage share, damage per hit, casts per minute, effective/raw damage, DPS, damage share, damage per hit,
@@ -118,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, labels remain readable. They never discard report data: the Bosses, Class/spec,
and Parties tabs retain every matching row. Search, party-source, boss-source, and Parties tabs retain every matching row. Search, party-source, boss-source,
role, metric, and sort-direction controls filter the display without modifying role, metric, and sort-direction controls filter the display without modifying
the saved report. the saved report. **View ability damage** on a class/spec row opens the
Abilities tab with that exact build selected, damaging abilities ranked by
effective damage, and its casts, hits, DPS, damage share, and damage-per-hit
details visible in the table. **Show all specs** clears that drill-down.
Server requirements: Server requirements:
@@ -325,6 +329,21 @@ member. When a suspected class outlier could be policy-sensitive, rerun with a
different `--active-slot`; do not compare active and companion results without different `--active-slot`; do not compare active and companion results without
noting that control-policy difference. noting that control-policy difference.
Companion builds initialize every resource pool required by their learned
abilities, not only the class's primary pool. Secondary Energy and Focus
regenerate, and any companion with a Rage pool gains Rage from landed weapon
attacks; this is what makes druid Cat and Bear rotations executable while Mana
remains the class's primary resource. A completed companion cast resolves the
member's currently learned rank before applying its effects, so cast-time spell
damage and healing cannot fall back to rank one.
`playerbots-inspired-v2` 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.
## Code map ## Code map
- `scripts/balance_lab.py`: prompts, command-line parsing, random/manual/automatic - `scripts/balance_lab.py`: prompts, command-line parsing, random/manual/automatic
@@ -347,8 +366,8 @@ noting that control-policy difference.
- `src/game/benchmark/benchmarkBuilds.ts`: selectable spec builds and legal - `src/game/benchmark/benchmarkBuilds.ts`: selectable spec builds and legal
talent allocation. The older canonical cohort remains unchanged. talent allocation. The older canonical cohort remains unchanged.
- `src/game/partyStore.ts`: `createConfiguredPartyMember`, the narrow factory - `src/game/partyStore.ts`: `createConfiguredPartyMember`, the narrow factory
that preserves production stats, equipment, resources, and appearance for an that preserves production stats, equipment, all learned-ability resource
exact companion. pools, and appearance for an exact companion.
- `src/game/bossTesting.ts`: the authoritative all-boss catalog and isolated - `src/game/bossTesting.ts`: the authoritative all-boss catalog and isolated
room construction. room construction.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.51", "version": "0.1.53",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.51", "version": "0.1.53",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.51", "version": "0.1.53",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.51", "versionName": "0.1.53",
"versionCode": 1051 "versionCode": 1053
} }
+16 -2
View File
@@ -96,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(); const damageRows = await page.locator('.balance-results-table-panel tbody tr').count();
assert(damageRows > 0 && damageRows < allClassRows, 'role filtering should narrow the complete class/spec table'); assert(damageRows > 0 && damageRows < allClassRows, 'role filtering should narrow the complete class/spec table');
await page.getByLabel('Class role').selectOption('all'); await page.getByLabel('Class role').selectOption('all');
await page.getByRole('button', { name: 'abilities', exact: true }).click(); const damageSpecRow = page.locator('.balance-results-table-panel tbody tr').filter({
has: page.locator('td:nth-child(3)').getByText('damage', { exact: true }),
}).first();
const damageSpecClass = await damageSpecRow.locator('td:first-child strong').innerText();
const damageSpecName = await damageSpecRow.locator('td:first-child small').innerText();
await damageSpecRow.getByRole('button', { name: `View ${damageSpecClass} ${damageSpecName} ability damage` }).click();
await page.getByRole('heading', { name: `${damageSpecClass} · ${damageSpecName} ability damage` }).waitFor();
assert.notEqual(await page.getByLabel('Filter ability class spec').inputValue(), 'all');
assert.equal(await page.getByLabel('Filter ability usage').inputValue(), 'damage');
const abilityRows = page.locator('.balance-results-abilities tbody tr'); const abilityRows = page.locator('.balance-results-abilities tbody tr');
assert(await abilityRows.count() > 0, 'ability breakdown should retain observed and available abilities'); assert(await abilityRows.count() > 0, 'spec drill-down should show abilities that dealt damage');
assert(Number((await abilityRows.first().locator('td:nth-child(12)').innerText()).replaceAll(',', '')) > 0, 'drilled ability rows should include effective damage');
const abilityHeaders = await page.locator('.balance-results-abilities thead').innerText();
assert.match(abilityHeaders, /CASTS/);
assert.match(abilityHeaders, /EFFECTIVE DAMAGE/);
assert.match(abilityHeaders, /DAMAGE SHARE/);
assert.match(abilityHeaders, /DPS/);
await page.getByLabel('Filter ability usage').selectOption('unused'); await page.getByLabel('Filter ability usage').selectOption('unused');
assert(await abilityRows.count() > 0, 'available but unused abilities should remain visible'); assert(await abilityRows.count() > 0, 'available but unused abilities should remain visible');
await page.getByLabel('Filter ability usage').selectOption('all'); await page.getByLabel('Filter ability usage').selectOption('all');
+132 -13
View File
@@ -1,4 +1,4 @@
"""Visual, fail-closed release manager for Healer Man. """Visual release manager for Healer Man with explicit safety gates.
The GUI intentionally leaves the TrueNAS portion as copyable shell commands: The GUI intentionally leaves the TrueNAS portion as copyable shell commands:
the app must be stopped and started from the TrueNAS Apps UI around that block. the app must be stopped and started from the TrueNAS Apps UI around that block.
@@ -18,6 +18,7 @@ import sys
import tempfile import tempfile
import threading import threading
import webbrowser import webbrowser
from collections import deque
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable, Iterable, Sequence from typing import Callable, Iterable, Sequence
@@ -59,6 +60,17 @@ FORBIDDEN_SUFFIXES = {
ALLOWED_GENERATED_FILES = { ALLOWED_GENERATED_FILES = {
"public/data/healer-man-loot.sqlite", "public/data/healer-man-loot.sqlite",
} }
OVERRIDABLE_TEST_LABELS = frozenset(
{
"Content tests",
"Server tests",
"Application tests",
"Shelved-mode application tests",
}
)
ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
FAILURE_OUTPUT_LINE_LIMIT = 160
FAILURE_OUTPUT_CHARACTER_LIMIT = 20_000
class ReleaseError(RuntimeError): class ReleaseError(RuntimeError):
@@ -69,6 +81,23 @@ class ReleaseCancelled(ReleaseError):
"""The user cancelled an active release.""" """The user cancelled an active release."""
class ReleaseCommandError(ReleaseError):
"""A release subprocess failed and retained its output for review."""
def __init__(
self,
label: str,
command: Sequence[str],
return_code: int,
output: str,
) -> None:
super().__init__(f"{label} failed with exit code {return_code}.")
self.label = label
self.command = tuple(command)
self.return_code = return_code
self.output = output
@dataclass(frozen=True) @dataclass(frozen=True)
class ReleaseVersion: class ReleaseVersion:
version_name: str version_name: str
@@ -88,6 +117,36 @@ def full_release_checks(npm: str) -> tuple[tuple[list[str], str], ...]:
) )
def can_override_release_check(label: str) -> bool:
"""Return whether the interactive release flow may continue after a failure."""
return label in OVERRIDABLE_TEST_LABELS
def format_failed_test_warning(failure: ReleaseCommandError) -> str:
"""Build the detailed warning shown before overriding a failed test command."""
clean_output = ANSI_ESCAPE_PATTERN.sub("", failure.output).strip()
output_lines = clean_output.splitlines()
omitted_lines = max(0, len(output_lines) - FAILURE_OUTPUT_LINE_LIMIT)
if omitted_lines:
output_lines = output_lines[-FAILURE_OUTPUT_LINE_LIMIT:]
recent_output = "\n".join(output_lines)
if len(recent_output) > FAILURE_OUTPUT_CHARACTER_LIMIT:
recent_output = recent_output[-FAILURE_OUTPUT_CHARACTER_LIMIT:]
recent_output = "… earlier output omitted …\n" + recent_output
elif omitted_lines:
recent_output = f"… {omitted_lines} earlier line(s) omitted …\n" + recent_output
if not recent_output:
recent_output = "(The command produced no output.)"
command = subprocess.list2cmdline(list(failure.command))
return (
f"{failure.label} failed with exit code {failure.return_code}.\n\n"
f"Command:\n{command}\n\n"
f"Recent output:\n{recent_output}\n\n"
"Continuing can publish a release with known test failures. "
"Only continue if you reviewed the failure and accept that risk."
)
def parse_semver(value: str) -> tuple[int, int, int]: def parse_semver(value: str) -> tuple[int, int, int]:
match = SEMVER_PATTERN.fullmatch(value.strip()) match = SEMVER_PATTERN.fullmatch(value.strip())
if not match: if not match:
@@ -1116,7 +1175,25 @@ def launch_gui(*, smoke_test: bool = False) -> int:
) )
for command, label in full_release_checks(npm): for command, label in full_release_checks(npm):
self._run_command(command, label) try:
self._run_command(command, label)
except ReleaseCommandError as failure:
if not can_override_release_check(label):
raise
approved = self._request_approval(
format_failed_test_warning(failure),
title=f"Warning: {label} failed",
heading="Tests failed — continue the release?",
approve_label="Continue Anyway",
cancel_label="Stop Release",
)
if not approved:
raise ReleaseCancelled(
f"Release stopped because {label} failed."
) from failure
self._log_event(
f"⚠ Continuing after failed {label}; override approved by user."
)
self._run_command(["git", "add", "-A"], "Stage release") self._run_command(["git", "add", "-A"], "Stage release")
staged = True staged = True
@@ -1267,31 +1344,66 @@ def launch_gui(*, smoke_test: bool = False) -> int:
) )
self.current_process = process self.current_process = process
assert process.stdout is not None assert process.stdout is not None
output_lines: deque[str] = deque(maxlen=FAILURE_OUTPUT_LINE_LIMIT)
try: try:
for line in process.stdout: for line in process.stdout:
self._log_event(line.rstrip("\n")) rendered = line.rstrip("\n")
output_lines.append(rendered)
self._log_event(rendered)
return_code = process.wait() return_code = process.wait()
finally: finally:
self.current_process = None self.current_process = None
if self.cancel_event.is_set(): if self.cancel_event.is_set():
raise ReleaseCancelled("Release cancelled.") raise ReleaseCancelled("Release cancelled.")
if return_code != 0: if return_code != 0:
raise ReleaseError(f"{label} failed with exit code {return_code}.") raise ReleaseCommandError(
label,
args,
return_code,
"\n".join(output_lines),
)
self._log_event(f"✓ {label} passed") self._log_event(f"✓ {label} passed")
def _request_approval(self, review: str) -> bool: def _request_approval(
self,
review: str,
*,
title: str = "Review staged release",
heading: str = "Inspect every staged path before publishing",
approve_label: str = "Commit, Push, and Publish",
cancel_label: str = "Cancel release",
) -> bool:
response: dict[str, bool] = {} response: dict[str, bool] = {}
ready = threading.Event() ready = threading.Event()
self.events.put(("approval", review, response, ready)) self.events.put(
(
"approval",
review,
title,
heading,
approve_label,
cancel_label,
response,
ready,
)
)
while not ready.wait(0.2): while not ready.wait(0.2):
if self.cancel_event.is_set(): if self.cancel_event.is_set():
return False return False
return response.get("approved", False) return response.get("approved", False)
def _show_staged_review(self, review: str) -> bool: def _show_approval(
self,
review: str,
*,
title: str,
heading: str,
approve_label: str,
cancel_label: str,
) -> bool:
decision = {"approved": False} decision = {"approved": False}
dialog = tk.Toplevel(self.root) dialog = tk.Toplevel(self.root)
dialog.title("Review staged release") dialog.title(title)
dialog.geometry("820x640") dialog.geometry("820x640")
dialog.minsize(640, 420) dialog.minsize(640, 420)
dialog.configure(bg=self.BG) dialog.configure(bg=self.BG)
@@ -1301,7 +1413,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
dialog.columnconfigure(0, weight=1) dialog.columnconfigure(0, weight=1)
ttk.Label( ttk.Label(
dialog, dialog,
text="Inspect every staged path before publishing", text=heading,
style="Title.TLabel", style="Title.TLabel",
).grid(row=0, column=0, sticky="w", padx=18, pady=(16, 10)) ).grid(row=0, column=0, sticky="w", padx=18, pady=(16, 10))
review_text = scrolledtext.ScrolledText( review_text = scrolledtext.ScrolledText(
@@ -1326,12 +1438,12 @@ def launch_gui(*, smoke_test: bool = False) -> int:
dialog.grab_release() dialog.grab_release()
dialog.destroy() dialog.destroy()
ttk.Button(buttons, text="Cancel release", command=lambda: finish(False)).pack( ttk.Button(buttons, text=cancel_label, command=lambda: finish(False)).pack(
side="left" side="left"
) )
ttk.Button( ttk.Button(
buttons, buttons,
text="Commit, Push, and Publish", text=approve_label,
style="Accent.TButton", style="Accent.TButton",
command=lambda: finish(True), command=lambda: finish(True),
).pack(side="right") ).pack(side="right")
@@ -1375,8 +1487,15 @@ def launch_gui(*, smoke_test: bool = False) -> int:
if kind == "log": if kind == "log":
self._append_log(str(event[1])) self._append_log(str(event[1]))
elif kind == "approval": elif kind == "approval":
review, response, ready = event[1], event[2], event[3] review, title, heading, approve_label, cancel_label = event[1:6]
approved = self._show_staged_review(str(review)) response, ready = event[6], event[7]
approved = self._show_approval(
str(review),
title=str(title),
heading=str(heading),
approve_label=str(approve_label),
cancel_label=str(cancel_label),
)
assert isinstance(response, dict) assert isinstance(response, dict)
response["approved"] = approved response["approved"] = approved
assert isinstance(ready, threading.Event) assert isinstance(ready, threading.Event)
+33
View File
@@ -54,6 +54,39 @@ class SafetyTests(unittest.TestCase):
self.assertLess(publish_index, commands.index(["npm", "run", "build"])) self.assertLess(publish_index, commands.index(["npm", "run", "build"]))
self.assertLess(publish_index, commands.index(["npm", "run", "android:sync"])) self.assertLess(publish_index, commands.index(["npm", "run", "android:sync"]))
def test_only_test_failures_may_be_overridden(self):
checks = release_manager.full_release_checks("npm")
overridable = {
label for _command, label in checks
if release_manager.can_override_release_check(label)
}
self.assertEqual(
overridable,
{
"Content tests",
"Server tests",
"Application tests",
"Shelved-mode application tests",
},
)
self.assertFalse(release_manager.can_override_release_check("Production build"))
self.assertFalse(release_manager.can_override_release_check("Push main"))
def test_failed_test_warning_includes_command_and_specific_output(self):
failure = release_manager.ReleaseCommandError(
"Application tests",
["npm", "test"],
1,
"\x1b[31mFAIL\x1b[39m src/game/example.test.ts\n"
"Error: expected 2 to be 3",
)
warning = release_manager.format_failed_test_warning(failure)
self.assertIn("Application tests failed with exit code 1", warning)
self.assertIn("npm test", warning)
self.assertIn("FAIL src/game/example.test.ts", warning)
self.assertIn("Error: expected 2 to be 3", warning)
self.assertNotIn("\x1b", warning)
class ReleaseCandidateTests(unittest.TestCase): class ReleaseCandidateTests(unittest.TestCase):
def test_checks_untracked_files_without_changing_the_real_index(self): def test_checks_untracked_files_without_changing_the_real_index(self):
+1 -1
View File
@@ -44,7 +44,7 @@ async function derivePassword(password, salt) {
return Buffer.from(await scrypt(password, salt, 64)); return Buffer.from(await scrypt(password, salt, 64));
} }
const GM_USERNAME_KEYS = new Set(["phenom"]); const GM_USERNAME_KEYS = new Set(["phenom", "codex_dev_gm"]);
function accountRoles(username) { function accountRoles(username) {
return GM_USERNAME_KEYS.has(String(username ?? "").trim().toLowerCase()) ? ["gm"] : []; return GM_USERNAME_KEYS.has(String(username ?? "").trim().toLowerCase()) ? ["gm"] : [];
+4 -1
View File
@@ -56,12 +56,15 @@ test("persists opaque cloud roster data with increasing revisions", () => withDa
assert.equal(database.getCloudSave(registered.account.id).data.characters[0].name, "Mendbetter"); assert.equal(database.getCloudSave(registered.account.id).data.characters[0].name, "Mendbetter");
})); }));
test("derives GM authority from the hardcoded Phenom account", () => withDatabase(async (database) => { test("derives GM authority from the hardcoded developer accounts", () => withDatabase(async (database) => {
const gm = await database.register("Phenom", "storm-admin"); const gm = await database.register("Phenom", "storm-admin");
const developerGm = await database.register("codex_dev_gm", "developer-admin");
const player = await database.register("Ordinary", "storm-player"); const player = await database.register("Ordinary", "storm-player");
assert.deepEqual(gm.account.roles, ["gm"]); assert.deepEqual(gm.account.roles, ["gm"]);
assert.deepEqual((await database.login("pHeNoM", "storm-admin")).account.roles, ["gm"]); assert.deepEqual((await database.login("pHeNoM", "storm-admin")).account.roles, ["gm"]);
assert.deepEqual(developerGm.account.roles, ["gm"]);
assert.equal(database.isGameMaster(database.authenticate(gm.token)), true); assert.equal(database.isGameMaster(database.authenticate(gm.token)), true);
assert.equal(database.isGameMaster(database.authenticate(developerGm.token)), true);
assert.equal(database.isGameMaster(database.authenticate(player.token)), false); assert.equal(database.isGameMaster(database.authenticate(player.token)), false);
assert.equal(database.isGameMaster({ ...player.account, roles: ["gm"] }), false); assert.equal(database.isGameMaster({ ...player.account, roles: ["gm"] }), false);
})); }));
@@ -219,6 +219,10 @@ export class BenchmarkController {
comboTargetId: combat.comboTargetId, comboTargetId: combat.comboTargetId,
runes: combat.deathKnightRunes, runes: combat.deathKnightRunes,
abilities: availableAbilities, abilities: availableAbilities,
knownDamagingAbilityNames: new Set(availableAbilities.filter((ability) => {
const categories = aiHintsForAbility(ability).categories;
return categories.includes("damage") || categories.includes("execute");
}).map((ability) => ability.name)),
hostileTarget, hostileTarget,
friendlyTargets: [ friendlyTargets: [
{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, fallen: combat.health <= 0 }, { id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, fallen: combat.health <= 0 },
+4 -1
View File
@@ -9,7 +9,10 @@ import { deriveCharacterStats } from "./wotlkStats";
export function companionResourceMaximum(member: PartyMember, id: string): number { export function companionResourceMaximum(member: PartyMember, id: string): number {
return id === member.resourceType ? member.maxResource : id === "mana" && !isRomClassId(member.classId) return id === member.resourceType ? member.maxResource : id === "mana" && !isRomClassId(member.classId)
? deriveCharacterStats(baseClassFor(member.classId), member.level, member.gearStats, member.raceId).mana : 100; ? deriveCharacterStats(baseClassFor(member.classId), member.level, member.gearStats, member.raceId).mana
: id === "nature-power" ? 10
: id === "psi" ? 6
: 100;
} }
/** /**
+67
View File
@@ -285,6 +285,73 @@ describe("shared combat rotations", () => {
.toBe("strike"); .toBe("strike");
}); });
it("requires modeled combat state for reactive attacks and named execute-range spells", () => {
const revenge = ability("warrior", "Revenge");
const warrior = context({
classId: "warrior",
specializationId: "warrior:protection",
role: "tank",
primaryResource: "rage",
resources: { rage: 100 },
maximumResources: { rage: 100 },
abilities: [revenge],
});
expect(evaluateCombatRotation(warrior)).toBeNull();
expect(evaluateCombatRotation({
...warrior,
availableConditionalAbilityNames: new Set(["Revenge"]),
})?.ability.id).toBe(revenge.id);
const drainSoul = ability("warlock", "Drain Soul");
const shadowBolt = ability("warlock", "Shadow Bolt");
const warlock = context({
classId: "warlock",
specializationId: "warlock:affliction",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [drainSoul, shadowBolt],
});
expect(evaluateCombatRotation(warlock)?.ability.id).toBe(shadowBolt.id);
expect(evaluateCombatRotation({
...warlock,
hostileTarget: { id: "target", health: 250, maxHealth: 1_000 },
})?.ability.id).toBe(drainSoul.id);
});
it("keeps available specialization actions ahead of off-profile throughput", () => {
const fireball = ability("mage", "Fireball");
const arcaneMissiles = ability("mage", "Arcane Missiles");
const mage = context({
classId: "mage",
specializationId: "mage:fire",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [arcaneMissiles, fireball],
});
expect(evaluateCombatRotation(mage)?.ability.id).toBe(fireball.id);
const bearMangle = ability("druid", "Mangle (Bear)");
const rake = ability("druid", "Rake");
const moonfire = ability("druid", "Moonfire");
const bear = context({
classId: "druid",
specializationId: "druid:feral-bear",
role: "tank",
primaryResource: "mana",
resources: { mana: 10_000, rage: 100, energy: 100 },
maximumResources: { mana: 10_000, rage: 100, energy: 100 },
abilities: [rake, bearMangle],
});
expect(evaluateCombatRotation(bear)?.ability.id).toBe(bearMangle.id);
expect(evaluateCombatRotation({
...bear,
abilities: [rake, moonfire],
knownDamagingAbilityNames: new Set([rake.name, moonfire.name, bearMangle.name]),
})).toBeNull();
});
it("never falls a full-health friendly maintenance heal through to a hostile target", () => { it("never falls a full-health friendly maintenance heal through to a hostile target", () => {
const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]); const strike = semanticAbility("strike", [{ kind: "damage", coefficient: 1 }]);
const prayer = semanticAbility("prayer", [ const prayer = semanticAbility("prayer", [
+77 -10
View File
@@ -7,7 +7,7 @@ import { spendDeathKnightRunes } from "./combatResources";
import { healingAbilityCanAidOthers } from "./healingAbilityRules"; import { healingAbilityCanAidOthers } from "./healingAbilityRules";
import type { PartyRole } from "./partyRoles"; import type { PartyRole } from "./partyRoles";
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v1"; export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v2";
export interface RotationProfile { export interface RotationProfile {
readonly id: string; readonly id: string;
@@ -51,6 +51,8 @@ export interface RotationContext {
readonly comboTargetId: string | null; readonly comboTargetId: string | null;
readonly runes: readonly DeathKnightRuneState[]; readonly runes: readonly DeathKnightRuneState[];
readonly abilities: readonly AbilityDefinition[]; readonly abilities: readonly AbilityDefinition[];
/** Learned actions capable of hostile damage, including unavailable ones. */
readonly knownDamagingAbilityNames?: ReadonlySet<string>;
readonly hostileTarget: RotationHostileTarget | null; readonly hostileTarget: RotationHostileTarget | null;
readonly friendlyTargets?: readonly RotationFriendlyTarget[]; readonly friendlyTargets?: readonly RotationFriendlyTarget[];
readonly hostileCount?: number; readonly hostileCount?: number;
@@ -62,6 +64,8 @@ export interface RotationContext {
readonly activeAuraExclusiveGroups?: ReadonlySet<string>; readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
readonly activeSummonAbilityIds?: ReadonlySet<string>; readonly activeSummonAbilityIds?: ReadonlySet<string>;
readonly hasActivePet?: boolean; readonly hasActivePet?: boolean;
/** Proc/state-gated actions that the caller has positively made available. */
readonly availableConditionalAbilityNames?: ReadonlySet<string>;
readonly canUseAbility?: (ability: AbilityDefinition) => boolean; readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
} }
@@ -108,7 +112,7 @@ export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.free
profile("shaman:enhancement", "shaman", ["Windfury Weapon", "Rockbiter Weapon", "Lightning Shield", "Feral Spirit", "Stormstrike", "Lava Lash", "Earth Shock", "Lightning Bolt"]), profile("shaman:enhancement", "shaman", ["Windfury Weapon", "Rockbiter Weapon", "Lightning Shield", "Feral Spirit", "Stormstrike", "Lava Lash", "Earth Shock", "Lightning Bolt"]),
profile("shaman:restoration", "shaman", ["Water Shield", "Rockbiter Weapon", "Lightning Shield", "Riptide", "Chain Heal", "Lesser Healing Wave", "Healing Wave", "Earth Shield"]), profile("shaman:restoration", "shaman", ["Water Shield", "Rockbiter Weapon", "Lightning Shield", "Riptide", "Chain Heal", "Lesser Healing Wave", "Healing Wave", "Earth Shield"]),
profile("mage:arcane", "mage", ["Mage Armor", "Ice Armor", "Frost Armor", "Arcane Barrage", "Arcane Blast", "Arcane Missiles"]), profile("mage:arcane", "mage", ["Mage Armor", "Ice Armor", "Frost Armor", "Arcane Barrage", "Arcane Blast", "Arcane Missiles"]),
profile("mage:fire", "mage", ["Molten Armor", "Mage Armor", "Ice Armor", "Frost Armor", "Living Bomb", "Pyroblast", "Fireball", "Scorch", "Fire Blast"]), profile("mage:fire", "mage", ["Molten Armor", "Mage Armor", "Ice Armor", "Frost Armor", "Living Bomb", "Fireball", "Scorch", "Fire Blast"]),
profile("mage:frost", "mage", ["Frost Armor", "Ice Armor", "Mage Armor", "Deep Freeze", "Frostbolt", "Ice Lance", "Cone of Cold"]), profile("mage:frost", "mage", ["Frost Armor", "Ice Armor", "Mage Armor", "Deep Freeze", "Frostbolt", "Ice Lance", "Cone of Cold"]),
profile("warlock:affliction", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felhunter", "Summon Voidwalker", "Summon Imp", "Haunt", "Unstable Affliction", "Corruption", "Curse of Agony", "Drain Soul", "Shadow Bolt"]), profile("warlock:affliction", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felhunter", "Summon Voidwalker", "Summon Imp", "Haunt", "Unstable Affliction", "Corruption", "Curse of Agony", "Drain Soul", "Shadow Bolt"]),
profile("warlock:demonology", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felguard", "Summon Voidwalker", "Summon Imp", "Metamorphosis", "Immolate", "Corruption", "Shadow Bolt"]), profile("warlock:demonology", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felguard", "Summon Voidwalker", "Summon Imp", "Metamorphosis", "Immolate", "Corruption", "Shadow Bolt"]),
@@ -123,6 +127,45 @@ const PROFILE_BY_SPECIALIZATION = new Map(
CLASSIC_ROTATION_PROFILES.map((entry) => [entry.specializationId, entry]), CLASSIC_ROTATION_PROFILES.map((entry) => [entry.specializationId, entry]),
); );
const CONDITIONAL_ABILITY_NAMES = new Set([
"Overpower",
"Revenge",
"Victory Rush",
]);
const TARGET_HEALTH_GATES = new Map<string, number>([
["Drain Soul", 0.25],
["Hammer of Wrath", 0.2],
["Kill Shot", 0.2],
]);
const BEAR_FORM_ACTION_NAMES = new Set([
"Bash",
"Challenging Roar",
"Demoralizing Roar",
"Frenzied Regeneration",
"Growl",
"Lacerate",
"Mangle (Bear)",
"Maul",
"Swipe (Bear)",
]);
const CAT_FORM_ACTION_NAMES = new Set([
"Claw",
"Cower",
"Ferocious Bite",
"Mangle (Cat)",
"Pounce",
"Rake",
"Ravage",
"Rip",
"Savage Roar",
"Shred",
"Swipe (Cat)",
"Tiger's Fury",
]);
function slug(value: string): string { function slug(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
} }
@@ -303,32 +346,52 @@ function scoreAbility(
|| (context.hostileTarget?.maxHealth ?? 0) >= context.maxHealth * 5, || (context.hostileTarget?.maxHealth ?? 0) >= context.maxHealth * 5,
); );
const profileIndex = rotationProfile?.abilityPriority.findIndex((name) => name === ability.name) ?? -1; const profileIndex = rotationProfile?.abilityPriority.findIndex((name) => name === ability.name) ?? -1;
const knownDamagingAbilityNames = context.knownDamagingAbilityNames
?? new Set(context.abilities.filter((candidate) => {
const candidateCategories = aiHintsForAbility(candidate).categories;
return candidateCategories.includes("damage") || candidateCategories.includes("execute");
}).map((candidate) => candidate.name));
const hasKnownProfileDamageAction = Boolean(rotationProfile?.abilityPriority.some((name) => (
knownDamagingAbilityNames.has(name)
)));
const targetHealthGate = TARGET_HEALTH_GATES.get(ability.name);
if (targetHealthGate !== undefined && targetHealth > targetHealthGate) return null;
if (CONDITIONAL_ABILITY_NAMES.has(ability.name)
&& !context.availableConditionalAbilityNames?.has(ability.name)) return null;
if (context.classId === "druid") {
const specializationId = rotationProfile?.specializationId;
if (specializationId === "druid:feral-bear" && CAT_FORM_ACTION_NAMES.has(ability.name)) return null;
if (specializationId === "druid:feral-cat" && BEAR_FORM_ACTION_NAMES.has(ability.name)) return null;
if (specializationId && !specializationId.startsWith("druid:feral-")
&& (BEAR_FORM_ACTION_NAMES.has(ability.name) || CAT_FORM_ACTION_NAMES.has(ability.name))) return null;
}
if (categories.has("resurrection")) { if (categories.has("resurrection")) {
const fallen = (context.friendlyTargets ?? []).find((target) => target.fallen); const fallen = (context.friendlyTargets ?? []).find((target) => target.fallen);
return fallen ? { ability, score: 20_000, ruleId: "reactive-resurrection", reason: "fallen ally", targetId: fallen.id } : null; return fallen ? { ability, score: 1_020_000, ruleId: "reactive-resurrection", reason: "fallen ally", targetId: fallen.id } : null;
} }
if (categories.has("dispel")) { if (categories.has("dispel")) {
const dispel = (context.friendlyTargets ?? []).find((target) => target.needsDispel); const dispel = (context.friendlyTargets ?? []).find((target) => target.needsDispel);
if (dispel) return { ability, score: 19_000, ruleId: "reactive-dispel", reason: "ally needs dispel", targetId: dispel.id }; if (dispel) return { ability, score: 1_019_000, ruleId: "reactive-dispel", reason: "ally needs dispel", targetId: dispel.id };
if (!damaging) return null; if (!damaging) return null;
} }
if (categories.has("interrupt")) { if (categories.has("interrupt")) {
if (!context.hostileTarget?.activeCast?.interruptible) { if (!context.hostileTarget?.activeCast?.interruptible) {
if (!damaging) return null; if (!damaging) return null;
} else { } else {
return { ability, score: 18_000, ruleId: "reactive-interrupt", reason: "interruptible enemy cast", targetId: context.hostileTarget.id }; return { ability, score: 1_018_000, ruleId: "reactive-interrupt", reason: "interruptible enemy cast", targetId: context.hostileTarget.id };
} }
} }
if (categories.has("taunt")) { if (categories.has("taunt")) {
if (context.role !== "tank" || !context.targetIsLoose) return null; if (context.role !== "tank" || !context.targetIsLoose) return null;
return { ability, score: 17_000, ruleId: "reactive-taunt", reason: "loose hostile target", targetId: context.hostileTarget?.id ?? null }; return { ability, score: 1_017_000, ruleId: "reactive-taunt", reason: "loose hostile target", targetId: context.hostileTarget?.id ?? null };
} }
if (categories.has("threat") && !damaging && !maintenanceBuff) { if (categories.has("threat") && !damaging && !maintenanceBuff) {
if (context.role === "tank" || !context.hasDangerousThreat) return null; if (context.role === "tank" || !context.hasDangerousThreat) return null;
return { return {
ability, ability,
score: 15_000, score: 1_015_000,
ruleId: "reactive-threat-drop", ruleId: "reactive-threat-drop",
reason: "non-tank has enemy attention", reason: "non-tank has enemy attention",
targetId: ability.target === "self" ? null : context.hostileTarget?.id ?? null, targetId: ability.target === "self" ? null : context.hostileTarget?.id ?? null,
@@ -359,12 +422,14 @@ function scoreAbility(
&& !petSummon && !offensiveSummon) return null; && !petSummon && !offensiveSummon) return null;
if (categories.has("movement") && !damaging && !context.moving) return null; if (categories.has("movement") && !damaging && !context.moving) return null;
if (context.requiredControl && categories.has("control")) { if (context.requiredControl && categories.has("control")) {
return { ability, score: 16_000, ruleId: "reactive-control", reason: "encounter requested control", targetId: context.hostileTarget?.id ?? null }; return { ability, score: 1_016_000, ruleId: "reactive-control", reason: "encounter requested control", targetId: context.hostileTarget?.id ?? null };
} }
if (offensiveMajorCooldown && !durableTarget) return null; if (offensiveMajorCooldown && !durableTarget) return null;
if (maintained && (categories.has("debuff") || categories.has("buff"))) return null; if (maintained && (categories.has("debuff") || categories.has("buff"))) return null;
if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null; if (activeSummon && (categories.has("summon") || categories.has("pet"))) return null;
if (rotationProfile && profileIndex < 0 && !damaging && (maintenanceBuff || petSummon || offensiveSummon)) return null; if (rotationProfile && profileIndex < 0 && !damaging && (maintenanceBuff || petSummon || offensiveSummon)) return null;
if (rotationProfile && context.level >= 10 && hasKnownProfileDamageAction
&& context.role !== "healer" && profileIndex < 0 && damaging) return null;
if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) return null; if (isComboFinisher(ability) && (context.comboPoints < 4 || context.comboTargetId !== context.hostileTarget?.id)) return null;
if (isComboBuilder(ability) && context.comboPoints >= 5 && context.comboTargetId === context.hostileTarget?.id) return null; if (isComboBuilder(ability) && context.comboPoints >= 5 && context.comboTargetId === context.hostileTarget?.id) return null;
@@ -374,7 +439,9 @@ function scoreAbility(
const maintenanceScore = rotationProfile === null ? 5_200 : profileIndex >= 0 ? 7_000 : 2_000; const maintenanceScore = rotationProfile === null ? 5_200 : profileIndex >= 0 ? 7_000 : 2_000;
const summonScore = rotationProfile === null ? 4_800 : profileIndex >= 0 ? 6_500 : 1_800; const summonScore = rotationProfile === null ? 4_800 : profileIndex >= 0 ? 6_500 : 1_800;
if (profileIndex >= 0) { if (profileIndex >= 0) {
score += 4_000 - profileIndex * 120; score += context.role === "healer" || context.level < 10
? 4_000 - profileIndex * 120
: 100_000 - profileIndex * 1_000;
ruleId = `profile-${slug(ability.name)}`; ruleId = `profile-${slug(ability.name)}`;
reason = `${rotationProfile!.specializationId} priority ${profileIndex + 1}`; reason = `${rotationProfile!.specializationId} priority ${profileIndex + 1}`;
} }
@@ -407,7 +474,7 @@ function scoreAbility(
} else { } else {
if (categories.has("mitigation")) { if (categories.has("mitigation")) {
if (!maintenanceBuff && selfHealth >= 0.55) return null; if (!maintenanceBuff && selfHealth >= 0.55) return null;
if (selfHealth < 0.55) score += selfHealth < 0.35 ? 8_000 : 3_000; if (selfHealth < 0.55) score += selfHealth < 0.35 ? 180_000 : 140_000;
} }
if (categories.has("execute")) { if (categories.has("execute")) {
if (targetHealth > 0.2) return null; if (targetHealth > 0.2) return null;
+1 -1
View File
@@ -117,7 +117,7 @@ describe("dungeon boss durability and difficulty", () => {
expect(ordinary.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(25); expect(ordinary.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(25);
expect(last.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(45); expect(last.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(45);
} }
}); }, 30000);
} }
it("allows real Stockades boss mechanics during a level-three WoW Priest party fight", () => { it("allows real Stockades boss mechanics during a level-three WoW Priest party fight", () => {
+8 -4
View File
@@ -60,11 +60,10 @@ export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinit
return !runeCost || spendDeathKnightRunes(member.runes, runeCost, now) !== null; return !runeCost || spendDeathKnightRunes(member.runes, runeCost, now) !== null;
} }
function candidateAbilities(member: PartyMember, now: number): readonly AbilityDefinition[] { function learnedAbilities(member: PartyMember): readonly AbilityDefinition[] {
return abilitiesForCharacter(member.classId, member.secondaryClassId) return abilitiesForCharacter(member.classId, member.secondaryClassId)
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks)) .map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
.filter((ability): ability is AbilityDefinition => ability !== null) .filter((ability): ability is AbilityDefinition => ability !== null);
.filter((ability) => partyAbilityIsReady(member, ability, now));
} }
export function choosePartyRotationDecision( export function choosePartyRotationDecision(
@@ -85,6 +84,7 @@ export function choosePartyRotationDecision(
[...new Set([...Object.keys(member.resourcePools), member.resourceType])] [...new Set([...Object.keys(member.resourcePools), member.resourceType])]
.map((resource) => [resource, companionResourceMaximum(member, resource)]), .map((resource) => [resource, companionResourceMaximum(member, resource)]),
); );
const learned = learnedAbilities(member);
return evaluateCombatRotation({ return evaluateCombatRotation({
now: context.now, now: context.now,
classId: member.classId, classId: member.classId,
@@ -102,7 +102,11 @@ export function choosePartyRotationDecision(
comboPoints: member.comboPoints, comboPoints: member.comboPoints,
comboTargetId: member.comboTargetId, comboTargetId: member.comboTargetId,
runes: member.runes, runes: member.runes,
abilities: candidateAbilities(member, context.now), abilities: learned.filter((ability) => partyAbilityIsReady(member, ability, context.now)),
knownDamagingAbilityNames: new Set(learned.filter((ability) => {
const categories = aiHintsForAbility(ability).categories;
return categories.includes("damage") || categories.includes("execute");
}).map((ability) => ability.name)),
hostileTarget: context.target, hostileTarget: context.target,
friendlyTargets, friendlyTargets,
hostileCount: context.hostileCount, hostileCount: context.hostileCount,
+82 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createDungeonParty, usePartyStore, type PartyMember } from "./partyStore"; import { createConfiguredPartyMember, createDungeonParty, usePartyStore, type PartyMember } from "./partyStore";
import { useCombatStore } from "./combatStore"; import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry"; import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
@@ -76,6 +76,87 @@ describe("NPC sustained damage audit", () => {
expect(useCombatStore.getState().mobs["area:c"].health).toBe(1_000); expect(useCombatStore.getState().mobs["area:c"].health).toBe(1_000);
}); });
it("generates Bear-form Rage from weapon hits and regenerates Cat-form Energy", () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const bear = createConfiguredPartyMember({
name: "Balance Bear",
classId: "druid",
specialization: "Feral (Bear)",
specializationId: "druid:feral-bear",
talentTreeId: "druid-feral-bear",
talentRanks: {},
role: "tank",
}, 80, 0, "feral-runtime");
usePartyStore.setState({ members: [{ ...bear, globalCooldownEndsAt: 60_000 }] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(bear.id, [0, 0, 0]);
useCombatStore.getState().registerMob("feral-target", { name: "Training target", maxHealth: 100_000, xpReward: 0 });
setMobPosition("feral-target", [2, 0, 0]);
useCombatStore.getState().damageMob("feral-target", 1, 100);
updatePartyRuntimeTargetVisibility(bear.id, "feral-target", true);
advanceDungeonPartyCombat(1_000);
expect(usePartyStore.getState().members[0].resourcePools.rage).toBe(6);
const cat = createConfiguredPartyMember({
name: "Balance Cat",
classId: "druid",
specialization: "Feral (Cat)",
specializationId: "druid:feral-cat",
talentTreeId: "druid-feral-cat",
talentRanks: {},
role: "damage",
}, 80, 1, "feral-runtime");
usePartyStore.setState({ members: [{
...cat,
globalCooldownEndsAt: 60_000,
resourcePools: { ...cat.resourcePools, energy: 0 },
}] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(cat.id, [0, 0, 0]);
updatePartyRuntimeTargetVisibility(cat.id, "feral-target", true);
advanceDungeonPartyCombat(2_000);
expect(usePartyStore.getState().members[0].resourcePools.energy).toBe(10);
});
it("finishes companion casts with the learned level rank instead of rank one", () => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
const mage = createConfiguredPartyMember({
name: "Balance Frost Mage",
classId: "mage",
specialization: "Frost",
specializationId: "mage:frost",
talentTreeId: "mage-frost",
role: "damage",
}, 80, 0, "ranked-cast");
const frostbolt = abilitiesForClass("mage").find((ability) => ability.name === "Frostbolt")!;
usePartyStore.setState({ members: [{
...mage,
cooldowns: Object.fromEntries(abilitiesForClass("mage")
.filter((ability) => ability.id !== frostbolt.id)
.map((ability) => [ability.id, 60_000])),
}] });
useCombatStore.getState().synchronizeActors();
registerPartyRuntimePosition(mage.id, [-12, 0, 0]);
useCombatStore.getState().registerMob("ranked-target", { name: "Training target", maxHealth: 100_000, xpReward: 0 });
setMobPosition("ranked-target", [0, 0, 0]);
useCombatStore.getState().damageMob("ranked-target", 1, 100);
updatePartyRuntimeTargetVisibility(mage.id, "ranked-target", true);
for (let now = 1_000; now <= 6_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
const hit = useCombatStore.getState().combatEvents.find((event) => (
event.kind === "damage" && event.sourceActorId === mage.id && event.abilityId === frostbolt.id
));
expect(hit?.rawAmount).toBeGreaterThan(700);
});
for (const level of [1, 3, 5, 20, 80]) { for (const level of [1, 3, 5, 20, 80]) {
it(`executes damaging abilities for every party template at level ${level}`, () => { it(`executes damaging abilities for every party template at level ${level}`, () => {
const templates = partyTemplates(level); const templates = partyTemplates(level);
+41 -23
View File
@@ -18,7 +18,7 @@ import {
} from "./abilityRules"; } from "./abilityRules";
import type { MobCombatState } from "./combatStore"; import type { MobCombatState } from "./combatStore";
import { useCombatStore } from "./combatStore"; import { useCombatStore } from "./combatStore";
import { memberFromCompanionActor } from "./combatActorAuthority"; import { companionResourceMaximum, memberFromCompanionActor } from "./combatActorAuthority";
import type { MobVector3 } from "./mobPopulation"; import type { MobVector3 } from "./mobPopulation";
import { requireDungeonDefinition } from "./dungeonRegistry"; import { requireDungeonDefinition } from "./dungeonRegistry";
import { getMobPosition } from "./mobRuntimeRegistry"; import { getMobPosition } from "./mobRuntimeRegistry";
@@ -713,9 +713,13 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu
targetActorId: targetId, abilityId, name: "Auto Attack", source: "fallback", school: "physical", targetActorId: targetId, abilityId, name: "Auto Attack", source: "fallback", school: "physical",
delivery: baseClassFor(member.classId) === "hunter" ? "projectile" : identity.delivery, origin, targetPosition, instantCast: true }); delivery: baseClassFor(member.classId) === "hunter" ? "projectile" : identity.delivery, origin, targetPosition, instantCast: true });
} }
if (member.resourceType !== "rage" || result.amount <= 0) return member; if (member.resourcePools.rage === undefined || result.amount <= 0) return member;
const resource = Math.min(member.maxResource, member.resource + 6); const rage = Math.min(companionResourceMaximum(member, "rage"), member.resourcePools.rage + 6);
return { ...member, resource, resourcePools: { ...member.resourcePools, rage: resource } }; return {
...member,
resource: member.resourceType === "rage" ? rage : member.resource,
resourcePools: { ...member.resourcePools, rage },
};
} }
function partyFriendlyPosition(actorId: string): MobVector3 | null { function partyFriendlyPosition(actorId: string): MobVector3 | null {
@@ -1207,26 +1211,37 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
); );
members = members.map((member) => { members = members.map((member) => {
const profile = resourceProfileForClass(member.classId); const profile = resourceProfileForClass(member.classId);
const regeneration = (profile.type === "rage" || profile.type === "runic-power") const passiveRates: Partial<Record<ResourceType, number>> = {
? 0 mana: 4,
: profile.regenerationPerSecond + member.gearStats.mp5 / 5 + member.gearStats.spirit * 0.02; rage: 0,
const pool = advanceCombatResourcePool(normalizeActorResourcePool( energy: 10,
member.resourceType, focus: 6,
member.resource, "runic-power": 0,
member.maxResource, };
regeneration, const resourcePools: Partial<Record<ResourceType, number>> = { ...member.resourcePools };
member.lastResourceSpentAt[member.resourceType] ?? 0, for (const type of Object.keys(resourcePools) as ResourceType[]) {
), { const manaRegeneration = member.gearStats.mp5 / 5 + member.gearStats.spirit * 0.02;
inCombat: Boolean(targetId), const regeneration = type === member.resourceType
actorLevel: member.level, ? (profile.type === "rage" || profile.type === "runic-power" ? 0 : profile.regenerationPerSecond + manaRegeneration)
now, : (passiveRates[type] ?? 0) + (type === "mana" ? manaRegeneration : 0);
deltaSeconds, resourcePools[type] = advanceCombatResourcePool(normalizeActorResourcePool(
manaSpiritRegenerationPerSecond: member.gearStats.spirit * 0.02, 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 { return {
...member, ...member,
resource: pool.current, resource: resourcePools[member.resourceType] ?? member.resource,
resourcePools: { ...member.resourcePools, [member.resourceType]: pool.current }, resourcePools,
cooldowns: Object.fromEntries(Object.entries(member.cooldowns).filter(([, readyAt]) => readyAt > now)), cooldowns: Object.fromEntries(Object.entries(member.cooldowns).filter(([, readyAt]) => readyAt > now)),
schoolLockedUntil: Object.fromEntries(Object.entries(member.schoolLockedUntil).filter(([, readyAt]) => (readyAt ?? 0) > now)), schoolLockedUntil: Object.fromEntries(Object.entries(member.schoolLockedUntil).filter(([, readyAt]) => (readyAt ?? 0) > now)),
}; };
@@ -1291,7 +1306,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
const currentCombat = useCombatStore.getState(); const currentCombat = useCombatStore.getState();
if (member.activeCast) { if (member.activeCast) {
const activeCast = member.activeCast; const activeCast = member.activeCast;
const castingAbility = abilityById(member.activeCast.abilityId); const catalogAbility = abilityById(member.activeCast.abilityId);
const castingAbility = catalogAbility
? learnedAbilityForBuild(catalogAbility, member.level, member.talentRanks)
: null;
const castTargetId = member.activeCastTargetId; const castTargetId = member.activeCastTargetId;
if (!castingAbility || !castTargetId) { if (!castingAbility || !castTargetId) {
nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null }; nextMembers[index] = { ...member, activeCast: null, activeCastTargetId: null };
+25
View File
@@ -8,6 +8,7 @@ import {
} from "../app/characterCatalog"; } from "../app/characterCatalog";
import { import {
createDungeonParty, createDungeonParty,
createConfiguredPartyMember,
isHealerSpecialization, isHealerSpecialization,
isTankSpecialization, isTankSpecialization,
usePartyStore, usePartyStore,
@@ -74,6 +75,30 @@ describe("dungeon party composition", () => {
} }
}); });
it("creates the secondary Rage and Energy pools used by druid forms", () => {
const bear = createConfiguredPartyMember({
name: "Balance Bear",
classId: "druid",
specialization: "Feral (Bear)",
specializationId: "druid:feral-bear",
talentTreeId: "druid-feral-bear",
talentRanks: {},
role: "tank",
}, 80, 0, "feral-resources");
const cat = createConfiguredPartyMember({
name: "Balance Cat",
classId: "druid",
specialization: "Feral (Cat)",
specializationId: "druid:feral-cat",
talentTreeId: "druid-feral-cat",
talentRanks: {},
role: "damage",
}, 80, 1, "feral-resources");
expect(bear.resourcePools).toMatchObject({ mana: bear.maxResource, rage: 0, energy: 100 });
expect(cat.resourcePools).toMatchObject({ mana: cat.maxResource, rage: 0, energy: 100 });
});
it("is deterministic per character and scales every ally to the player level", () => { it("is deterministic per character and scales every ally to the player level", () => {
const first = createDungeonParty("character-42", 18); const first = createDungeonParty("character-42", 18);
const second = createDungeonParty("character-42", 18); const second = createDungeonParty("character-42", 18);
+51 -9
View File
@@ -31,7 +31,12 @@ import type { RuntimeTalentRanks as TalentRanks } from "./talentRuntimeCatalog";
import type { EnemyControlMechanic } from "./enemyAttacks"; import type { EnemyControlMechanic } from "./enemyAttacks";
import type { ContentCategoryId } from "../app/contentCategories"; import type { ContentCategoryId } from "../app/contentCategories";
import type { PartyRole, PartySize } from "./partyRoles"; import type { PartyRole, PartySize } from "./partyRoles";
import { resourceProfileForClass, type ResourceType } from "./abilityCatalog"; import {
abilitiesForCharacter,
learnedAbilityForBuild,
resourceProfileForClass,
type ResourceType,
} from "./abilityCatalog";
import { createDeathKnightRunes, type CombatActorCastState, type DeathKnightRuneState } from "./combatActors"; import { createDeathKnightRunes, type CombatActorCastState, type DeathKnightRuneState } from "./combatActors";
export { export {
@@ -222,6 +227,43 @@ function memberMaxResource(
: profile.maximum; : profile.maximum;
} }
function secondaryResourceMaximum(type: ResourceType): number {
if (type === "nature-power") return 10;
if (type === "psi") return 6;
return 100;
}
function initialSecondaryResource(type: ResourceType, maximum: number): number {
return type === "rage" || type === "runic-power" || type === "nature-power" || type === "psi"
? 0
: maximum;
}
/** Keeps every resource used by the member's learned kit available to companion AI. */
function withMemberAbilityResourcePools(member: PartyMember): PartyMember {
const types = new Set<ResourceType>([member.resourceType]);
for (const ability of abilitiesForCharacter(member.classId, member.secondaryClassId)) {
const learned = learnedAbilityForBuild(ability, member.level, member.talentRanks);
if (!learned) continue;
for (const cost of learned.costs?.length ? learned.costs : [learned.cost]) {
if (cost.resource !== "health") types.add(cost.resource);
}
}
const resourcePools: Partial<Record<ResourceType, number>> = {};
for (const type of types) {
if (type === member.resourceType) {
resourcePools[type] = member.resource;
continue;
}
const maximum = secondaryResourceMaximum(type);
resourcePools[type] = Math.max(0, Math.min(
maximum,
member.resourcePools[type] ?? initialSecondaryResource(type, maximum),
));
}
return { ...member, resourcePools: Object.freeze(resourcePools) };
}
function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMember { function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMember {
const maxHealth = memberMaxHealth( const maxHealth = memberMaxHealth(
member.level, member.level,
@@ -237,7 +279,7 @@ function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMe
const resource = Math.max(0, Math.round( const resource = Math.max(0, Math.round(
member.resource / Math.max(1, member.maxResource) * maxResource, member.resource / Math.max(1, member.maxResource) * maxResource,
)); ));
return { return withMemberAbilityResourcePools({
...member, ...member,
gearStats, gearStats,
health, health,
@@ -245,7 +287,7 @@ function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMe
resource, resource,
maxResource, maxResource,
resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }), resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }),
}; });
} }
function createMember( function createMember(
@@ -286,7 +328,7 @@ function createMember(
const resource = Math.round( const resource = Math.round(
maxResource * (resourceProfile.maximum > 0 ? resourceProfile.initial / resourceProfile.maximum : 0), maxResource * (resourceProfile.maximum > 0 ? resourceProfile.initial / resourceProfile.maximum : 0),
); );
return { return withMemberAbilityResourcePools({
id, id,
name: template.name, name: template.name,
raceId: race.id, raceId: race.id,
@@ -323,7 +365,7 @@ function createMember(
tauntReadyAt: 0, tauntReadyAt: 0,
controlledUntil: 0, controlledUntil: 0,
controlMechanic: null, controlMechanic: null,
}; });
} }
/** Creates one exact companion while retaining the live party stat/equipment path. */ /** Creates one exact companion while retaining the live party stat/equipment path. */
@@ -339,12 +381,12 @@ export function createConfiguredPartyMember(
specialization: configuration.specialization, specialization: configuration.specialization,
talentTreeId: configuration.talentTreeId ?? configuration.specializationId ?? "", talentTreeId: configuration.talentTreeId ?? configuration.specializationId ?? "",
}, configuration.role, level, index, seed, configuration.talentRanks); }, configuration.role, level, index, seed, configuration.talentRanks);
return Object.freeze({ return Object.freeze(withMemberAbilityResourcePools({
...member, ...member,
secondaryClassId: configuration.secondaryClassId ?? null, secondaryClassId: configuration.secondaryClassId ?? null,
specializationId: configuration.specializationId ?? member.specializationId, specializationId: configuration.specializationId ?? member.specializationId,
talentRanks: configuration.talentRanks ?? member.talentRanks, talentRanks: configuration.talentRanks ?? member.talentRanks,
}); }));
} }
/** /**
@@ -571,7 +613,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
const resource = Math.max(0, Math.round( const resource = Math.max(0, Math.round(
member.resource / Math.max(1, member.maxResource) * maxResource, member.resource / Math.max(1, member.maxResource) * maxResource,
)); ));
return { return withMemberAbilityResourcePools({
...member, ...member,
level: safeLevel, level: safeLevel,
health, health,
@@ -582,7 +624,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
talentRanks: isRomClassId(member.classId) talentRanks: isRomClassId(member.classId)
? Object.freeze({}) ? Object.freeze({})
: runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel), : runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel),
}; });
}), }),
}; };
}), }),
+10
View File
@@ -3342,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, .balance-results td small { display: block; }
.balance-results td strong { color: #dfe7d7; font-size: 8px; } .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 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 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 { 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-high { color: #efb8aa; background: rgba(155, 65, 50, .18); }
.balance-results-flag.is-low { color: #b8d6ee; background: rgba(60, 111, 147, .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 { display: grid; min-width: 0; gap: 10px; }
.balance-results-abilities > .balance-results-chart { width: 100%; } .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-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 { 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; } .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; }
@@ -3380,6 +3389,7 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
.balance-results__tabs button { white-space: nowrap; } .balance-results__tabs button { white-space: nowrap; }
.balance-results-table-panel > header { display: grid; } .balance-results-table-panel > header { display: grid; }
.balance-results-sort { width: 100%; min-width: 0; } .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-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; } .balance-results-bar { grid-template-columns: minmax(90px, 40%) minmax(55px, 1fr) minmax(40px, auto); gap: 5px; }
} }
+22 -2
View File
@@ -129,6 +129,8 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite
|| left[1].specialization.localeCompare(right[1].specialization) || left[1].specialization.localeCompare(right[1].specialization)
)); ));
}, [allAbilities]); }, [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 abilities = useMemo(() => allAbilities.filter((row) => {
const buildKey = `${row.source}/${row.buildId}`; const buildKey = `${row.source}/${row.buildId}`;
const matchesStatus = abilityStatus === "all" 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 usedAbilityCount = abilities.filter((row) => row.activeRuns > 0).length;
const filteredAbilityDamage = abilities.reduce((total, row) => total + row.effectiveDamage, 0); 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 ( return (
<section className="balance-results" aria-label="Balance Lab report analysis"> <section className="balance-results" aria-label="Balance Lab report analysis">
<header className="balance-results__header"> <header className="balance-results__header">
@@ -229,18 +244,23 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite
{tab === "classes" && <div className="balance-results-table-panel"> {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> <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>} </div>}
{tab === "abilities" && <div className="balance-results-abilities"> {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> : <> {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"> <div className="balance-results-summary balance-results-summary--abilities">
<article><span>Matching abilities</span><strong>{abilities.length.toLocaleString()}</strong></article> <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>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>Available but unused</span><strong>{unusedAbilityCount.toLocaleString()}</strong></article>
<article><span>Filtered effective damage</span><strong>{formatWhole(filteredAbilityDamage)}</strong></article> <article><span>Filtered effective damage</span><strong>{formatWhole(filteredAbilityDamage)}</strong></article>
</div> </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"> <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"> <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> <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>