Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0a02487db | ||
|
|
dd583023ac | ||
|
|
79153ca40d | ||
|
|
717d12e5af |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.49",
|
"versionName": "0.1.52",
|
||||||
"versionCode": 1049
|
"versionCode": 1052
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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"] : [];
|
||||||
|
|||||||
@@ -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);
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
Reference in new issue
Block a user