forked from phenom/healer-man
Release Healer Man 0.1.51
This commit is contained in:
1 parent
79153ca40d
commit
dd583023ac
13 files changed
+405
-31
No files matched your search
@@ -3561,3 +3561,23 @@ Vite's existing large-chunk advisory remains informational.
|
||||
effective healing as 57.8 HPS done and the Blood Death Knight's same 867 as
|
||||
57.8 HPS received. A matching automatic-suite run verified the new JSON and
|
||||
CSV fields under `playtest-artifacts/balance-lab-healing-suite/`.
|
||||
|
||||
## Balance Lab deterministic class/spec coverage (2026-09-17)
|
||||
|
||||
- Automatic sweeps now offer **Random parties**, **All specs**, and **Specific
|
||||
specs**. All-spec mode derives the minimum valid 1-tank/1-healer/3-damage
|
||||
party set that covers every available build. Specific mode guarantees every
|
||||
selected build appears and uses distinct compatible fillers for missing
|
||||
roles; selected builds are not reused as fillers when alternatives exist.
|
||||
- A real level-20 WoW all-spec sweep against Eck the Ferocious generated eight
|
||||
distinct parties and placed all 31 catalog builds in `suite-report.json`
|
||||
with zero missing. The combined catalog audit found 70 CoA builds requiring
|
||||
16 coverage parties and 16 RoM builds requiring four; every source exposes
|
||||
enough tank, healer, and damage builds for valid coverage parties.
|
||||
- Eight Python controller tests, nine Balance Lab service tests, two server
|
||||
integration tests, and the production build passed. Production Edge
|
||||
automation verified the All Specs estimate, selected Paladin Holy through the
|
||||
Specific Specs picker, completed the server job, rendered all seven overview
|
||||
graphs, and passed desktop plus 480×900 overflow checks without browser
|
||||
errors. Evidence is in `playtest-artifacts/balance-lab/02-server-job-config.png`
|
||||
and `playtest-artifacts/balance-lab/03-server-job-complete.png`.
|
||||
+26
-6
@@ -25,10 +25,28 @@ The prompt selects:
|
||||
1. one random party, five manual members, or an automatic multi-party sweep;
|
||||
2. a WoW, CoA, or RoM party catalog; automatic mode can select all three;
|
||||
3. a level from 1 through 80;
|
||||
4. how many unique 1-tank/1-healer/3-damage configurations to generate per source;
|
||||
5. all bosses, WoW bosses, RoM bosses, or a searchable multi-boss selection;
|
||||
6. how many deterministic simulations to run for every party/boss pairing; and
|
||||
7. the maximum duration of each fight.
|
||||
4. random parties, guaranteed coverage of all class/spec builds, or a
|
||||
searchable selection of specific class/spec builds;
|
||||
5. how many unique 1-tank/1-healer/3-damage configurations to generate per
|
||||
source when random selection is used;
|
||||
6. all bosses, WoW bosses, RoM bosses, or a searchable multi-boss selection;
|
||||
7. how many deterministic simulations to run for every party/boss pairing; and
|
||||
8. the maximum duration of each fight.
|
||||
|
||||
Automatic class/spec selection has three strategies:
|
||||
|
||||
- **Random parties** preserves the original seeded behavior and generates the
|
||||
requested number of unique parties per source.
|
||||
- **All specs** creates the minimum deterministic set of valid parties needed
|
||||
to include every available class/spec build for the selected WoW, CoA, or
|
||||
RoM source at least once.
|
||||
- **Specific specs** treats each selected build as required coverage and fills
|
||||
the remaining tank, healer, and damage slots with distinct compatible builds.
|
||||
When alternatives exist, required builds are not reused as fillers.
|
||||
|
||||
Coverage parties always contain one tank, one healer, and three different
|
||||
damage builds. Their composition is deterministic for the party seed, and no
|
||||
build is duplicated inside one party.
|
||||
|
||||
The active member is driven by the shared benchmark policy while the other four
|
||||
use the live companion AI. The healer is the default active member. Use
|
||||
@@ -38,8 +56,10 @@ use the live companion AI. The healer is the default active member. Use
|
||||
|
||||
A server-authorized GM sees **Balance Lab** directly underneath **GM Tools** on
|
||||
the main menu. That screen exposes the automatic sweep controls: party source,
|
||||
level, unique parties per source, simulations per party/boss, duration, random
|
||||
seed, boss category, and a searchable multi-boss picker.
|
||||
level, class/spec selection strategy, a searchable spec picker, simulations per
|
||||
party/boss, duration, random seed, boss category, and a searchable multi-boss
|
||||
picker. Random mode exposes the requested parties per source; coverage modes
|
||||
show the derived minimum party count before submission.
|
||||
|
||||
**Run on server** sends only that allow-listed configuration to the authenticated
|
||||
GM API. The server places it in a one-at-a-time queue, launches the same Python
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.51",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.51",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.51",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.50",
|
||||
"versionCode": 1050
|
||||
"versionName": "0.1.51",
|
||||
"versionCode": 1051
|
||||
}
|
||||
+155
-3
@@ -195,6 +195,92 @@ def automatic_parties(
|
||||
return configurations
|
||||
|
||||
|
||||
def coverage_parties(
|
||||
builds: Sequence[dict[str, Any]],
|
||||
sources: Sequence[str],
|
||||
selected_build_ids: Sequence[str] | None,
|
||||
seed: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the minimum deterministic party set that covers every requested build."""
|
||||
requested = set(selected_build_ids) if selected_build_ids is not None else None
|
||||
offsets = {"wow": 100_000, "coa": 200_000, "rom": 300_000}
|
||||
configurations: list[dict[str, Any]] = []
|
||||
for source in sources:
|
||||
source_builds = [build for build in builds if build["source"] == source]
|
||||
targets = source_builds if requested is None else [
|
||||
build for build in source_builds if build["id"] in requested
|
||||
]
|
||||
if not targets:
|
||||
continue
|
||||
available_by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
targets_by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for build in source_builds:
|
||||
available_by_role[build["role"]].append(build)
|
||||
for build in targets:
|
||||
targets_by_role[build["role"]].append(build)
|
||||
if not available_by_role["tank"] or not available_by_role["healer"] or len(available_by_role["damage"]) < 3:
|
||||
raise SystemExit(
|
||||
f"The {source} catalog does not expose enough tank, healer, and damage builds for a 1/1/3 party."
|
||||
)
|
||||
party_count = max(
|
||||
1,
|
||||
len(targets_by_role["tank"]),
|
||||
len(targets_by_role["healer"]),
|
||||
math.ceil(len(targets_by_role["damage"]) / 3),
|
||||
)
|
||||
generator = random.Random(seed + offsets[source])
|
||||
for rows in (*available_by_role.values(), *targets_by_role.values()):
|
||||
rows.sort(key=lambda build: build["id"])
|
||||
generator.shuffle(rows)
|
||||
|
||||
def filler(role: str, party_index: int, slot_index: int, used: set[str]) -> dict[str, Any]:
|
||||
pool = available_by_role[role]
|
||||
start = (party_index * (3 if role == "damage" else 1) + slot_index) % len(pool)
|
||||
for avoid_requested in (True, False):
|
||||
for offset in range(len(pool)):
|
||||
candidate = pool[(start + offset) % len(pool)]
|
||||
if candidate["id"] in used:
|
||||
continue
|
||||
if avoid_requested and requested is not None and candidate["id"] in requested:
|
||||
continue
|
||||
return candidate
|
||||
raise SystemExit(f"Could not fill a unique {role} slot for a {source} coverage party.")
|
||||
|
||||
for party_index in range(party_count):
|
||||
used: set[str] = set()
|
||||
tank = targets_by_role["tank"][party_index] if party_index < len(targets_by_role["tank"]) else filler(
|
||||
"tank", party_index, 0, used
|
||||
)
|
||||
used.add(tank["id"])
|
||||
healer = targets_by_role["healer"][party_index] if party_index < len(targets_by_role["healer"]) else filler(
|
||||
"healer", party_index, 0, used
|
||||
)
|
||||
used.add(healer["id"])
|
||||
damage = list(targets_by_role["damage"][party_index * 3:(party_index + 1) * 3])
|
||||
used.update(build["id"] for build in damage)
|
||||
while len(damage) < 3:
|
||||
build = filler("damage", party_index, len(damage), used)
|
||||
damage.append(build)
|
||||
used.add(build["id"])
|
||||
party = [tank, healer, *damage]
|
||||
configurations.append({
|
||||
"id": f"{source}-party-{party_index + 1:03d}",
|
||||
"source": source,
|
||||
"party": party,
|
||||
"activeSlot": 1,
|
||||
})
|
||||
if not configurations:
|
||||
raise SystemExit("No class/spec builds matched the requested coverage selection.")
|
||||
return configurations
|
||||
|
||||
|
||||
def search_builds(query: str, builds: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
needle = normalized(query)
|
||||
return [build for build in builds if needle in normalized(
|
||||
f"{build['id']} {build['label']} {build['source']} {build['role']}"
|
||||
)]
|
||||
|
||||
|
||||
def search_bosses(query: str, bosses: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
needle = normalized(query)
|
||||
return [boss for boss in bosses if needle in normalized(
|
||||
@@ -273,6 +359,43 @@ def prompt_specific_bosses(bosses: Sequence[dict[str, Any]]) -> list[str]:
|
||||
print(f"Selected bosses: {len(selected)}")
|
||||
|
||||
|
||||
def prompt_specific_builds(builds: Sequence[dict[str, Any]]) -> list[str]:
|
||||
selected: dict[str, dict[str, Any]] = {}
|
||||
print("\nClass/spec picker: search by class, specialization, role, source, or build ID.")
|
||||
while True:
|
||||
suffix = f" ({len(selected)} selected; type 'done' to finish)" if selected else ""
|
||||
query = input(f"Class/spec search{suffix}: ").strip()
|
||||
if query.casefold() == "done":
|
||||
if selected:
|
||||
return list(selected)
|
||||
print("Select at least one class/spec first.")
|
||||
continue
|
||||
matches = search_builds(query, builds)
|
||||
if not matches:
|
||||
print("No class/spec builds matched that search.")
|
||||
continue
|
||||
if len(matches) > 50:
|
||||
print(f"{len(matches)} builds matched. Use a more specific search (50 maximum per result page).")
|
||||
continue
|
||||
for index, build in enumerate(matches, 1):
|
||||
marker = " [selected]" if build["id"] in selected else ""
|
||||
print(f" {index:>2}. {console_text(build['label'])} [{build['source']}]{marker}")
|
||||
raw = input("Add numbers separated by commas, or 'all': ").strip().casefold()
|
||||
if raw == "all":
|
||||
indexes = list(range(len(matches)))
|
||||
else:
|
||||
try:
|
||||
indexes = [int(entry.strip()) - 1 for entry in raw.split(",") if entry.strip()]
|
||||
except ValueError:
|
||||
indexes = []
|
||||
if not indexes or any(index < 0 or index >= len(matches) for index in indexes):
|
||||
print("Choose valid result numbers.")
|
||||
continue
|
||||
for index in indexes:
|
||||
selected[matches[index]["id"]] = matches[index]
|
||||
print(f"Selected class/spec builds: {len(selected)}")
|
||||
|
||||
|
||||
def interactive_arguments(
|
||||
args: argparse.Namespace,
|
||||
workspace: Path,
|
||||
@@ -288,7 +411,11 @@ def interactive_arguments(
|
||||
if args.party_mode == "random":
|
||||
args.party_seed = prompt_integer("Random party seed", 1, -2_147_483_648, 2_147_483_647)
|
||||
elif args.party_mode == "automatic":
|
||||
args.party_configurations = prompt_integer("Unique party configurations per source", 10, 1, 1_000)
|
||||
args.spec_selection = prompt_choice("Class/spec selection", ("random", "all", "specific"), "all")
|
||||
if args.spec_selection == "random":
|
||||
args.party_configurations = prompt_integer("Unique party configurations per source", 10, 1, 1_000)
|
||||
elif args.spec_selection == "specific":
|
||||
args.spec = prompt_specific_builds(catalog["builds"])
|
||||
args.party_seed = prompt_integer("Automatic party seed", 1, -2_147_483_648, 2_147_483_647)
|
||||
else:
|
||||
print("\nAvailable members:")
|
||||
@@ -1159,6 +1286,10 @@ def parser() -> argparse.ArgumentParser:
|
||||
result.add_argument("--source", choices=("wow", "coa", "rom", "all"), help="party catalog; all requires automatic mode")
|
||||
result.add_argument("--level", type=int, default=60, help="party level (default: 60)")
|
||||
result.add_argument("--party-mode", choices=("random", "manual", "automatic"), default="random")
|
||||
result.add_argument("--spec-selection", choices=("random", "all", "specific"), default="random",
|
||||
help="automatic party strategy (default: random)")
|
||||
result.add_argument("--spec", action="append", default=[],
|
||||
help="required build ID or class:spec selector for --spec-selection specific")
|
||||
result.add_argument("--party-seed", type=int, default=1, help="random party seed")
|
||||
result.add_argument("--party-configurations", type=int, default=10, help="automatic parties per selected source")
|
||||
result.add_argument("--member", action="append", default=[], help="manual build ID or class:spec selector; repeat five times")
|
||||
@@ -1215,7 +1346,25 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
seeds = args.seeds or list(range(1, args.simulations_per_party + 1))
|
||||
if args.party_mode == "automatic":
|
||||
sources = ("wow", "coa", "rom") if args.source == "all" else (args.source,)
|
||||
configurations = automatic_parties(builds, sources, args.party_configurations, args.party_seed)
|
||||
if args.spec_selection != "specific" and args.spec:
|
||||
session.close()
|
||||
raise SystemExit("--spec values require --spec-selection specific")
|
||||
if args.spec_selection == "random":
|
||||
configurations = automatic_parties(builds, sources, args.party_configurations, args.party_seed)
|
||||
selected_build_ids: list[str] = []
|
||||
else:
|
||||
if args.spec_selection == "specific" and not args.spec:
|
||||
session.close()
|
||||
raise SystemExit("--spec-selection specific requires at least one --spec value")
|
||||
selected_build_ids = list(dict.fromkeys(
|
||||
resolve_build(selector, builds)["id"] for selector in args.spec
|
||||
))
|
||||
configurations = coverage_parties(
|
||||
builds,
|
||||
sources,
|
||||
None if args.spec_selection == "all" else selected_build_ids,
|
||||
args.party_seed,
|
||||
)
|
||||
if args.active_slot is not None:
|
||||
if not 1 <= args.active_slot <= 5:
|
||||
session.close()
|
||||
@@ -1258,10 +1407,13 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
"policyId": catalog["policyId"],
|
||||
"rotationVersion": catalog["rotationVersion"],
|
||||
"mode": "automatic",
|
||||
"specSelection": args.spec_selection,
|
||||
"selectedBuildIds": selected_build_ids,
|
||||
"source": args.source,
|
||||
"level": args.level,
|
||||
"partySeed": args.party_seed,
|
||||
"partyConfigurationsPerSource": args.party_configurations,
|
||||
"partyConfigurationsPerSource": args.party_configurations if args.spec_selection == "random" else None,
|
||||
"partyConfigurations": len(configurations),
|
||||
"simulationsPerParty": len(seeds),
|
||||
"seeds": seeds,
|
||||
"durationSeconds": args.duration,
|
||||
|
||||
@@ -56,7 +56,12 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
|
||||
assert.match(await page.locator('.balance-lab-runtime').innerText(), /Python 3\./);
|
||||
await page.getByLabel('Party source').selectOption('wow');
|
||||
await page.getByLabel('Level').fill('20');
|
||||
await page.getByLabel('Parties per source').fill('1');
|
||||
await page.getByLabel('Class/spec selection').selectOption('all');
|
||||
assert(Number(await page.getByLabel('Coverage parties').inputValue()) > 1, 'all-spec coverage should derive multiple parties');
|
||||
await page.getByLabel('Class/spec selection').selectOption('specific');
|
||||
await page.getByLabel('Search Balance Lab class specs').fill('Paladin Holy');
|
||||
await page.getByRole('checkbox', { name: /Paladin · Holy/ }).check();
|
||||
assert.equal(await page.getByLabel('Coverage parties').inputValue(), '1');
|
||||
await page.getByLabel('Runs per party/boss').fill('1');
|
||||
await page.getByLabel('Fight limit (seconds)').fill('5');
|
||||
await page.getByLabel('Bosses').selectOption('specific');
|
||||
@@ -75,7 +80,7 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
|
||||
|
||||
await page.getByRole('button', { name: 'View graphs and filterable data' }).click();
|
||||
await page.locator('.balance-results').waitFor();
|
||||
assert.equal(await page.locator('.balance-results-chart').count(), 5);
|
||||
assert.equal(await page.locator('.balance-results-chart').count(), 7);
|
||||
assert.match(await page.locator('.balance-results-chart').filter({ hasText: 'Boss damage to party' }).innerText(), /Eck the Ferocious/);
|
||||
await capture('04-run-analytics');
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from scripts.balance_lab import (
|
||||
aggregate_class_abilities,
|
||||
aggregate_class_specs,
|
||||
automatic_parties,
|
||||
coverage_parties,
|
||||
random_party,
|
||||
search_bosses,
|
||||
tuning_label,
|
||||
@@ -57,6 +58,44 @@ class BalanceLabControllerTests(unittest.TestCase):
|
||||
"tank", "healer", "damage", "damage", "damage",
|
||||
])
|
||||
|
||||
def test_coverage_parties_include_every_build_with_minimum_unique_parties(self) -> None:
|
||||
builds = [
|
||||
{"id": "tank-a", "source": "wow", "role": "tank"},
|
||||
{"id": "tank-b", "source": "wow", "role": "tank"},
|
||||
{"id": "healer-a", "source": "wow", "role": "healer"},
|
||||
{"id": "healer-b", "source": "wow", "role": "healer"},
|
||||
*({"id": f"damage-{index}", "source": "wow", "role": "damage"} for index in range(7)),
|
||||
]
|
||||
first = coverage_parties(builds, ("wow",), None, 19)
|
||||
second = coverage_parties(builds, ("wow",), None, 19)
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(len(first), 3)
|
||||
covered = {member["id"] for configuration in first for member in configuration["party"]}
|
||||
self.assertEqual(covered, {build["id"] for build in builds})
|
||||
signatures = {tuple(member["id"] for member in configuration["party"]) for configuration in first}
|
||||
self.assertEqual(len(signatures), 3)
|
||||
for configuration in first:
|
||||
self.assertEqual([member["role"] for member in configuration["party"]], [
|
||||
"tank", "healer", "damage", "damage", "damage",
|
||||
])
|
||||
self.assertEqual(len({member["id"] for member in configuration["party"]}), 5)
|
||||
|
||||
def test_specific_coverage_uses_fillers_but_guarantees_selected_builds(self) -> None:
|
||||
builds = [
|
||||
{"id": "tank-a", "source": "wow", "role": "tank"},
|
||||
{"id": "tank-b", "source": "wow", "role": "tank"},
|
||||
{"id": "healer-a", "source": "wow", "role": "healer"},
|
||||
{"id": "healer-b", "source": "wow", "role": "healer"},
|
||||
*({"id": f"damage-{index}", "source": "wow", "role": "damage"} for index in range(5)),
|
||||
]
|
||||
selected = ["tank-a", "tank-b", "damage-4"]
|
||||
configurations = coverage_parties(builds, ("wow",), selected, 7)
|
||||
self.assertEqual(len(configurations), 2)
|
||||
observed = [member["id"] for configuration in configurations for member in configuration["party"]]
|
||||
covered = set(observed)
|
||||
self.assertTrue(set(selected).issubset(covered))
|
||||
self.assertTrue(all(observed.count(build_id) == 1 for build_id in selected))
|
||||
|
||||
def test_boss_search_matches_name_dungeon_key_and_source(self) -> None:
|
||||
bosses = [
|
||||
{"key": "gundrak/eck", "name": "Eck the Ferocious", "dungeonTitle": "Gundrak", "category": "wow"},
|
||||
|
||||
@@ -15,6 +15,7 @@ import { GameDatabaseError } from "./database.mjs";
|
||||
|
||||
const SOURCES = new Set(["wow", "coa", "rom", "all"]);
|
||||
const BOSS_CATEGORIES = new Set(["all", "wow", "rom"]);
|
||||
const SPEC_SELECTIONS = new Set(["random", "all", "specific"]);
|
||||
const JOB_STATUSES = new Set(["queued", "running", "complete", "failed"]);
|
||||
const JOB_METADATA_NAME = "job.json";
|
||||
const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
@@ -43,12 +44,16 @@ function integer(value, name, minimum, maximum) {
|
||||
export function normalizeBalanceLabJobSpec(input) {
|
||||
const source = String(input?.source ?? "all").toLowerCase();
|
||||
const bossCategory = String(input?.bossCategory ?? "all").toLowerCase();
|
||||
const specSelection = String(input?.specSelection ?? "random").toLowerCase();
|
||||
if (!SOURCES.has(source)) {
|
||||
throw new GameDatabaseError("Party source must be wow, coa, rom, or all.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
if (!BOSS_CATEGORIES.has(bossCategory)) {
|
||||
throw new GameDatabaseError("Boss category must be all, wow, or rom.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
if (!SPEC_SELECTIONS.has(specSelection)) {
|
||||
throw new GameDatabaseError("Class/spec selection must be random, all, or specific.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
const rawBossKeys = Array.isArray(input?.bossKeys) ? input.bossKeys : [];
|
||||
if (rawBossKeys.length > 500) {
|
||||
throw new GameDatabaseError("Select no more than 500 bosses.", 400, "invalid_balance_lab_job");
|
||||
@@ -57,8 +62,21 @@ export function normalizeBalanceLabJobSpec(input) {
|
||||
if (bossKeys.some((key) => key.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(key))) {
|
||||
throw new GameDatabaseError("A selected boss key is invalid.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
const rawBuildIds = Array.isArray(input?.selectedBuildIds) ? input.selectedBuildIds : [];
|
||||
if (rawBuildIds.length > 200) {
|
||||
throw new GameDatabaseError("Select no more than 200 class/spec builds.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
const selectedBuildIds = [...new Set(rawBuildIds.map((value) => String(value).trim()).filter(Boolean))];
|
||||
if (selectedBuildIds.some((id) => id.length > 240 || !/^[A-Za-z0-9._:/-]+$/.test(id))) {
|
||||
throw new GameDatabaseError("A selected class/spec build ID is invalid.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
if (specSelection === "specific" && selectedBuildIds.length === 0) {
|
||||
throw new GameDatabaseError("Select at least one class/spec build.", 400, "invalid_balance_lab_job");
|
||||
}
|
||||
const spec = Object.freeze({
|
||||
source,
|
||||
specSelection,
|
||||
selectedBuildIds: Object.freeze(specSelection === "specific" ? selectedBuildIds : []),
|
||||
level: integer(input?.level ?? 60, "Level", 1, 80),
|
||||
partyConfigurations: integer(input?.partyConfigurations ?? 10, "Party configurations", 1, 100),
|
||||
simulationsPerParty: integer(input?.simulationsPerParty ?? 3, "Simulations per party", 1, 100),
|
||||
@@ -69,7 +87,12 @@ export function normalizeBalanceLabJobSpec(input) {
|
||||
});
|
||||
const sourceCount = spec.source === "all" ? 3 : 1;
|
||||
const bossEstimate = Math.max(1, spec.bossKeys.length || (spec.bossCategory === "all" ? 500 : 300));
|
||||
const estimatedRuns = sourceCount * spec.partyConfigurations * spec.simulationsPerParty * bossEstimate;
|
||||
const partyEstimate = spec.specSelection === "random"
|
||||
? sourceCount * spec.partyConfigurations
|
||||
: spec.specSelection === "specific"
|
||||
? Math.max(spec.partyConfigurations, spec.selectedBuildIds.length)
|
||||
: Math.max(spec.partyConfigurations, spec.source === "all" ? 100 : 50);
|
||||
const estimatedRuns = partyEstimate * spec.simulationsPerParty * bossEstimate;
|
||||
if (estimatedRuns > 250_000) {
|
||||
throw new GameDatabaseError(
|
||||
"That job is too large for one server request. Reduce parties, simulations, or selected bosses.",
|
||||
@@ -90,6 +113,7 @@ export function balanceLabCommand(spec, outputDir, options = {}) {
|
||||
"--source", spec.source,
|
||||
"--level", String(spec.level),
|
||||
"--party-mode", "automatic",
|
||||
"--spec-selection", spec.specSelection,
|
||||
"--party-seed", String(spec.partySeed),
|
||||
"--party-configurations", String(spec.partyConfigurations),
|
||||
"--simulations-per-party", String(spec.simulationsPerParty),
|
||||
@@ -97,6 +121,7 @@ export function balanceLabCommand(spec, outputDir, options = {}) {
|
||||
"--boss-category", spec.bossCategory,
|
||||
"--output-dir", outputDir,
|
||||
];
|
||||
for (const buildId of spec.selectedBuildIds) args.push("--spec", buildId);
|
||||
for (const bossKey of spec.bossKeys) args.push("--boss", bossKey);
|
||||
return { executable, args, cwd: projectRoot };
|
||||
}
|
||||
@@ -167,6 +192,12 @@ function reportSummary(outputDir) {
|
||||
}
|
||||
}
|
||||
|
||||
function expectedPartyConfigurations(spec) {
|
||||
return spec.specSelection === "random"
|
||||
? spec.partyConfigurations * (spec.source === "all" ? 3 : 1)
|
||||
: spec.partyConfigurations;
|
||||
}
|
||||
|
||||
function lineCollector(onLine) {
|
||||
let pending = "";
|
||||
return {
|
||||
@@ -236,8 +267,12 @@ export function createBalanceLabJobService(options = {}) {
|
||||
requestedBy: "unknown (legacy run)",
|
||||
spec: {
|
||||
source: legacyConfig.source,
|
||||
specSelection: legacyConfig.specSelection ?? "random",
|
||||
selectedBuildIds: legacyConfig.selectedBuildIds ?? [],
|
||||
level: legacyConfig.level,
|
||||
partyConfigurations: legacyConfig.partyConfigurationsPerSource,
|
||||
partyConfigurations: legacyConfig.specSelection && legacyConfig.specSelection !== "random"
|
||||
? legacyConfig.partyConfigurations
|
||||
: legacyConfig.partyConfigurationsPerSource,
|
||||
simulationsPerParty: legacyConfig.simulationsPerParty,
|
||||
durationSeconds: legacyConfig.durationSeconds,
|
||||
partySeed: legacyConfig.partySeed,
|
||||
@@ -252,7 +287,8 @@ export function createBalanceLabJobService(options = {}) {
|
||||
const hasReport = existsSync(reportPath);
|
||||
const storedStatus = JOB_STATUSES.has(stored.status) ? stored.status : "failed";
|
||||
const interrupted = !hasReport && (storedStatus === "queued" || storedStatus === "running");
|
||||
const totalParties = spec.partyConfigurations * (spec.source === "all" ? 3 : 1);
|
||||
const restoredSummary = hasReport ? reportSummary(outputDir) : null;
|
||||
const totalParties = restoredSummary?.partyConfigurations || expectedPartyConfigurations(spec);
|
||||
const createdAt = typeof stored.createdAt === "string"
|
||||
? stored.createdAt
|
||||
: statSync(outputDir).birthtime.toISOString();
|
||||
@@ -282,7 +318,7 @@ export function createBalanceLabJobService(options = {}) {
|
||||
logTail: Array.isArray(stored.logTail)
|
||||
? stored.logTail.filter((line) => typeof line === "string").slice(-80)
|
||||
: [],
|
||||
summary: hasReport ? reportSummary(outputDir) : null,
|
||||
summary: restoredSummary,
|
||||
outputDir,
|
||||
};
|
||||
jobs.set(job.id, job);
|
||||
@@ -401,7 +437,7 @@ export function createBalanceLabJobService(options = {}) {
|
||||
const id = randomUUID();
|
||||
const outputDir = path.join(jobsDir, id);
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const totalParties = spec.partyConfigurations * (spec.source === "all" ? 3 : 1);
|
||||
const totalParties = expectedPartyConfigurations(spec);
|
||||
const job = {
|
||||
id,
|
||||
status: "queued",
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
test("normalizes and bounds server Balance Lab requests", () => {
|
||||
const spec = normalizeBalanceLabJobSpec({
|
||||
source: "ALL",
|
||||
specSelection: "specific",
|
||||
selectedBuildIds: ["balance-wow-priest-healer-holy-level-40", "balance-wow-priest-healer-holy-level-40"],
|
||||
level: 40,
|
||||
partyConfigurations: 2,
|
||||
simulationsPerParty: 3,
|
||||
@@ -24,6 +26,8 @@ test("normalizes and bounds server Balance Lab requests", () => {
|
||||
bossKeys: ["gundrak/entry-29932", "gundrak/entry-29932"],
|
||||
});
|
||||
assert.equal(spec.source, "all");
|
||||
assert.equal(spec.specSelection, "specific");
|
||||
assert.deepEqual(spec.selectedBuildIds, ["balance-wow-priest-healer-holy-level-40"]);
|
||||
assert.deepEqual(spec.bossKeys, ["gundrak/entry-29932"]);
|
||||
assert.throws(
|
||||
() => normalizeBalanceLabJobSpec({ source: "wow", level: 81 }),
|
||||
@@ -33,12 +37,17 @@ test("normalizes and bounds server Balance Lab requests", () => {
|
||||
() => normalizeBalanceLabJobSpec({ source: "wow", bossKeys: ["boss; shutdown"] }),
|
||||
/boss key is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeBalanceLabJobSpec({ source: "wow", specSelection: "specific", selectedBuildIds: [] }),
|
||||
/Select at least one class\/spec build/,
|
||||
);
|
||||
});
|
||||
|
||||
test("builds an argument-array command without a shell", () => {
|
||||
const spec = normalizeBalanceLabJobSpec({
|
||||
source: "rom", level: 60, partyConfigurations: 4, simulationsPerParty: 2,
|
||||
durationSeconds: 30, partySeed: 9, bossCategory: "rom", bossKeys: ["abbey/boss-1"],
|
||||
specSelection: "specific", selectedBuildIds: ["balance-rom-mage-damage-primary-level-60"],
|
||||
});
|
||||
const command = balanceLabCommand(spec, "C:/reports/job", {
|
||||
projectRoot: "C:/app",
|
||||
@@ -47,6 +56,8 @@ test("builds an argument-array command without a shell", () => {
|
||||
assert.equal(command.executable, "python-custom");
|
||||
assert.equal(command.args.includes("--party-mode"), true);
|
||||
assert.equal(command.args.includes("automatic"), true);
|
||||
assert.equal(command.args.includes("specific"), true);
|
||||
assert.equal(command.args.includes("balance-rom-mage-damage-primary-level-60"), true);
|
||||
assert.equal(command.args.includes("abbey/boss-1"), true);
|
||||
assert.equal(command.cwd, path.resolve("C:/app"));
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { onlineApiOrigin, requestOnlineJson } from "./onlineAccountClient";
|
||||
|
||||
export type BalanceLabPartySource = "wow" | "coa" | "rom" | "all";
|
||||
export type BalanceLabBossCategory = "all" | "wow" | "rom";
|
||||
export type BalanceLabSpecSelection = "random" | "all" | "specific";
|
||||
export type BalanceLabJobStatus = "queued" | "running" | "complete" | "failed";
|
||||
|
||||
export interface BalanceLabRuntimeStatus {
|
||||
@@ -13,6 +14,8 @@ export interface BalanceLabRuntimeStatus {
|
||||
|
||||
export interface BalanceLabJobSpec {
|
||||
readonly source: BalanceLabPartySource;
|
||||
readonly specSelection?: BalanceLabSpecSelection;
|
||||
readonly selectedBuildIds?: readonly string[];
|
||||
readonly level: number;
|
||||
readonly partyConfigurations: number;
|
||||
readonly simulationsPerParty: number;
|
||||
|
||||
+5
-1
@@ -3236,6 +3236,9 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
.balance-lab-boss-picker { margin-top: 10px; border: 1px solid var(--front-line); border-radius: 8px; overflow: hidden; }
|
||||
.balance-lab-boss-picker > header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid var(--front-line); }
|
||||
.balance-lab-boss-picker > header strong { color: #d7c68c; font-size: 8px; white-space: nowrap; }
|
||||
.balance-lab-picker-actions { display: flex; align-items: center; gap: 5px; }
|
||||
.balance-lab-picker-actions button { padding: 5px 7px; border: 1px solid var(--front-line); border-radius: 5px; color: var(--front-dim); background: rgba(149, 190, 118, .06); cursor: pointer; font: inherit; font-size: 7px; white-space: nowrap; }
|
||||
.balance-lab-picker-actions button:hover { color: var(--front-cream); background: rgba(149, 190, 118, .12); }
|
||||
.balance-lab-boss-picker > div { display: grid; max-height: 220px; overflow: auto; }
|
||||
.balance-lab-boss-picker label { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8px; padding: 7px 8px; border-bottom: 1px solid rgba(190, 213, 172, .08); cursor: pointer; }
|
||||
.balance-lab-boss-picker label:hover, .balance-lab-boss-picker label.is-selected { background: rgba(149, 190, 118, .1); }
|
||||
@@ -3243,7 +3246,8 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
.balance-lab-boss-picker label span, .balance-lab-boss-picker label strong, .balance-lab-boss-picker label small { display: block; min-width: 0; }
|
||||
.balance-lab-boss-picker label strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.balance-lab-boss-picker label small { margin-top: 2px; color: var(--front-dim); font-size: 7px; }
|
||||
.balance-lab-estimate { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; margin-top: 10px; }
|
||||
.balance-lab-coverage-note { margin: 8px 0 0; color: var(--front-dim); font-size: 7px; line-height: 1.45; }
|
||||
.balance-lab-estimate { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 5px; margin-top: 10px; }
|
||||
.balance-lab-estimate span { display: grid; gap: 3px; padding: 8px; border: 1px solid rgba(190, 213, 172, .11); border-radius: 6px; color: var(--front-dim); font-size: 7px; }
|
||||
.balance-lab-estimate b { color: var(--front-cream); font-size: 11px; }
|
||||
.balance-lab-run { width: 100%; margin-top: 10px; }
|
||||
|
||||
@@ -12,9 +12,15 @@ import {
|
||||
type BalanceLabJob,
|
||||
type BalanceLabPartySource,
|
||||
type BalanceLabRuntimeStatus,
|
||||
type BalanceLabSpecSelection,
|
||||
type BalanceLabSuiteReport,
|
||||
} from "../app/balanceLabClient";
|
||||
import { classById } from "../app/characterCatalog";
|
||||
import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
|
||||
import {
|
||||
selectableBenchmarkBuildCatalog,
|
||||
type SelectableBenchmarkBuildCatalogEntry,
|
||||
} from "../game/benchmark/benchmarkBuilds";
|
||||
import { BalanceLabResults } from "./BalanceLabResults";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
|
||||
@@ -60,10 +66,24 @@ function describeJob(job: BalanceLabJob): string {
|
||||
return `Party ${party}/${job.progress.partiesTotal}${simulation}`;
|
||||
}
|
||||
|
||||
function coveragePartyCount(builds: readonly SelectableBenchmarkBuildCatalogEntry[]): number {
|
||||
const bySource = new Map<string, SelectableBenchmarkBuildCatalogEntry[]>();
|
||||
for (const build of builds) bySource.set(build.source, [...(bySource.get(build.source) ?? []), build]);
|
||||
return [...bySource.values()].reduce((total, rows) => {
|
||||
const tanks = rows.filter((build) => build.role === "tank").length;
|
||||
const healers = rows.filter((build) => build.role === "healer").length;
|
||||
const damage = rows.filter((build) => build.role === "damage").length;
|
||||
return total + Math.max(1, tanks, healers, Math.ceil(damage / 3));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) {
|
||||
const session = useShellStore((state) => state.session);
|
||||
const token = session?.accessToken ?? "";
|
||||
const [source, setSource] = useState<BalanceLabPartySource>("all");
|
||||
const [specSelection, setSpecSelection] = useState<BalanceLabSpecSelection>("random");
|
||||
const [specSearch, setSpecSearch] = useState("");
|
||||
const [selectedBuildIds, setSelectedBuildIds] = useState<readonly string[]>([]);
|
||||
const [level, setLevel] = useState(60);
|
||||
const [partyConfigurations, setPartyConfigurations] = useState(10);
|
||||
const [simulationsPerParty, setSimulationsPerParty] = useState(3);
|
||||
@@ -90,9 +110,26 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
const query = jobSearch.trim().toLowerCase();
|
||||
return jobs.filter((job) => (
|
||||
(jobStatus === "all" || job.status === jobStatus)
|
||||
&& (!query || `${job.requestedBy} ${job.spec.source} ${job.spec.level} ${job.id} ${job.status}`.toLowerCase().includes(query))
|
||||
&& (!query || `${job.requestedBy} ${job.spec.source} ${job.spec.level} ${job.spec.specSelection ?? "random"} ${job.id} ${job.status}`.toLowerCase().includes(query))
|
||||
));
|
||||
}, [jobSearch, jobStatus, jobs]);
|
||||
const availableBuilds = useMemo(() => selectableBenchmarkBuildCatalog(
|
||||
Number.isFinite(level) ? level : 60,
|
||||
).filter((build) => source === "all" || build.source === source).sort((left, right) => (
|
||||
left.source.localeCompare(right.source)
|
||||
|| classById(left.classId).name.localeCompare(classById(right.classId).name)
|
||||
|| left.orientation.localeCompare(right.orientation)
|
||||
|| left.role.localeCompare(right.role)
|
||||
)), [level, source]);
|
||||
const selectedBuildSet = useMemo(() => new Set(selectedBuildIds), [selectedBuildIds]);
|
||||
const selectedBuilds = useMemo(() => availableBuilds.filter((build) => selectedBuildSet.has(build.id)), [
|
||||
availableBuilds,
|
||||
selectedBuildSet,
|
||||
]);
|
||||
const filteredBuilds = useMemo(() => {
|
||||
const query = specSearch.trim().toLowerCase();
|
||||
return availableBuilds.filter((build) => !query || `${classById(build.classId).name} ${build.orientation} ${build.role} ${build.source} ${build.id}`.toLowerCase().includes(query));
|
||||
}, [availableBuilds, specSearch]);
|
||||
const filteredBosses = useMemo(() => {
|
||||
const query = bossSearch.trim().toLowerCase();
|
||||
return BOSS_TESTING_CATALOG.filter((boss) => !query
|
||||
@@ -102,7 +139,11 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
const bossCount = bossScope === "specific"
|
||||
? selectedBossKeys.length
|
||||
: BOSS_TESTING_CATALOG.filter((boss) => bossScope === "all" || boss.category === bossScope).length;
|
||||
const partyCount = partyConfigurations * (source === "all" ? 3 : 1);
|
||||
const coverageBuilds = specSelection === "all" ? availableBuilds : selectedBuilds;
|
||||
const partyCount = specSelection === "random"
|
||||
? partyConfigurations * (source === "all" ? 3 : 1)
|
||||
: coveragePartyCount(coverageBuilds);
|
||||
const coveredSpecCount = specSelection === "all" ? availableBuilds.length : selectedBuilds.length;
|
||||
const estimatedRuns = partyCount * bossCount * simulationsPerParty;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -172,6 +213,12 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
: [...current, key]);
|
||||
};
|
||||
|
||||
const toggleBuild = (id: string) => {
|
||||
setSelectedBuildIds((current) => current.includes(id)
|
||||
? current.filter((candidate) => candidate !== id)
|
||||
: [...current, id]);
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
if (!runtimeStatus?.available) {
|
||||
setError(runtimeStatus?.error ?? "The server Balance Lab runtime is unavailable.");
|
||||
@@ -181,13 +228,19 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
setError("Select at least one boss for a specific-boss run.");
|
||||
return;
|
||||
}
|
||||
if (specSelection === "specific" && selectedBuilds.length === 0) {
|
||||
setError("Select at least one class/spec for a specific-spec run.");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const job = await startBalanceLabJob({
|
||||
source,
|
||||
specSelection,
|
||||
selectedBuildIds: specSelection === "specific" ? selectedBuilds.map((build) => build.id) : [],
|
||||
level,
|
||||
partyConfigurations,
|
||||
partyConfigurations: specSelection === "random" ? partyConfigurations : partyCount,
|
||||
simulationsPerParty,
|
||||
durationSeconds,
|
||||
partySeed,
|
||||
@@ -242,11 +295,23 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
<span>{runtimeStatus.available ? runtimeStatus.version : `${runtimeStatus.error} Install Python 3 in the server container, set BALANCE_LAB_PYTHON=python3, and restart the app.`}</span>
|
||||
</div>}
|
||||
<div className="balance-lab-form-grid">
|
||||
<label><span>Party source</span><select value={source} onChange={(event) => setSource(event.currentTarget.value as BalanceLabPartySource)}>
|
||||
<label><span>Party source</span><select value={source} onChange={(event) => {
|
||||
setSource(event.currentTarget.value as BalanceLabPartySource);
|
||||
setSelectedBuildIds([]);
|
||||
setSpecSearch("");
|
||||
}}>
|
||||
<option value="all">WoW + CoA + RoM</option><option value="wow">WoW only</option><option value="coa">CoA only</option><option value="rom">RoM only</option>
|
||||
</select></label>
|
||||
<label><span>Level</span><input type="number" min={1} max={80} value={level} onChange={(event) => setLevel(event.currentTarget.valueAsNumber)} /></label>
|
||||
<label><span>Parties per source</span><input type="number" min={1} max={100} value={partyConfigurations} onChange={(event) => setPartyConfigurations(event.currentTarget.valueAsNumber)} /></label>
|
||||
<label><span>Level</span><input type="number" min={1} max={80} value={level} onChange={(event) => {
|
||||
setLevel(event.currentTarget.valueAsNumber);
|
||||
setSelectedBuildIds([]);
|
||||
}} /></label>
|
||||
<label className="balance-lab-form-grid__wide"><span>Class/spec selection</span><select value={specSelection} onChange={(event) => setSpecSelection(event.currentTarget.value as BalanceLabSpecSelection)}>
|
||||
<option value="random">Random parties</option><option value="all">All specs</option><option value="specific">Specific specs</option>
|
||||
</select></label>
|
||||
{specSelection === "random"
|
||||
? <label><span>Parties per source</span><input type="number" min={1} max={100} value={partyConfigurations} onChange={(event) => setPartyConfigurations(event.currentTarget.valueAsNumber)} /></label>
|
||||
: <label><span>Coverage parties</span><input type="number" readOnly value={partyCount} /></label>}
|
||||
<label><span>Runs per party/boss</span><input type="number" min={1} max={100} value={simulationsPerParty} onChange={(event) => setSimulationsPerParty(event.currentTarget.valueAsNumber)} /></label>
|
||||
<label><span>Fight limit (seconds)</span><input type="number" min={5} max={600} value={durationSeconds} onChange={(event) => setDurationSeconds(event.currentTarget.valueAsNumber)} /></label>
|
||||
<label><span>Party seed</span><input type="number" value={partySeed} onChange={(event) => setPartySeed(event.currentTarget.valueAsNumber)} /></label>
|
||||
@@ -254,6 +319,25 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
<option value="all">All bosses</option><option value="wow">WoW bosses</option><option value="rom">RoM bosses</option><option value="specific">Specific selection</option>
|
||||
</select></label>
|
||||
</div>
|
||||
{specSelection !== "random" && <p className="balance-lab-coverage-note">
|
||||
{specSelection === "all"
|
||||
? `${coveredSpecCount} class/spec builds will be covered at least once across the minimum valid party set.`
|
||||
: `${coveredSpecCount} selected class/spec builds will be covered; unselected specs fill required party roles.`}
|
||||
</p>}
|
||||
{specSelection === "specific" && (
|
||||
<section className="balance-lab-boss-picker balance-lab-spec-picker">
|
||||
<header>
|
||||
<input aria-label="Search Balance Lab class specs" value={specSearch} onChange={(event) => setSpecSearch(event.currentTarget.value)} placeholder="Search class, spec, role, or source" />
|
||||
<span className="balance-lab-picker-actions"><button type="button" onClick={() => setSelectedBuildIds((current) => [...new Set([...current, ...filteredBuilds.map((build) => build.id)])])}>Select visible</button><button type="button" onClick={() => setSelectedBuildIds([])}>Clear</button><strong>{selectedBuilds.length} selected</strong></span>
|
||||
</header>
|
||||
<div role="listbox" aria-label="Balance Lab class specs" aria-multiselectable="true">
|
||||
{filteredBuilds.map((build) => <label key={build.id} role="option" aria-selected={selectedBuildSet.has(build.id)} className={selectedBuildSet.has(build.id) ? "is-selected" : ""}>
|
||||
<input type="checkbox" checked={selectedBuildSet.has(build.id)} onChange={() => toggleBuild(build.id)} />
|
||||
<span><strong>{classById(build.classId).name} · {build.orientation}</strong><small>{build.source.toUpperCase()} · {build.role}</small></span>
|
||||
</label>)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{bossScope === "specific" && (
|
||||
<section className="balance-lab-boss-picker">
|
||||
<header><input aria-label="Search Balance Lab bosses" value={bossSearch} onChange={(event) => setBossSearch(event.currentTarget.value)} placeholder="Search boss, dungeon, or key" /><strong>{selectedBossKeys.length} selected</strong></header>
|
||||
@@ -265,7 +349,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<div className="balance-lab-estimate"><span>Party configurations <b>{partyCount.toLocaleString()}</b></span><span>Bosses <b>{bossCount.toLocaleString()}</b></span><span>Total simulations <b>{estimatedRuns.toLocaleString()}</b></span></div>
|
||||
<div className="balance-lab-estimate"><span>Party configurations <b>{partyCount.toLocaleString()}</b></span><span>Class/spec coverage <b>{specSelection === "random" ? "Random" : coveredSpecCount.toLocaleString()}</b></span><span>Bosses <b>{bossCount.toLocaleString()}</b></span><span>Total simulations <b>{estimatedRuns.toLocaleString()}</b></span></div>
|
||||
<button className="front-button front-button--primary balance-lab-run" type="button" disabled={submitting || !runtimeStatus?.available || !Number.isFinite(estimatedRuns) || estimatedRuns < 1} onClick={() => void run()}>{submitting ? "Queuing…" : runtimeStatus && !runtimeStatus.available ? "Server runtime unavailable" : "Run on server"}</button>
|
||||
<p className="balance-lab-local-note">To spend this device's CPU instead, run <code>npm run balance:lab</code> from the project checkout.</p>
|
||||
{error && <p className="front-notice" role="alert">{error}</p>}
|
||||
@@ -279,7 +363,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
<div className="balance-lab-history-filters"><input type="search" aria-label="Search Balance Lab run history" placeholder="Find source, level, GM…" value={jobSearch} onChange={(event) => setJobSearch(event.currentTarget.value)} /><select aria-label="Filter Balance Lab run status" value={jobStatus} onChange={(event) => setJobStatus(event.currentTarget.value as "all" | BalanceLabJob["status"])}><option value="all">All statuses</option><option value="complete">Complete</option><option value="running">Running</option><option value="queued">Queued</option><option value="failed">Failed</option></select></div>
|
||||
<div className="balance-lab-job-list">
|
||||
{visibleJobs.map((job) => <button key={job.id} type="button" className={job.id === selectedJob?.id ? "is-selected" : ""} onClick={() => setSelectedJobId(job.id)}>
|
||||
<span><strong>{job.spec.source.toUpperCase()} level {job.spec.level}</strong><small>{new Date(job.createdAt).toLocaleString()} · {job.requestedBy}</small>{job.summary && <small>{job.summary.bosses.toLocaleString()} bosses · {job.summary.runs.toLocaleString()} simulations</small>}</span><em className={`is-${job.status}`}>{job.status}</em>
|
||||
<span><strong>{job.spec.source.toUpperCase()} level {job.spec.level}</strong><small>{new Date(job.createdAt).toLocaleString()} · {job.requestedBy} · {job.spec.specSelection ?? "random"} specs</small>{job.summary && <small>{job.summary.bosses.toLocaleString()} bosses · {job.summary.runs.toLocaleString()} simulations</small>}</span><em className={`is-${job.status}`}>{job.status}</em>
|
||||
</button>)}
|
||||
{!loading && visibleJobs.length === 0 && <p className="balance-lab-empty">No saved runs match these filters.</p>}
|
||||
</div>
|
||||
@@ -291,7 +375,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
<p>{describeJob(selectedJob)}</p>
|
||||
<div className="balance-lab-progress" aria-label={`${progressPercent(selectedJob).toFixed(0)} percent complete`}><span style={{ width: `${progressPercent(selectedJob)}%` }} /></div>
|
||||
<dl>
|
||||
<div><dt>Status</dt><dd>{selectedJob.status}</dd></div><div><dt>Parties</dt><dd>{selectedJob.progress.partiesTotal}</dd></div><div><dt>Runs per pairing</dt><dd>{selectedJob.spec.simulationsPerParty}</dd></div><div><dt>Boss filter</dt><dd>{selectedJob.spec.bossKeys.length ? `${selectedJob.spec.bossKeys.length} selected` : selectedJob.spec.bossCategory}</dd></div><div><dt>Fight limit</dt><dd>{selectedJob.spec.durationSeconds}s</dd></div>
|
||||
<div><dt>Status</dt><dd>{selectedJob.status}</dd></div><div><dt>Parties</dt><dd>{selectedJob.progress.partiesTotal}</dd></div><div><dt>Spec selection</dt><dd>{selectedJob.spec.specSelection ?? "random"}{selectedJob.spec.specSelection === "specific" ? ` · ${selectedJob.spec.selectedBuildIds?.length ?? 0} selected` : ""}</dd></div><div><dt>Runs per pairing</dt><dd>{selectedJob.spec.simulationsPerParty}</dd></div><div><dt>Boss filter</dt><dd>{selectedJob.spec.bossKeys.length ? `${selectedJob.spec.bossKeys.length} selected` : selectedJob.spec.bossCategory}</dd></div><div><dt>Fight limit</dt><dd>{selectedJob.spec.durationSeconds}s</dd></div>
|
||||
</dl>
|
||||
{selectedJob.status === "complete" && <div className="balance-lab-artifacts"><button type="button" className="front-button front-button--primary" onClick={() => document.getElementById("balance-lab-analysis")?.scrollIntoView({ behavior: "smooth", block: "start" })}>View graphs and filterable data</button>{selectedJob.artifacts.map((name) => <button key={name} type="button" className="front-button" onClick={() => void openArtifact(selectedJob, name)}>{ARTIFACT_LABELS[name] ?? name}</button>)}</div>}
|
||||
{selectedJob.logTail.length > 0 && <details className="balance-lab-log"><summary>Server progress log</summary><pre>{selectedJob.logTail.slice(-20).join("\n")}</pre></details>}
|
||||
|
||||
Reference in new issue
Block a user