Compare commits

...
4 Commits
Author SHA1 Message Date
phenom d0a02487db Release Healer Man 0.1.52 2026-09-18 09:34:59 -04:00
phenom dd583023ac Release Healer Man 0.1.51 2026-09-17 18:01:29 -04:00
phenom 79153ca40d Release Healer Man 0.1.50 2026-09-17 13:33:55 -04:00
phenom 717d12e5af Document Obsidian project memory 2026-09-17 11:36:54 -04:00
18 changed files with 584 additions and 74 deletions

No files matched your search

+21 -4
View File
@@ -11,10 +11,11 @@ These rules apply to every change under this project root.
## Workspace and reference boundaries ## Workspace and reference boundaries
- The active project root is `D:\Projects\HealerMan`. All implementation, - The active project root is the directory containing this `AGENTS.md`
generated content, scripts, tests, builds, documentation, and validation for (currently `F:\Projects\HealerMan`); do not assume a fixed drive letter. All
this project belong here. implementation, generated content, scripts, tests, builds, documentation, and
- `D:\Projects\wow-pvt-server-backups-2` is historical reference material only. validation for this project belong here.
- The sibling `../wow-pvt-server-backups-2` tree is historical reference material only.
Inspect it only when old-server behavior, AzerothCore data, creature IDs, Inspect it only when old-server behavior, AzerothCore data, creature IDs,
spells, encounters, maps, or assets are useful as source information. spells, encounters, maps, or assets are useful as source information.
- Never treat `wow-pvt-server-backups-2` as the active project, primary checkout, - Never treat `wow-pvt-server-backups-2` as the active project, primary checkout,
@@ -27,6 +28,22 @@ These rules apply to every change under this project root.
structure as appropriate, and validate it through HealerMan's tests/build and structure as appropriate, and validate it through HealerMan's tests/build and
playtest workflow. playtest workflow.
## Obsidian project memory
- The separate Healer-Man knowledge vault lives at the sibling path
`../ObsidianNotes/ObsidianVault1`. Start at
`00 Home/Healer-Man Hub.md` and follow
`40 Agent Workflows/Agent Memory Protocol.md` for broad or cross-system work.
- The code and tests in this repository remain authoritative. Check a note's
`last_verified` date and verify material claims against current code before
acting on them.
- Update the relevant vault note in the same task when a change alters feature
behavior, architectural ownership, entry points, cross-file relationships,
invariants, or required validation. Use repository-relative code paths and
keep transient investigation details in the vault's `50 Worklog/` area.
- The vault is its own Git repository. Review and commit its changes separately
from HealerMan changes; never stage one repository from the other.
## Dungeon report triage ## Dungeon report triage
- For any request to inspect or fix current dungeon reports, start with - For any request to inspect or fix current dungeon reports, start with
+53
View File
@@ -3545,3 +3545,56 @@ Vite's existing large-chunk advisory remains informational.
errors; all five bodies and their equipped weapon/staff silhouettes rendered errors; all five bodies and their equipped weapon/staff silhouettes rendered
intact. Evidence is in intact. Evidence is in
`playtest-artifacts/dungeon-health-scaling/stockades-level-three.png`. `playtest-artifacts/dungeon-health-scaling/stockades-level-three.png`.
## Balance Lab healing done and received analytics (2026-09-17)
- Raw Balance Lab member metrics now attribute effective healing to both the
caster and the party target. Party and suite reports expose healing done and
received per active second plus absolute totals; self-heals count on both
sides of the attribution.
- The GM overview adds separate **Healing done by class/spec** and **Healing
received by class/spec** graphs. The class/spec table can display and sort
both rates and totals. Older saved reports remain readable but show no target
healing until they are rerun.
- All six Python controller tests and the production build passed. A live
one-seed, 15-second Eck the Ferocious run recorded the Holy Paladin's 867
effective healing as 57.8 HPS done and the Blood Death Knight's same 867 as
57.8 HPS received. A matching automatic-suite run verified the new JSON and
CSV fields under `playtest-artifacts/balance-lab-healing-suite/`.
## Balance Lab deterministic class/spec coverage (2026-09-17)
- Automatic sweeps now offer **Random parties**, **All specs**, and **Specific
specs**. All-spec mode derives the minimum valid 1-tank/1-healer/3-damage
party set that covers every available build. Specific mode guarantees every
selected build appears and uses distinct compatible fillers for missing
roles; selected builds are not reused as fillers when alternatives exist.
- A real level-20 WoW all-spec sweep against Eck the Ferocious generated eight
distinct parties and placed all 31 catalog builds in `suite-report.json`
with zero missing. The combined catalog audit found 70 CoA builds requiring
16 coverage parties and 16 RoM builds requiring four; every source exposes
enough tank, healer, and damage builds for valid coverage parties.
- Eight Python controller tests, nine Balance Lab service tests, two server
integration tests, and the production build passed. Production Edge
automation verified the All Specs estimate, selected Paladin Holy through the
Specific Specs picker, completed the server job, rendered all seven overview
graphs, and passed desktop plus 480×900 overflow checks without browser
errors. Evidence is in `playtest-artifacts/balance-lab/02-server-job-config.png`
and `playtest-artifacts/balance-lab/03-server-job-complete.png`.
## Balance Lab class/spec ability damage drill-down (2026-09-17)
- Every class/spec row now exposes **View ability damage** when ability telemetry
exists. It opens the Abilities tab on that exact source/build, clears
conflicting search and role filters, and defaults to abilities with positive
effective damage sorted highest first.
- The drill-down graph and table retain ability name/ID, casts, casts per minute,
hits, effective/raw damage, damage share, DPS, and damage per hit. Healing,
overheal, absorbs, usage, and unused-ability filters remain available.
- The production build passed. A real five-second level-20 WoW run against Eck
the Ferocious opened a Death Knight Unholy row through the new action and
verified positive effective ability damage plus the expected cast, damage
share, and DPS columns. Desktop and 480×900 checks passed with no browser
errors. Evidence is in
`playtest-artifacts/balance-lab/04b-ability-breakdown.png` and
`playtest-artifacts/balance-lab/06-mobile-abilities.png`.
+42 -10
View File
@@ -25,10 +25,28 @@ The prompt selects:
1. one random party, five manual members, or an automatic multi-party sweep; 1. one random party, five manual members, or an automatic multi-party sweep;
2. a WoW, CoA, or RoM party catalog; automatic mode can select all three; 2. a WoW, CoA, or RoM party catalog; automatic mode can select all three;
3. a level from 1 through 80; 3. a level from 1 through 80;
4. how many unique 1-tank/1-healer/3-damage configurations to generate per source; 4. random parties, guaranteed coverage of all class/spec builds, or a
5. all bosses, WoW bosses, RoM bosses, or a searchable multi-boss selection; searchable selection of specific class/spec builds;
6. how many deterministic simulations to run for every party/boss pairing; and 5. how many unique 1-tank/1-healer/3-damage configurations to generate per
7. the maximum duration of each fight. source when random selection is used;
6. all bosses, WoW bosses, RoM bosses, or a searchable multi-boss selection;
7. how many deterministic simulations to run for every party/boss pairing; and
8. the maximum duration of each fight.
Automatic class/spec selection has three strategies:
- **Random parties** preserves the original seeded behavior and generates the
requested number of unique parties per source.
- **All specs** creates the minimum deterministic set of valid parties needed
to include every available class/spec build for the selected WoW, CoA, or
RoM source at least once.
- **Specific specs** treats each selected build as required coverage and fills
the remaining tank, healer, and damage slots with distinct compatible builds.
When alternatives exist, required builds are not reused as fillers.
Coverage parties always contain one tank, one healer, and three different
damage builds. Their composition is deterministic for the party seed, and no
build is duplicated inside one party.
The active member is driven by the shared benchmark policy while the other four The active member is driven by the shared benchmark policy while the other four
use the live companion AI. The healer is the default active member. Use use the live companion AI. The healer is the default active member. Use
@@ -38,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 A server-authorized GM sees **Balance Lab** directly underneath **GM Tools** on
the main menu. That screen exposes the automatic sweep controls: party source, the main menu. That screen exposes the automatic sweep controls: party source,
level, unique parties per source, simulations per party/boss, duration, random level, class/spec selection strategy, a searchable spec picker, simulations per
seed, boss category, and a searchable multi-boss picker. 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 **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 GM API. The server places it in a one-at-a-time queue, launches the same Python
@@ -80,11 +100,14 @@ The dashboard includes:
- party damage applied directly to each boss; - party damage applied directly to each boss;
- average successful kill time; - average successful kill time;
- boss DPS and damage taken for every class/specialization; - boss DPS and damage taken for every class/specialization;
- effective healing done and effective healing received per second for every
class/specialization;
- complete boss tables with pressure versus median, total incoming damage, - complete boss tables with pressure versus median, total incoming damage,
defeat/wipe rates, deaths, and health; defeat/wipe rates, deaths, and health;
- complete class/spec tables with total and boss DPS, HPS, DTPS, absolute - complete class/spec tables with total and boss DPS, healing done/received per
damage taken, damage/healing/absorb totals, deaths, and source/role-median second, DTPS, absolute damage taken, healing done/received and absorb totals,
tuning flags; and deaths, source/role-median tuning flags, and a direct ability-damage
drill-down for every build; and
- a complete Abilities tab with learned/available abilities (including zero-use - a complete Abilities tab with learned/available abilities (including zero-use
rows), exact runtime IDs, AI categories, active-run coverage, casts, hits, rows), exact runtime IDs, AI categories, active-run coverage, casts, hits,
casts per minute, effective/raw damage, DPS, damage share, damage per hit, casts per minute, effective/raw damage, DPS, damage share, damage per hit,
@@ -96,7 +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:
@@ -250,6 +276,12 @@ under `parties/<party-id>/` and adds these suite-level files:
direct boss damage. Suite class/spec `dtps` is damage taken per active second; direct boss damage. Suite class/spec `dtps` is damage taken per active second;
`totalDamageTaken` is the absolute healer/survivability burden across every `totalDamageTaken` is the absolute healer/survivability burden across every
included run. included run.
- Member `hps` is effective healing done per active second and excludes
overhealing. `hpsReceived` is effective healing received per active second,
attributed from each healing event's target (including self-heals).
`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, - Ability `activeRuns` counts simulations with at least one cast, hit, damage,
healing, overheal, or absorb event. `usageRate` divides that by every run for healing, overheal, or absorb event. `usageRate` divides that by every run for
that build; `damageShare` divides the ability's effective damage by the that build; `damageShare` divides the ability's effective damage by the
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.49", "version": "0.1.52",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.49", "version": "0.1.52",
"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.49", "version": "0.1.52",
"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.49", "versionName": "0.1.52",
"versionCode": 1049 "versionCode": 1052
} }
+16 -9
View File
@@ -90,6 +90,7 @@ interface MutableMemberMetrics {
healing: number; healing: number;
rawHealing: number; rawHealing: number;
overheal: number; overheal: number;
healingReceived: number;
absorbs: number; absorbs: number;
damageTaken: number; damageTaken: number;
rawDamageTaken: number; rawDamageTaken: number;
@@ -286,6 +287,8 @@ function serializedMember(
rawHealing: metrics.rawHealing, rawHealing: metrics.rawHealing,
overheal: metrics.overheal, overheal: metrics.overheal,
hps: metrics.healing / Math.max(0.1, activeSeconds), hps: metrics.healing / Math.max(0.1, activeSeconds),
healingReceived: metrics.healingReceived,
hpsReceived: metrics.healingReceived / Math.max(0.1, activeSeconds),
absorbs: metrics.absorbs, absorbs: metrics.absorbs,
damageTaken: metrics.damageTaken, damageTaken: metrics.damageTaken,
rawDamageTaken: metrics.rawDamageTaken, rawDamageTaken: metrics.rawDamageTaken,
@@ -356,6 +359,7 @@ async function runBoss(
healing: 0, healing: 0,
rawHealing: 0, rawHealing: 0,
overheal: 0, overheal: 0,
healingReceived: 0,
absorbs: 0, absorbs: 0,
damageTaken: 0, damageTaken: 0,
rawDamageTaken: 0, rawDamageTaken: 0,
@@ -395,15 +399,18 @@ async function runBoss(
ability.hits += 1; ability.hits += 1;
bossAbilities.set(key, ability); bossAbilities.set(key, ability);
} }
} else if (event.kind === "healing" && source) { } else if (event.kind === "healing") {
source.rawHealing += event.rawAmount; if (source) {
source.healing += event.effectiveAmount; source.rawHealing += event.rawAmount;
source.overheal += event.overheal; source.healing += event.effectiveAmount;
const ability = abilityMetrics(source, event.abilityId); source.overheal += event.overheal;
ability.rawHealing += event.rawAmount; const ability = abilityMetrics(source, event.abilityId);
ability.effectiveHealing += event.effectiveAmount; ability.rawHealing += event.rawAmount;
ability.overheal += event.overheal; ability.effectiveHealing += event.effectiveAmount;
ability.hits += 1; ability.overheal += event.overheal;
ability.hits += 1;
}
if (target) target.healingReceived += event.effectiveAmount;
} else if (event.kind === "absorb" && source) { } else if (event.kind === "absorb" && source) {
source.absorbs += event.effectiveAmount; source.absorbs += event.effectiveAmount;
abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount; abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount;
+182 -18
View File
@@ -195,6 +195,92 @@ def automatic_parties(
return configurations return configurations
def coverage_parties(
builds: Sequence[dict[str, Any]],
sources: Sequence[str],
selected_build_ids: Sequence[str] | None,
seed: int,
) -> list[dict[str, Any]]:
"""Build the minimum deterministic party set that covers every requested build."""
requested = set(selected_build_ids) if selected_build_ids is not None else None
offsets = {"wow": 100_000, "coa": 200_000, "rom": 300_000}
configurations: list[dict[str, Any]] = []
for source in sources:
source_builds = [build for build in builds if build["source"] == source]
targets = source_builds if requested is None else [
build for build in source_builds if build["id"] in requested
]
if not targets:
continue
available_by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
targets_by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
for build in source_builds:
available_by_role[build["role"]].append(build)
for build in targets:
targets_by_role[build["role"]].append(build)
if not available_by_role["tank"] or not available_by_role["healer"] or len(available_by_role["damage"]) < 3:
raise SystemExit(
f"The {source} catalog does not expose enough tank, healer, and damage builds for a 1/1/3 party."
)
party_count = max(
1,
len(targets_by_role["tank"]),
len(targets_by_role["healer"]),
math.ceil(len(targets_by_role["damage"]) / 3),
)
generator = random.Random(seed + offsets[source])
for rows in (*available_by_role.values(), *targets_by_role.values()):
rows.sort(key=lambda build: build["id"])
generator.shuffle(rows)
def filler(role: str, party_index: int, slot_index: int, used: set[str]) -> dict[str, Any]:
pool = available_by_role[role]
start = (party_index * (3 if role == "damage" else 1) + slot_index) % len(pool)
for avoid_requested in (True, False):
for offset in range(len(pool)):
candidate = pool[(start + offset) % len(pool)]
if candidate["id"] in used:
continue
if avoid_requested and requested is not None and candidate["id"] in requested:
continue
return candidate
raise SystemExit(f"Could not fill a unique {role} slot for a {source} coverage party.")
for party_index in range(party_count):
used: set[str] = set()
tank = targets_by_role["tank"][party_index] if party_index < len(targets_by_role["tank"]) else filler(
"tank", party_index, 0, used
)
used.add(tank["id"])
healer = targets_by_role["healer"][party_index] if party_index < len(targets_by_role["healer"]) else filler(
"healer", party_index, 0, used
)
used.add(healer["id"])
damage = list(targets_by_role["damage"][party_index * 3:(party_index + 1) * 3])
used.update(build["id"] for build in damage)
while len(damage) < 3:
build = filler("damage", party_index, len(damage), used)
damage.append(build)
used.add(build["id"])
party = [tank, healer, *damage]
configurations.append({
"id": f"{source}-party-{party_index + 1:03d}",
"source": source,
"party": party,
"activeSlot": 1,
})
if not configurations:
raise SystemExit("No class/spec builds matched the requested coverage selection.")
return configurations
def search_builds(query: str, builds: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
needle = normalized(query)
return [build for build in builds if needle in normalized(
f"{build['id']} {build['label']} {build['source']} {build['role']}"
)]
def search_bosses(query: str, bosses: Sequence[dict[str, Any]]) -> list[dict[str, Any]]: def search_bosses(query: str, bosses: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
needle = normalized(query) needle = normalized(query)
return [boss for boss in bosses if needle in normalized( return [boss for boss in bosses if needle in normalized(
@@ -273,6 +359,43 @@ def prompt_specific_bosses(bosses: Sequence[dict[str, Any]]) -> list[str]:
print(f"Selected bosses: {len(selected)}") print(f"Selected bosses: {len(selected)}")
def prompt_specific_builds(builds: Sequence[dict[str, Any]]) -> list[str]:
selected: dict[str, dict[str, Any]] = {}
print("\nClass/spec picker: search by class, specialization, role, source, or build ID.")
while True:
suffix = f" ({len(selected)} selected; type 'done' to finish)" if selected else ""
query = input(f"Class/spec search{suffix}: ").strip()
if query.casefold() == "done":
if selected:
return list(selected)
print("Select at least one class/spec first.")
continue
matches = search_builds(query, builds)
if not matches:
print("No class/spec builds matched that search.")
continue
if len(matches) > 50:
print(f"{len(matches)} builds matched. Use a more specific search (50 maximum per result page).")
continue
for index, build in enumerate(matches, 1):
marker = " [selected]" if build["id"] in selected else ""
print(f" {index:>2}. {console_text(build['label'])} [{build['source']}]{marker}")
raw = input("Add numbers separated by commas, or 'all': ").strip().casefold()
if raw == "all":
indexes = list(range(len(matches)))
else:
try:
indexes = [int(entry.strip()) - 1 for entry in raw.split(",") if entry.strip()]
except ValueError:
indexes = []
if not indexes or any(index < 0 or index >= len(matches) for index in indexes):
print("Choose valid result numbers.")
continue
for index in indexes:
selected[matches[index]["id"]] = matches[index]
print(f"Selected class/spec builds: {len(selected)}")
def interactive_arguments( def interactive_arguments(
args: argparse.Namespace, args: argparse.Namespace,
workspace: Path, workspace: Path,
@@ -288,7 +411,11 @@ def interactive_arguments(
if args.party_mode == "random": if args.party_mode == "random":
args.party_seed = prompt_integer("Random party seed", 1, -2_147_483_648, 2_147_483_647) args.party_seed = prompt_integer("Random party seed", 1, -2_147_483_648, 2_147_483_647)
elif args.party_mode == "automatic": elif args.party_mode == "automatic":
args.party_configurations = prompt_integer("Unique party configurations per source", 10, 1, 1_000) args.spec_selection = prompt_choice("Class/spec selection", ("random", "all", "specific"), "all")
if args.spec_selection == "random":
args.party_configurations = prompt_integer("Unique party configurations per source", 10, 1, 1_000)
elif args.spec_selection == "specific":
args.spec = prompt_specific_builds(catalog["builds"])
args.party_seed = prompt_integer("Automatic party seed", 1, -2_147_483_648, 2_147_483_647) args.party_seed = prompt_integer("Automatic party seed", 1, -2_147_483_648, 2_147_483_647)
else: else:
print("\nAvailable members:") print("\nAvailable members:")
@@ -320,7 +447,7 @@ def interactive_arguments(
def weighted_rate(rows: Iterable[dict[str, Any]], amount: str) -> float: def weighted_rate(rows: Iterable[dict[str, Any]], amount: str) -> float:
materialized = list(rows) materialized = list(rows)
seconds = sum(float(row["elapsedSeconds"]) for row in materialized) seconds = sum(float(row["elapsedSeconds"]) for row in materialized)
return sum(float(row[amount]) for row in materialized) / max(0.1, seconds) return sum(float(row.get(amount, 0)) for row in materialized) / max(0.1, seconds)
def mean(values: Iterable[float]) -> float: def mean(values: Iterable[float]) -> float:
@@ -402,9 +529,11 @@ def aggregate(raw: dict[str, Any]) -> dict[str, Any]:
"dps": weighted_rate(rows, "damage"), "dps": weighted_rate(rows, "damage"),
"bossDps": weighted_rate(rows, "bossDamage"), "bossDps": weighted_rate(rows, "bossDamage"),
"hps": weighted_rate(rows, "healing"), "hps": weighted_rate(rows, "healing"),
"hpsReceived": weighted_rate(rows, "healingReceived"),
"dtps": weighted_rate(rows, "damageTaken"), "dtps": weighted_rate(rows, "damageTaken"),
"totalDamage": sum(row["damage"] for row in rows), "totalDamage": sum(row["damage"] for row in rows),
"totalHealing": sum(row["healing"] for row in rows), "totalHealing": sum(row["healing"] for row in rows),
"totalHealingReceived": sum(row.get("healingReceived", 0) for row in rows),
"totalAbsorbs": sum(row["absorbs"] for row in rows), "totalAbsorbs": sum(row["absorbs"] for row in rows),
"deathEvents": sum(row["deathEvents"] for row in rows), "deathEvents": sum(row["deathEvents"] for row in rows),
"minimumHealthPercent": min(row["minimumHealthPercent"] for row in rows), "minimumHealthPercent": min(row["minimumHealthPercent"] for row in rows),
@@ -435,6 +564,7 @@ def aggregate(raw: dict[str, Any]) -> dict[str, Any]:
"dps": weighted_rate(rows, "damage"), "dps": weighted_rate(rows, "damage"),
"bossDps": weighted_rate(rows, "bossDamage"), "bossDps": weighted_rate(rows, "bossDamage"),
"hps": weighted_rate(rows, "healing"), "hps": weighted_rate(rows, "healing"),
"hpsReceived": weighted_rate(rows, "healingReceived"),
"dtps": weighted_rate(rows, "damageTaken"), "dtps": weighted_rate(rows, "damageTaken"),
"deathEvents": sum(row["deathEvents"] for row in rows), "deathEvents": sum(row["deathEvents"] for row in rows),
}) })
@@ -520,15 +650,16 @@ def markdown_report(report: dict[str, Any]) -> str:
"", "",
"## Every party member", "## Every party member",
"", "",
"| Slot | Member | Role | DPS | Boss DPS | HPS | DTPS | Role median | Flag | Death events |", "| Slot | Member | Role | DPS | Boss DPS | HPS done | HPS received | DTPS | Role median | Flag | Death events |",
"| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | ---: |", "| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: |",
] ]
for member in report["members"]: for member in report["members"]:
marker = " (active)" if member["activePlayer"] else "" marker = " (active)" if member["activePlayer"] else ""
lines.append( lines.append(
f"| {member['slot']} | {member['className']} — {member['specialization']}{marker} | " f"| {member['slot']} | {member['className']} — {member['specialization']}{marker} | "
f"{member['role']} | {format_number(member['dps'])} | {format_number(member['bossDps'])} | " f"{member['role']} | {format_number(member['dps'])} | {format_number(member['bossDps'])} | "
f"{format_number(member['hps'])} | {format_number(member['dtps'])} | " f"{format_number(member['hps'])} | {format_number(member['hpsReceived'])} | "
f"{format_number(member['dtps'])} | "
f"{format_ratio(member['damageVsRoleMedian'])} | {member['damageFlag']} | {member['deathEvents']} |" f"{format_ratio(member['damageVsRoleMedian'])} | {member['damageFlag']} | {member['deathEvents']} |"
) )
lines.extend([ lines.extend([
@@ -592,12 +723,14 @@ def html_report(report: dict[str, Any]) -> str:
"dps": format_number(row["dps"]), "dps": format_number(row["dps"]),
"bossDps": format_number(row["bossDps"]), "bossDps": format_number(row["bossDps"]),
"hps": format_number(row["hps"]), "hps": format_number(row["hps"]),
"hpsReceived": format_number(row["hpsReceived"]),
"dtps": format_number(row["dtps"]), "dtps": format_number(row["dtps"]),
"damageVsRoleMedian": format_number(row["damageVsRoleMedian"], 2), "damageVsRoleMedian": format_number(row["damageVsRoleMedian"], 2),
} for row in report["members"]] } for row in report["members"]]
member_table = html_table("members", "Every party member", members, ( member_table = html_table("members", "Every party member", members, (
("slot", "Slot"), ("member", "Member"), ("role", "Role"), ("dps", "DPS"), ("slot", "Slot"), ("member", "Member"), ("role", "Role"), ("dps", "DPS"),
("bossDps", "Boss DPS"), ("hps", "HPS"), ("dtps", "DTPS"), ("bossDps", "Boss DPS"), ("hps", "HPS done"), ("hpsReceived", "HPS received"),
("dtps", "DTPS"),
("damageVsRoleMedian", "Role median ×"), ("damageFlag", "Flag"), ("deathEvents", "Death events"), ("damageVsRoleMedian", "Role median ×"), ("damageFlag", "Flag"), ("deathEvents", "Death events"),
)) ))
boss_table = html_table("bosses", "Every boss — highest pressure first", bosses, ( boss_table = html_table("bosses", "Every boss — highest pressure first", bosses, (
@@ -639,12 +772,12 @@ def create_reports(raw_path: Path, output_dir: Path) -> dict[str, Any]:
)) ))
write_csv(output_dir / "party_members.csv", report["members"], ( write_csv(output_dir / "party_members.csv", report["members"], (
"slot", "activePlayer", "classId", "className", "specialization", "role", "dps", "bossDps", "slot", "activePlayer", "classId", "className", "specialization", "role", "dps", "bossDps",
"hps", "dtps", "damageVsRoleMedian", "damageFlag", "totalDamage", "totalHealing", "hps", "hpsReceived", "dtps", "damageVsRoleMedian", "damageFlag", "totalDamage", "totalHealing",
"totalAbsorbs", "deathEvents", "minimumHealthPercent", "totalHealingReceived", "totalAbsorbs", "deathEvents", "minimumHealthPercent",
)) ))
write_csv(output_dir / "member_by_boss.csv", report["memberByBoss"], ( write_csv(output_dir / "member_by_boss.csv", report["memberByBoss"], (
"bossKey", "bossName", "dungeonTitle", "slot", "className", "specialization", "role", "bossKey", "bossName", "dungeonTitle", "slot", "className", "specialization", "role",
"dps", "bossDps", "hps", "dtps", "deathEvents", "dps", "bossDps", "hps", "hpsReceived", "dtps", "deathEvents",
)) ))
run_rows = [{ run_rows = [{
"policyId": raw["policyId"], "policyId": raw["policyId"],
@@ -688,10 +821,12 @@ def aggregate_class_specs(
"dps": weighted_rate(observations, "damage"), "dps": weighted_rate(observations, "damage"),
"bossDps": weighted_rate(observations, "bossDamage"), "bossDps": weighted_rate(observations, "bossDamage"),
"hps": weighted_rate(observations, "healing"), "hps": weighted_rate(observations, "healing"),
"hpsReceived": weighted_rate(observations, "healingReceived"),
"dtps": weighted_rate(observations, "damageTaken"), "dtps": weighted_rate(observations, "damageTaken"),
"totalDamage": sum(row["damage"] for row in observations), "totalDamage": sum(row["damage"] for row in observations),
"totalDamageTaken": sum(row["damageTaken"] for row in observations), "totalDamageTaken": sum(row["damageTaken"] for row in observations),
"totalHealing": sum(row["healing"] for row in observations), "totalHealing": sum(row["healing"] for row in observations),
"totalHealingReceived": sum(row.get("healingReceived", 0) for row in observations),
"totalAbsorbs": sum(row["absorbs"] for row in observations), "totalAbsorbs": sum(row["absorbs"] for row in observations),
"deathEvents": sum(row["deathEvents"] for row in observations), "deathEvents": sum(row["deathEvents"] for row in observations),
}) })
@@ -805,14 +940,15 @@ def suite_markdown(report: dict[str, Any]) -> str:
"", "",
"## Every tested class/spec", "## Every tested class/spec",
"", "",
"| Source | Class/spec | Role | Parties | Runs | DPS | Boss DPS | HPS | DTPS | Total taken | Role median | Flag | Deaths |", "| Source | Class/spec | Role | Parties | Runs | DPS | Boss DPS | HPS done | HPS received | DTPS | Total taken | Role median | Flag | Deaths |",
"| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: |", "| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: |",
] ]
for row in report["classSpecs"]: for row in report["classSpecs"]:
lines.append( lines.append(
f"| {row['source']} | {row['className']} — {row['specialization']} | {row['role']} | " f"| {row['source']} | {row['className']} — {row['specialization']} | {row['role']} | "
f"{row['partyConfigurations']} | {row['runs']} | {format_number(row['dps'])} | " f"{row['partyConfigurations']} | {row['runs']} | {format_number(row['dps'])} | "
f"{format_number(row['bossDps'])} | {format_number(row['hps'])} | {format_number(row['dtps'])} | " f"{format_number(row['bossDps'])} | {format_number(row['hps'])} | "
f"{format_number(row['hpsReceived'])} | {format_number(row['dtps'])} | "
f"{format_number(row['totalDamageTaken'])} | " f"{format_number(row['totalDamageTaken'])} | "
f"{format_ratio(row['damageVsSourceRoleMedian'])} | {row['damageFlag']} | {row['deathEvents']} |" f"{format_ratio(row['damageVsSourceRoleMedian'])} | {row['damageFlag']} | {row['deathEvents']} |"
) )
@@ -873,6 +1009,7 @@ def suite_html(report: dict[str, Any]) -> str:
"dps": format_number(row["dps"]), "dps": format_number(row["dps"]),
"bossDps": format_number(row["bossDps"]), "bossDps": format_number(row["bossDps"]),
"hps": format_number(row["hps"]), "hps": format_number(row["hps"]),
"hpsReceived": format_number(row["hpsReceived"]),
"dtps": format_number(row["dtps"]), "dtps": format_number(row["dtps"]),
"totalDamageTaken": format_number(row["totalDamageTaken"]), "totalDamageTaken": format_number(row["totalDamageTaken"]),
"damageVsSourceRoleMedian": format_ratio(row["damageVsSourceRoleMedian"]), "damageVsSourceRoleMedian": format_ratio(row["damageVsSourceRoleMedian"]),
@@ -911,7 +1048,8 @@ def suite_html(report: dict[str, Any]) -> str:
class_table = html_table("class-specs", "Every tested class/spec", class_specs, ( class_table = html_table("class-specs", "Every tested class/spec", class_specs, (
("source", "Source"), ("classSpec", "Class/spec"), ("role", "Role"), ("source", "Source"), ("classSpec", "Class/spec"), ("role", "Role"),
("partyConfigurations", "Parties"), ("runs", "Runs"), ("dps", "DPS"), ("partyConfigurations", "Parties"), ("runs", "Runs"), ("dps", "DPS"),
("bossDps", "Boss DPS"), ("hps", "HPS"), ("dtps", "DTPS"), ("bossDps", "Boss DPS"), ("hps", "HPS done"), ("hpsReceived", "HPS received"),
("dtps", "DTPS"),
("totalDamageTaken", "Total taken"), ("damageVsSourceRoleMedian", "Role median"), ("totalDamageTaken", "Total taken"), ("damageVsSourceRoleMedian", "Role median"),
("damageFlag", "Flag"), ("deathEvents", "Deaths"), ("damageFlag", "Flag"), ("deathEvents", "Deaths"),
)) ))
@@ -1064,6 +1202,7 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
"dps": weighted_rate(observations, "damage"), "dps": weighted_rate(observations, "damage"),
"bossDps": weighted_rate(observations, "bossDamage"), "bossDps": weighted_rate(observations, "bossDamage"),
"hps": weighted_rate(observations, "healing"), "hps": weighted_rate(observations, "healing"),
"hpsReceived": weighted_rate(observations, "healingReceived"),
"dtps": weighted_rate(observations, "damageTaken"), "dtps": weighted_rate(observations, "damageTaken"),
"totalDamageTaken": sum(row["damageTaken"] for row in observations), "totalDamageTaken": sum(row["damageTaken"] for row in observations),
"deathEvents": sum(row["deathEvents"] for row in observations), "deathEvents": sum(row["deathEvents"] for row in observations),
@@ -1106,8 +1245,8 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
)) ))
write_csv(output_dir / "class_specs_across_parties.csv", class_specs, ( write_csv(output_dir / "class_specs_across_parties.csv", class_specs, (
"source", "buildId", "classId", "className", "specialization", "role", "partyConfigurations", "source", "buildId", "classId", "className", "specialization", "role", "partyConfigurations",
"runs", "dps", "bossDps", "hps", "dtps", "damageVsSourceRoleMedian", "damageFlag", "runs", "dps", "bossDps", "hps", "hpsReceived", "dtps", "damageVsSourceRoleMedian", "damageFlag",
"totalDamage", "totalDamageTaken", "totalHealing", "totalAbsorbs", "deathEvents", "totalDamage", "totalDamageTaken", "totalHealing", "totalHealingReceived", "totalAbsorbs", "deathEvents",
)) ))
write_csv(output_dir / "class_abilities_across_parties.csv", class_abilities, ( write_csv(output_dir / "class_abilities_across_parties.csv", class_abilities, (
"source", "buildId", "classId", "className", "specialization", "role", "abilityId", "source", "buildId", "classId", "className", "specialization", "role", "abilityId",
@@ -1129,7 +1268,7 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le
)) ))
write_csv(output_dir / "class_spec_by_boss.csv", class_spec_by_boss, ( write_csv(output_dir / "class_spec_by_boss.csv", class_spec_by_boss, (
"source", "buildId", "classId", "className", "specialization", "role", "bossKey", "bossName", "source", "buildId", "classId", "className", "specialization", "role", "bossKey", "bossName",
"dungeonTitle", "partyConfigurations", "runs", "dps", "bossDps", "hps", "dtps", "dungeonTitle", "partyConfigurations", "runs", "dps", "bossDps", "hps", "hpsReceived", "dtps",
"totalDamageTaken", "deathEvents", "totalDamageTaken", "deathEvents",
)) ))
write_csv(output_dir / "suite_runs.csv", compact_runs, ( write_csv(output_dir / "suite_runs.csv", compact_runs, (
@@ -1147,6 +1286,10 @@ def parser() -> argparse.ArgumentParser:
result.add_argument("--source", choices=("wow", "coa", "rom", "all"), help="party catalog; all requires automatic mode") result.add_argument("--source", choices=("wow", "coa", "rom", "all"), help="party catalog; all requires automatic mode")
result.add_argument("--level", type=int, default=60, help="party level (default: 60)") result.add_argument("--level", type=int, default=60, help="party level (default: 60)")
result.add_argument("--party-mode", choices=("random", "manual", "automatic"), default="random") result.add_argument("--party-mode", choices=("random", "manual", "automatic"), default="random")
result.add_argument("--spec-selection", choices=("random", "all", "specific"), default="random",
help="automatic party strategy (default: random)")
result.add_argument("--spec", action="append", default=[],
help="required build ID or class:spec selector for --spec-selection specific")
result.add_argument("--party-seed", type=int, default=1, help="random party seed") result.add_argument("--party-seed", type=int, default=1, help="random party seed")
result.add_argument("--party-configurations", type=int, default=10, help="automatic parties per selected source") result.add_argument("--party-configurations", type=int, default=10, help="automatic parties per selected source")
result.add_argument("--member", action="append", default=[], help="manual build ID or class:spec selector; repeat five times") result.add_argument("--member", action="append", default=[], help="manual build ID or class:spec selector; repeat five times")
@@ -1203,7 +1346,25 @@ def main(argv: Sequence[str] | None = None) -> int:
seeds = args.seeds or list(range(1, args.simulations_per_party + 1)) seeds = args.seeds or list(range(1, args.simulations_per_party + 1))
if args.party_mode == "automatic": if args.party_mode == "automatic":
sources = ("wow", "coa", "rom") if args.source == "all" else (args.source,) sources = ("wow", "coa", "rom") if args.source == "all" else (args.source,)
configurations = automatic_parties(builds, sources, args.party_configurations, args.party_seed) if args.spec_selection != "specific" and args.spec:
session.close()
raise SystemExit("--spec values require --spec-selection specific")
if args.spec_selection == "random":
configurations = automatic_parties(builds, sources, args.party_configurations, args.party_seed)
selected_build_ids: list[str] = []
else:
if args.spec_selection == "specific" and not args.spec:
session.close()
raise SystemExit("--spec-selection specific requires at least one --spec value")
selected_build_ids = list(dict.fromkeys(
resolve_build(selector, builds)["id"] for selector in args.spec
))
configurations = coverage_parties(
builds,
sources,
None if args.spec_selection == "all" else selected_build_ids,
args.party_seed,
)
if args.active_slot is not None: if args.active_slot is not None:
if not 1 <= args.active_slot <= 5: if not 1 <= args.active_slot <= 5:
session.close() session.close()
@@ -1246,10 +1407,13 @@ def main(argv: Sequence[str] | None = None) -> int:
"policyId": catalog["policyId"], "policyId": catalog["policyId"],
"rotationVersion": catalog["rotationVersion"], "rotationVersion": catalog["rotationVersion"],
"mode": "automatic", "mode": "automatic",
"specSelection": args.spec_selection,
"selectedBuildIds": selected_build_ids,
"source": args.source, "source": args.source,
"level": args.level, "level": args.level,
"partySeed": args.party_seed, "partySeed": args.party_seed,
"partyConfigurationsPerSource": args.party_configurations, "partyConfigurationsPerSource": args.party_configurations if args.spec_selection == "random" else None,
"partyConfigurations": len(configurations),
"simulationsPerParty": len(seeds), "simulationsPerParty": len(seeds),
"seeds": seeds, "seeds": seeds,
"durationSeconds": args.duration, "durationSeconds": args.duration,
+23 -4
View File
@@ -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\./); assert.match(await page.locator('.balance-lab-runtime').innerText(), /Python 3\./);
await page.getByLabel('Party source').selectOption('wow'); await page.getByLabel('Party source').selectOption('wow');
await page.getByLabel('Level').fill('20'); 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('Runs per party/boss').fill('1');
await page.getByLabel('Fight limit (seconds)').fill('5'); await page.getByLabel('Fight limit (seconds)').fill('5');
await page.getByLabel('Bosses').selectOption('specific'); await page.getByLabel('Bosses').selectOption('specific');
@@ -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.getByRole('button', { name: 'View graphs and filterable data' }).click();
await page.locator('.balance-results').waitFor(); await page.locator('.balance-results').waitFor();
assert.equal(await page.locator('.balance-results-chart').count(), 5); assert.equal(await page.locator('.balance-results-chart').count(), 7);
assert.match(await page.locator('.balance-results-chart').filter({ hasText: 'Boss damage to party' }).innerText(), /Eck the Ferocious/); assert.match(await page.locator('.balance-results-chart').filter({ hasText: 'Boss damage to party' }).innerText(), /Eck the Ferocious/);
await capture('04-run-analytics'); await capture('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(); 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');
+44 -3
View File
@@ -4,6 +4,7 @@ from scripts.balance_lab import (
aggregate_class_abilities, aggregate_class_abilities,
aggregate_class_specs, aggregate_class_specs,
automatic_parties, automatic_parties,
coverage_parties,
random_party, random_party,
search_bosses, search_bosses,
tuning_label, tuning_label,
@@ -57,6 +58,44 @@ class BalanceLabControllerTests(unittest.TestCase):
"tank", "healer", "damage", "damage", "damage", "tank", "healer", "damage", "damage", "damage",
]) ])
def test_coverage_parties_include_every_build_with_minimum_unique_parties(self) -> None:
builds = [
{"id": "tank-a", "source": "wow", "role": "tank"},
{"id": "tank-b", "source": "wow", "role": "tank"},
{"id": "healer-a", "source": "wow", "role": "healer"},
{"id": "healer-b", "source": "wow", "role": "healer"},
*({"id": f"damage-{index}", "source": "wow", "role": "damage"} for index in range(7)),
]
first = coverage_parties(builds, ("wow",), None, 19)
second = coverage_parties(builds, ("wow",), None, 19)
self.assertEqual(first, second)
self.assertEqual(len(first), 3)
covered = {member["id"] for configuration in first for member in configuration["party"]}
self.assertEqual(covered, {build["id"] for build in builds})
signatures = {tuple(member["id"] for member in configuration["party"]) for configuration in first}
self.assertEqual(len(signatures), 3)
for configuration in first:
self.assertEqual([member["role"] for member in configuration["party"]], [
"tank", "healer", "damage", "damage", "damage",
])
self.assertEqual(len({member["id"] for member in configuration["party"]}), 5)
def test_specific_coverage_uses_fillers_but_guarantees_selected_builds(self) -> None:
builds = [
{"id": "tank-a", "source": "wow", "role": "tank"},
{"id": "tank-b", "source": "wow", "role": "tank"},
{"id": "healer-a", "source": "wow", "role": "healer"},
{"id": "healer-b", "source": "wow", "role": "healer"},
*({"id": f"damage-{index}", "source": "wow", "role": "damage"} for index in range(5)),
]
selected = ["tank-a", "tank-b", "damage-4"]
configurations = coverage_parties(builds, ("wow",), selected, 7)
self.assertEqual(len(configurations), 2)
observed = [member["id"] for configuration in configurations for member in configuration["party"]]
covered = set(observed)
self.assertTrue(set(selected).issubset(covered))
self.assertTrue(all(observed.count(build_id) == 1 for build_id in selected))
def test_boss_search_matches_name_dungeon_key_and_source(self) -> None: def test_boss_search_matches_name_dungeon_key_and_source(self) -> None:
bosses = [ bosses = [
{"key": "gundrak/eck", "name": "Eck the Ferocious", "dungeonTitle": "Gundrak", "category": "wow"}, {"key": "gundrak/eck", "name": "Eck the Ferocious", "dungeonTitle": "Gundrak", "category": "wow"},
@@ -66,23 +105,25 @@ class BalanceLabControllerTests(unittest.TestCase):
self.assertEqual([boss["key"] for boss in search_bosses("forsaken", bosses)], ["abbey/duke"]) self.assertEqual([boss["key"] for boss in search_bosses("forsaken", bosses)], ["abbey/duke"])
self.assertEqual([boss["key"] for boss in search_bosses("rom", bosses)], ["abbey/duke"]) self.assertEqual([boss["key"] for boss in search_bosses("rom", bosses)], ["abbey/duke"])
def test_class_spec_aggregate_keeps_rate_and_absolute_damage_taken(self) -> None: def test_class_spec_aggregate_keeps_damage_and_healing_received_metrics(self) -> None:
key = ("wow", "priest-holy", "priest", "Priest", "Holy", "healer") key = ("wow", "priest-holy", "priest", "Priest", "Holy", "healer")
rows = aggregate_class_specs({key: [ rows = aggregate_class_specs({key: [
{ {
"partyConfigurationId": "wow-party-001", "elapsedSeconds": 10, "partyConfigurationId": "wow-party-001", "elapsedSeconds": 10,
"damage": 200, "bossDamage": 180, "healing": 500, "damage": 200, "bossDamage": 180, "healing": 500, "healingReceived": 300,
"damageTaken": 120, "absorbs": 25, "deathEvents": 0, "damageTaken": 120, "absorbs": 25, "deathEvents": 0,
}, },
{ {
"partyConfigurationId": "wow-party-002", "elapsedSeconds": 20, "partyConfigurationId": "wow-party-002", "elapsedSeconds": 20,
"damage": 400, "bossDamage": 360, "healing": 900, "damage": 400, "bossDamage": 360, "healing": 900, "healingReceived": 600,
"damageTaken": 330, "absorbs": 50, "deathEvents": 1, "damageTaken": 330, "absorbs": 50, "deathEvents": 1,
}, },
]}) ]})
self.assertEqual(len(rows), 1) self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["dtps"], 15) self.assertEqual(rows[0]["dtps"], 15)
self.assertEqual(rows[0]["totalDamageTaken"], 450) self.assertEqual(rows[0]["totalDamageTaken"], 450)
self.assertEqual(rows[0]["hpsReceived"], 30)
self.assertEqual(rows[0]["totalHealingReceived"], 900)
def test_ability_aggregate_keeps_used_and_available_but_unused_rows(self) -> None: def test_ability_aggregate_keeps_used_and_available_but_unused_rows(self) -> None:
class_key = ("wow", "mage-fire", "mage", "Mage", "Fire", "damage") class_key = ("wow", "mage-fire", "mage", "Mage", "Fire", "damage")
+41 -5
View File
@@ -15,6 +15,7 @@ import { GameDatabaseError } from "./database.mjs";
const SOURCES = new Set(["wow", "coa", "rom", "all"]); const SOURCES = new Set(["wow", "coa", "rom", "all"]);
const BOSS_CATEGORIES = new Set(["all", "wow", "rom"]); const BOSS_CATEGORIES = new Set(["all", "wow", "rom"]);
const SPEC_SELECTIONS = new Set(["random", "all", "specific"]);
const JOB_STATUSES = new Set(["queued", "running", "complete", "failed"]); const JOB_STATUSES = new Set(["queued", "running", "complete", "failed"]);
const JOB_METADATA_NAME = "job.json"; const JOB_METADATA_NAME = "job.json";
const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -43,12 +44,16 @@ function integer(value, name, minimum, maximum) {
export function normalizeBalanceLabJobSpec(input) { export function normalizeBalanceLabJobSpec(input) {
const source = String(input?.source ?? "all").toLowerCase(); const source = String(input?.source ?? "all").toLowerCase();
const bossCategory = String(input?.bossCategory ?? "all").toLowerCase(); const bossCategory = String(input?.bossCategory ?? "all").toLowerCase();
const specSelection = String(input?.specSelection ?? "random").toLowerCase();
if (!SOURCES.has(source)) { if (!SOURCES.has(source)) {
throw new GameDatabaseError("Party source must be wow, coa, rom, or all.", 400, "invalid_balance_lab_job"); throw new GameDatabaseError("Party source must be wow, coa, rom, or all.", 400, "invalid_balance_lab_job");
} }
if (!BOSS_CATEGORIES.has(bossCategory)) { if (!BOSS_CATEGORIES.has(bossCategory)) {
throw new GameDatabaseError("Boss category must be all, wow, or rom.", 400, "invalid_balance_lab_job"); throw new GameDatabaseError("Boss category must be all, wow, or rom.", 400, "invalid_balance_lab_job");
} }
if (!SPEC_SELECTIONS.has(specSelection)) {
throw new GameDatabaseError("Class/spec selection must be random, all, or specific.", 400, "invalid_balance_lab_job");
}
const rawBossKeys = Array.isArray(input?.bossKeys) ? input.bossKeys : []; const rawBossKeys = Array.isArray(input?.bossKeys) ? input.bossKeys : [];
if (rawBossKeys.length > 500) { if (rawBossKeys.length > 500) {
throw new GameDatabaseError("Select no more than 500 bosses.", 400, "invalid_balance_lab_job"); throw new GameDatabaseError("Select no more than 500 bosses.", 400, "invalid_balance_lab_job");
@@ -57,8 +62,21 @@ export function normalizeBalanceLabJobSpec(input) {
if (bossKeys.some((key) => key.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(key))) { if (bossKeys.some((key) => key.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(key))) {
throw new GameDatabaseError("A selected boss key is invalid.", 400, "invalid_balance_lab_job"); throw new GameDatabaseError("A selected boss key is invalid.", 400, "invalid_balance_lab_job");
} }
const rawBuildIds = Array.isArray(input?.selectedBuildIds) ? input.selectedBuildIds : [];
if (rawBuildIds.length > 200) {
throw new GameDatabaseError("Select no more than 200 class/spec builds.", 400, "invalid_balance_lab_job");
}
const selectedBuildIds = [...new Set(rawBuildIds.map((value) => String(value).trim()).filter(Boolean))];
if (selectedBuildIds.some((id) => id.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(id))) {
throw new GameDatabaseError("A selected class/spec build ID is invalid.", 400, "invalid_balance_lab_job");
}
if (specSelection === "specific" && selectedBuildIds.length === 0) {
throw new GameDatabaseError("Select at least one class/spec build.", 400, "invalid_balance_lab_job");
}
const spec = Object.freeze({ const spec = Object.freeze({
source, source,
specSelection,
selectedBuildIds: Object.freeze(specSelection === "specific" ? selectedBuildIds : []),
level: integer(input?.level ?? 60, "Level", 1, 80), level: integer(input?.level ?? 60, "Level", 1, 80),
partyConfigurations: integer(input?.partyConfigurations ?? 10, "Party configurations", 1, 100), partyConfigurations: integer(input?.partyConfigurations ?? 10, "Party configurations", 1, 100),
simulationsPerParty: integer(input?.simulationsPerParty ?? 3, "Simulations per party", 1, 100), simulationsPerParty: integer(input?.simulationsPerParty ?? 3, "Simulations per party", 1, 100),
@@ -69,7 +87,12 @@ export function normalizeBalanceLabJobSpec(input) {
}); });
const sourceCount = spec.source === "all" ? 3 : 1; const sourceCount = spec.source === "all" ? 3 : 1;
const bossEstimate = Math.max(1, spec.bossKeys.length || (spec.bossCategory === "all" ? 500 : 300)); const bossEstimate = Math.max(1, spec.bossKeys.length || (spec.bossCategory === "all" ? 500 : 300));
const estimatedRuns = sourceCount * spec.partyConfigurations * spec.simulationsPerParty * bossEstimate; const partyEstimate = spec.specSelection === "random"
? sourceCount * spec.partyConfigurations
: spec.specSelection === "specific"
? Math.max(spec.partyConfigurations, spec.selectedBuildIds.length)
: Math.max(spec.partyConfigurations, spec.source === "all" ? 100 : 50);
const estimatedRuns = partyEstimate * spec.simulationsPerParty * bossEstimate;
if (estimatedRuns > 250_000) { if (estimatedRuns > 250_000) {
throw new GameDatabaseError( throw new GameDatabaseError(
"That job is too large for one server request. Reduce parties, simulations, or selected bosses.", "That job is too large for one server request. Reduce parties, simulations, or selected bosses.",
@@ -90,6 +113,7 @@ export function balanceLabCommand(spec, outputDir, options = {}) {
"--source", spec.source, "--source", spec.source,
"--level", String(spec.level), "--level", String(spec.level),
"--party-mode", "automatic", "--party-mode", "automatic",
"--spec-selection", spec.specSelection,
"--party-seed", String(spec.partySeed), "--party-seed", String(spec.partySeed),
"--party-configurations", String(spec.partyConfigurations), "--party-configurations", String(spec.partyConfigurations),
"--simulations-per-party", String(spec.simulationsPerParty), "--simulations-per-party", String(spec.simulationsPerParty),
@@ -97,6 +121,7 @@ export function balanceLabCommand(spec, outputDir, options = {}) {
"--boss-category", spec.bossCategory, "--boss-category", spec.bossCategory,
"--output-dir", outputDir, "--output-dir", outputDir,
]; ];
for (const buildId of spec.selectedBuildIds) args.push("--spec", buildId);
for (const bossKey of spec.bossKeys) args.push("--boss", bossKey); for (const bossKey of spec.bossKeys) args.push("--boss", bossKey);
return { executable, args, cwd: projectRoot }; return { executable, args, cwd: projectRoot };
} }
@@ -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) { function lineCollector(onLine) {
let pending = ""; let pending = "";
return { return {
@@ -236,8 +267,12 @@ export function createBalanceLabJobService(options = {}) {
requestedBy: "unknown (legacy run)", requestedBy: "unknown (legacy run)",
spec: { spec: {
source: legacyConfig.source, source: legacyConfig.source,
specSelection: legacyConfig.specSelection ?? "random",
selectedBuildIds: legacyConfig.selectedBuildIds ?? [],
level: legacyConfig.level, level: legacyConfig.level,
partyConfigurations: legacyConfig.partyConfigurationsPerSource, partyConfigurations: legacyConfig.specSelection && legacyConfig.specSelection !== "random"
? legacyConfig.partyConfigurations
: legacyConfig.partyConfigurationsPerSource,
simulationsPerParty: legacyConfig.simulationsPerParty, simulationsPerParty: legacyConfig.simulationsPerParty,
durationSeconds: legacyConfig.durationSeconds, durationSeconds: legacyConfig.durationSeconds,
partySeed: legacyConfig.partySeed, partySeed: legacyConfig.partySeed,
@@ -252,7 +287,8 @@ export function createBalanceLabJobService(options = {}) {
const hasReport = existsSync(reportPath); const hasReport = existsSync(reportPath);
const storedStatus = JOB_STATUSES.has(stored.status) ? stored.status : "failed"; const storedStatus = JOB_STATUSES.has(stored.status) ? stored.status : "failed";
const interrupted = !hasReport && (storedStatus === "queued" || storedStatus === "running"); const interrupted = !hasReport && (storedStatus === "queued" || storedStatus === "running");
const totalParties = spec.partyConfigurations * (spec.source === "all" ? 3 : 1); const restoredSummary = hasReport ? reportSummary(outputDir) : null;
const totalParties = restoredSummary?.partyConfigurations || expectedPartyConfigurations(spec);
const createdAt = typeof stored.createdAt === "string" const createdAt = typeof stored.createdAt === "string"
? stored.createdAt ? stored.createdAt
: statSync(outputDir).birthtime.toISOString(); : statSync(outputDir).birthtime.toISOString();
@@ -282,7 +318,7 @@ export function createBalanceLabJobService(options = {}) {
logTail: Array.isArray(stored.logTail) logTail: Array.isArray(stored.logTail)
? stored.logTail.filter((line) => typeof line === "string").slice(-80) ? stored.logTail.filter((line) => typeof line === "string").slice(-80)
: [], : [],
summary: hasReport ? reportSummary(outputDir) : null, summary: restoredSummary,
outputDir, outputDir,
}; };
jobs.set(job.id, job); jobs.set(job.id, job);
@@ -401,7 +437,7 @@ export function createBalanceLabJobService(options = {}) {
const id = randomUUID(); const id = randomUUID();
const outputDir = path.join(jobsDir, id); const outputDir = path.join(jobsDir, id);
mkdirSync(outputDir, { recursive: true }); mkdirSync(outputDir, { recursive: true });
const totalParties = spec.partyConfigurations * (spec.source === "all" ? 3 : 1); const totalParties = expectedPartyConfigurations(spec);
const job = { const job = {
id, id,
status: "queued", status: "queued",
+11
View File
@@ -15,6 +15,8 @@ import {
test("normalizes and bounds server Balance Lab requests", () => { test("normalizes and bounds server Balance Lab requests", () => {
const spec = normalizeBalanceLabJobSpec({ const spec = normalizeBalanceLabJobSpec({
source: "ALL", source: "ALL",
specSelection: "specific",
selectedBuildIds: ["balance-wow-priest-healer-holy-level-40", "balance-wow-priest-healer-holy-level-40"],
level: 40, level: 40,
partyConfigurations: 2, partyConfigurations: 2,
simulationsPerParty: 3, simulationsPerParty: 3,
@@ -24,6 +26,8 @@ test("normalizes and bounds server Balance Lab requests", () => {
bossKeys: ["gundrak/entry-29932", "gundrak/entry-29932"], bossKeys: ["gundrak/entry-29932", "gundrak/entry-29932"],
}); });
assert.equal(spec.source, "all"); assert.equal(spec.source, "all");
assert.equal(spec.specSelection, "specific");
assert.deepEqual(spec.selectedBuildIds, ["balance-wow-priest-healer-holy-level-40"]);
assert.deepEqual(spec.bossKeys, ["gundrak/entry-29932"]); assert.deepEqual(spec.bossKeys, ["gundrak/entry-29932"]);
assert.throws( assert.throws(
() => normalizeBalanceLabJobSpec({ source: "wow", level: 81 }), () => normalizeBalanceLabJobSpec({ source: "wow", level: 81 }),
@@ -33,12 +37,17 @@ test("normalizes and bounds server Balance Lab requests", () => {
() => normalizeBalanceLabJobSpec({ source: "wow", bossKeys: ["boss; shutdown"] }), () => normalizeBalanceLabJobSpec({ source: "wow", bossKeys: ["boss; shutdown"] }),
/boss key is invalid/, /boss key is invalid/,
); );
assert.throws(
() => normalizeBalanceLabJobSpec({ source: "wow", specSelection: "specific", selectedBuildIds: [] }),
/Select at least one class\/spec build/,
);
}); });
test("builds an argument-array command without a shell", () => { test("builds an argument-array command without a shell", () => {
const spec = normalizeBalanceLabJobSpec({ const spec = normalizeBalanceLabJobSpec({
source: "rom", level: 60, partyConfigurations: 4, simulationsPerParty: 2, source: "rom", level: 60, partyConfigurations: 4, simulationsPerParty: 2,
durationSeconds: 30, partySeed: 9, bossCategory: "rom", bossKeys: ["abbey/boss-1"], durationSeconds: 30, partySeed: 9, bossCategory: "rom", bossKeys: ["abbey/boss-1"],
specSelection: "specific", selectedBuildIds: ["balance-rom-mage-damage-primary-level-60"],
}); });
const command = balanceLabCommand(spec, "C:/reports/job", { const command = balanceLabCommand(spec, "C:/reports/job", {
projectRoot: "C:/app", projectRoot: "C:/app",
@@ -47,6 +56,8 @@ test("builds an argument-array command without a shell", () => {
assert.equal(command.executable, "python-custom"); assert.equal(command.executable, "python-custom");
assert.equal(command.args.includes("--party-mode"), true); assert.equal(command.args.includes("--party-mode"), true);
assert.equal(command.args.includes("automatic"), true); assert.equal(command.args.includes("automatic"), true);
assert.equal(command.args.includes("specific"), true);
assert.equal(command.args.includes("balance-rom-mage-damage-primary-level-60"), true);
assert.equal(command.args.includes("abbey/boss-1"), true); assert.equal(command.args.includes("abbey/boss-1"), true);
assert.equal(command.cwd, path.resolve("C:/app")); assert.equal(command.cwd, path.resolve("C:/app"));
}); });
+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);
})); }));
+5
View File
@@ -2,6 +2,7 @@ import { onlineApiOrigin, requestOnlineJson } from "./onlineAccountClient";
export type BalanceLabPartySource = "wow" | "coa" | "rom" | "all"; export type BalanceLabPartySource = "wow" | "coa" | "rom" | "all";
export type BalanceLabBossCategory = "all" | "wow" | "rom"; export type BalanceLabBossCategory = "all" | "wow" | "rom";
export type BalanceLabSpecSelection = "random" | "all" | "specific";
export type BalanceLabJobStatus = "queued" | "running" | "complete" | "failed"; export type BalanceLabJobStatus = "queued" | "running" | "complete" | "failed";
export interface BalanceLabRuntimeStatus { export interface BalanceLabRuntimeStatus {
@@ -13,6 +14,8 @@ export interface BalanceLabRuntimeStatus {
export interface BalanceLabJobSpec { export interface BalanceLabJobSpec {
readonly source: BalanceLabPartySource; readonly source: BalanceLabPartySource;
readonly specSelection?: BalanceLabSpecSelection;
readonly selectedBuildIds?: readonly string[];
readonly level: number; readonly level: number;
readonly partyConfigurations: number; readonly partyConfigurations: number;
readonly simulationsPerParty: number; readonly simulationsPerParty: number;
@@ -84,10 +87,12 @@ export interface BalanceLabClassSpecResult {
readonly dps: number; readonly dps: number;
readonly bossDps: number; readonly bossDps: number;
readonly hps: number; readonly hps: number;
readonly hpsReceived?: number;
readonly dtps: number; readonly dtps: number;
readonly totalDamage: number; readonly totalDamage: number;
readonly totalDamageTaken: number; readonly totalDamageTaken: number;
readonly totalHealing: number; readonly totalHealing: number;
readonly totalHealingReceived?: number;
readonly totalAbsorbs: number; readonly totalAbsorbs: number;
readonly deathEvents: number; readonly deathEvents: number;
readonly damageVsSourceRoleMedian: number | null; readonly damageVsSourceRoleMedian: number | null;
+17 -1
View File
@@ -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 { 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 { 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-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 > 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 { 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); } .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 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 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-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 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-estimate b { color: var(--front-cream); font-size: 11px; }
.balance-lab-run { width: 100%; margin-top: 10px; } .balance-lab-run { width: 100%; margin-top: 10px; }
@@ -3317,6 +3321,8 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
.balance-results-chart.is-time .balance-results-bar__track i { background: linear-gradient(90deg, #766130, #d1aa51); } .balance-results-chart.is-time .balance-results-bar__track i { background: linear-gradient(90deg, #766130, #d1aa51); }
.balance-results-chart.is-class .balance-results-bar__track i { background: linear-gradient(90deg, #395e76, #6faac4); } .balance-results-chart.is-class .balance-results-bar__track i { background: linear-gradient(90deg, #395e76, #6faac4); }
.balance-results-chart.is-taken .balance-results-bar__track i { background: linear-gradient(90deg, #6b3d70, #b376b5); } .balance-results-chart.is-taken .balance-results-bar__track i { background: linear-gradient(90deg, #6b3d70, #b376b5); }
.balance-results-chart.is-healing .balance-results-bar__track i { background: linear-gradient(90deg, #327058, #73c995); }
.balance-results-chart.is-received .balance-results-bar__track i { background: linear-gradient(90deg, #426a7a, #82c5c7); }
.balance-results-bar > strong { color: #d8d2b5; font-size: 7px; text-align: right; white-space: nowrap; } .balance-results-bar > strong { color: #d8d2b5; font-size: 7px; text-align: right; white-space: nowrap; }
.balance-results-chart > footer { padding: 7px 11px; border-top: 1px solid rgba(190, 213, 172, .08); color: var(--front-dim); font-size: 6px; } .balance-results-chart > footer { padding: 7px 11px; border-top: 1px solid rgba(190, 213, 172, .08); color: var(--front-dim); font-size: 6px; }
.balance-results-empty { padding: 18px 8px; color: var(--front-dim); font-size: 8px; text-align: center; } .balance-results-empty { padding: 18px 8px; color: var(--front-dim); font-size: 8px; text-align: center; }
@@ -3336,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; }
@@ -3374,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; }
} }
+26 -4
View File
@@ -58,7 +58,7 @@ function BarChart({
readonly description: string; readonly description: string;
readonly rows: readonly ChartDatum[]; readonly rows: readonly ChartDatum[];
readonly format?: (value: number) => string; readonly format?: (value: number) => string;
readonly tone?: "damage" | "party" | "time" | "class" | "taken"; readonly tone?: "damage" | "party" | "time" | "class" | "taken" | "healing" | "received";
}) { }) {
const sorted = [...rows].filter((row) => Number.isFinite(row.value)).sort((left, right) => right.value - left.value); const sorted = [...rows].filter((row) => Number.isFinite(row.value)).sort((left, right) => right.value - left.value);
const visible = sorted.slice(0, 15); const visible = sorted.slice(0, 15);
@@ -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">
@@ -217,6 +232,8 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite
<BarChart title="Kill times" description="Average seconds to defeat; bosses never defeated are listed in the boss table as —." rows={bossChart("averageTimeToDefeat")} format={formatSeconds} tone="time" /> <BarChart title="Kill times" description="Average seconds to defeat; bosses never defeated are listed in the boss table as —." rows={bossChart("averageTimeToDefeat")} format={formatSeconds} tone="time" />
<BarChart title="Class/spec boss damage" description="Every tested build ranked by DPS applied directly to bosses." rows={classChart("bossDps")} tone="class" /> <BarChart title="Class/spec boss damage" description="Every tested build ranked by DPS applied directly to bosses." rows={classChart("bossDps")} tone="class" />
<BarChart title="Damage taken by class/spec" description="Incoming damage per second by build; useful for survivability and healer-load checks." rows={classChart("dtps")} tone="taken" /> <BarChart title="Damage taken by class/spec" description="Incoming damage per second by build; useful for survivability and healer-load checks." rows={classChart("dtps")} tone="taken" />
<BarChart title="Healing done by class/spec" description="Effective healing per second, excluding overhealing. Compare healer output with boss pressure and healer DPS." rows={classChart("hps")} tone="healing" />
<BarChart title="Healing received by class/spec" description="Effective healing received per second. New runs attribute every heal to its party target, including self-heals." rows={classChart("hpsReceived")} tone="received" />
</div> </div>
</div>} </div>}
@@ -226,19 +243,24 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite
</div>} </div>}
{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">HPS</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</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>HPS</th><th>Damage taken / sec</th><th>Total damage taken</th><th>Role median</th><th>Total damage dealt</th><th>Total healing</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.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.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>
+93 -9
View File
@@ -12,9 +12,15 @@ import {
type BalanceLabJob, type BalanceLabJob,
type BalanceLabPartySource, type BalanceLabPartySource,
type BalanceLabRuntimeStatus, type BalanceLabRuntimeStatus,
type BalanceLabSpecSelection,
type BalanceLabSuiteReport, type BalanceLabSuiteReport,
} from "../app/balanceLabClient"; } from "../app/balanceLabClient";
import { classById } from "../app/characterCatalog";
import { BOSS_TESTING_CATALOG } from "../game/bossTesting"; import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
import {
selectableBenchmarkBuildCatalog,
type SelectableBenchmarkBuildCatalogEntry,
} from "../game/benchmark/benchmarkBuilds";
import { BalanceLabResults } from "./BalanceLabResults"; import { BalanceLabResults } from "./BalanceLabResults";
import { BrandMark, FrontSurface } from "./FrontSurface"; import { BrandMark, FrontSurface } from "./FrontSurface";
@@ -60,10 +66,24 @@ function describeJob(job: BalanceLabJob): string {
return `Party ${party}/${job.progress.partiesTotal}${simulation}`; 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 }) { export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) {
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const token = session?.accessToken ?? ""; const token = session?.accessToken ?? "";
const [source, setSource] = useState<BalanceLabPartySource>("all"); 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 [level, setLevel] = useState(60);
const [partyConfigurations, setPartyConfigurations] = useState(10); const [partyConfigurations, setPartyConfigurations] = useState(10);
const [simulationsPerParty, setSimulationsPerParty] = useState(3); const [simulationsPerParty, setSimulationsPerParty] = useState(3);
@@ -90,9 +110,26 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
const query = jobSearch.trim().toLowerCase(); const query = jobSearch.trim().toLowerCase();
return jobs.filter((job) => ( return jobs.filter((job) => (
(jobStatus === "all" || job.status === jobStatus) (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]); }, [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 filteredBosses = useMemo(() => {
const query = bossSearch.trim().toLowerCase(); const query = bossSearch.trim().toLowerCase();
return BOSS_TESTING_CATALOG.filter((boss) => !query return BOSS_TESTING_CATALOG.filter((boss) => !query
@@ -102,7 +139,11 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
const bossCount = bossScope === "specific" const bossCount = bossScope === "specific"
? selectedBossKeys.length ? selectedBossKeys.length
: BOSS_TESTING_CATALOG.filter((boss) => bossScope === "all" || boss.category === bossScope).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; const estimatedRuns = partyCount * bossCount * simulationsPerParty;
useEffect(() => { useEffect(() => {
@@ -172,6 +213,12 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
: [...current, key]); : [...current, key]);
}; };
const toggleBuild = (id: string) => {
setSelectedBuildIds((current) => current.includes(id)
? current.filter((candidate) => candidate !== id)
: [...current, id]);
};
const run = async () => { const run = async () => {
if (!runtimeStatus?.available) { if (!runtimeStatus?.available) {
setError(runtimeStatus?.error ?? "The server Balance Lab runtime is unavailable."); 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."); setError("Select at least one boss for a specific-boss run.");
return; return;
} }
if (specSelection === "specific" && selectedBuilds.length === 0) {
setError("Select at least one class/spec for a specific-spec run.");
return;
}
setSubmitting(true); setSubmitting(true);
setError(""); setError("");
try { try {
const job = await startBalanceLabJob({ const job = await startBalanceLabJob({
source, source,
specSelection,
selectedBuildIds: specSelection === "specific" ? selectedBuilds.map((build) => build.id) : [],
level, level,
partyConfigurations, partyConfigurations: specSelection === "random" ? partyConfigurations : partyCount,
simulationsPerParty, simulationsPerParty,
durationSeconds, durationSeconds,
partySeed, 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> <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>}
<div className="balance-lab-form-grid"> <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> <option value="all">WoW + CoA + RoM</option><option value="wow">WoW only</option><option value="coa">CoA only</option><option value="rom">RoM only</option>
</select></label> </select></label>
<label><span>Level</span><input type="number" min={1} max={80} value={level} onChange={(event) => setLevel(event.currentTarget.valueAsNumber)} /></label> <label><span>Level</span><input type="number" min={1} max={80} value={level} onChange={(event) => {
<label><span>Parties per source</span><input type="number" min={1} max={100} value={partyConfigurations} onChange={(event) => setPartyConfigurations(event.currentTarget.valueAsNumber)} /></label> 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>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>Fight limit (seconds)</span><input type="number" min={5} max={600} value={durationSeconds} onChange={(event) => setDurationSeconds(event.currentTarget.valueAsNumber)} /></label>
<label><span>Party seed</span><input type="number" value={partySeed} onChange={(event) => setPartySeed(event.currentTarget.valueAsNumber)} /></label> <label><span>Party seed</span><input type="number" value={partySeed} onChange={(event) => setPartySeed(event.currentTarget.valueAsNumber)} /></label>
@@ -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> <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> </select></label>
</div> </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" && ( {bossScope === "specific" && (
<section className="balance-lab-boss-picker"> <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> <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> </div>
</section> </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> <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> <p className="balance-lab-local-note">To spend this device's CPU instead, run <code>npm run balance:lab</code> from the project checkout.</p>
{error && <p className="front-notice" role="alert">{error}</p>} {error && <p className="front-notice" role="alert">{error}</p>}
@@ -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-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"> <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)}> {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>)} </button>)}
{!loading && visibleJobs.length === 0 && <p className="balance-lab-empty">No saved runs match these filters.</p>} {!loading && visibleJobs.length === 0 && <p className="balance-lab-empty">No saved runs match these filters.</p>}
</div> </div>
@@ -291,7 +375,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
<p>{describeJob(selectedJob)}</p> <p>{describeJob(selectedJob)}</p>
<div className="balance-lab-progress" aria-label={`${progressPercent(selectedJob).toFixed(0)} percent complete`}><span style={{ width: `${progressPercent(selectedJob)}%` }} /></div> <div className="balance-lab-progress" aria-label={`${progressPercent(selectedJob).toFixed(0)} percent complete`}><span style={{ width: `${progressPercent(selectedJob)}%` }} /></div>
<dl> <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> </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.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>} {selectedJob.logTail.length > 0 && <details className="balance-lab-log"><summary>Server progress log</summary><pre>{selectedJob.logTail.slice(-20).join("\n")}</pre></details>}