diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index 6ffe30e0..da21ef6c 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -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`. diff --git a/docs/BALANCE_LAB.md b/docs/BALANCE_LAB.md index bba03369..cdc8d4c3 100644 --- a/docs/BALANCE_LAB.md +++ b/docs/BALANCE_LAB.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 5e893a3e..bb37978e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 557aec0d..969654b2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/release-version.json b/release-version.json index 3de517b7..181b2cbf 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.50", - "versionCode": 1050 + "versionName": "0.1.51", + "versionCode": 1051 } diff --git a/scripts/balance_lab.py b/scripts/balance_lab.py index c7684429..1843f58d 100644 --- a/scripts/balance_lab.py +++ b/scripts/balance_lab.py @@ -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, diff --git a/scripts/playtest-balance-lab.cjs b/scripts/playtest-balance-lab.cjs index 95688038..537c99a1 100644 --- a/scripts/playtest-balance-lab.cjs +++ b/scripts/playtest-balance-lab.cjs @@ -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'); diff --git a/scripts/test_balance_lab.py b/scripts/test_balance_lab.py index f5e31a4d..9763a353 100644 --- a/scripts/test_balance_lab.py +++ b/scripts/test_balance_lab.py @@ -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"}, diff --git a/server/balance-lab-jobs.mjs b/server/balance-lab-jobs.mjs index 39578ec6..b2fcd0c8 100644 --- a/server/balance-lab-jobs.mjs +++ b/server/balance-lab-jobs.mjs @@ -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", diff --git a/server/balance-lab-jobs.test.mjs b/server/balance-lab-jobs.test.mjs index 29b43c94..09ef7b00 100644 --- a/server/balance-lab-jobs.test.mjs +++ b/server/balance-lab-jobs.test.mjs @@ -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")); }); diff --git a/src/app/balanceLabClient.ts b/src/app/balanceLabClient.ts index 7cfdcea2..d63776c9 100644 --- a/src/app/balanceLabClient.ts +++ b/src/app/balanceLabClient.ts @@ -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; diff --git a/src/styles.css b/src/styles.css index 36bfcdd5..97b2d36c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/src/ui/BalanceLabScreen.tsx b/src/ui/BalanceLabScreen.tsx index b87023d4..83efd95d 100644 --- a/src/ui/BalanceLabScreen.tsx +++ b/src/ui/BalanceLabScreen.tsx @@ -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(); + 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("all"); + const [specSelection, setSpecSelection] = useState("random"); + const [specSearch, setSpecSearch] = useState(""); + const [selectedBuildIds, setSelectedBuildIds] = useState([]); 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 }) {runtimeStatus.available ? runtimeStatus.version : `${runtimeStatus.error} Install Python 3 in the server container, set BALANCE_LAB_PYTHON=python3, and restart the app.`} }
- - - + + + {specSelection === "random" + ? + : } @@ -254,6 +319,25 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
+ {specSelection !== "random" &&

+ {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.`} +

} + {specSelection === "specific" && ( +
+
+ setSpecSearch(event.currentTarget.value)} placeholder="Search class, spec, role, or source" /> + {selectedBuilds.length} selected +
+
+ {filteredBuilds.map((build) => )} +
+
+ )} {bossScope === "specific" && (
setBossSearch(event.currentTarget.value)} placeholder="Search boss, dungeon, or key" />{selectedBossKeys.length} selected
@@ -265,7 +349,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
)} -
Party configurations {partyCount.toLocaleString()}Bosses {bossCount.toLocaleString()}Total simulations {estimatedRuns.toLocaleString()}
+
Party configurations {partyCount.toLocaleString()}Class/spec coverage {specSelection === "random" ? "Random" : coveredSpecCount.toLocaleString()}Bosses {bossCount.toLocaleString()}Total simulations {estimatedRuns.toLocaleString()}

To spend this device's CPU instead, run npm run balance:lab from the project checkout.

{error &&

{error}

} @@ -279,7 +363,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
setJobSearch(event.currentTarget.value)} />
{visibleJobs.map((job) => )} {!loading && visibleJobs.length === 0 &&

No saved runs match these filters.

}
@@ -291,7 +375,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })

{describeJob(selectedJob)}

-
Status
{selectedJob.status}
Parties
{selectedJob.progress.partiesTotal}
Runs per pairing
{selectedJob.spec.simulationsPerParty}
Boss filter
{selectedJob.spec.bossKeys.length ? `${selectedJob.spec.bossKeys.length} selected` : selectedJob.spec.bossCategory}
Fight limit
{selectedJob.spec.durationSeconds}s
+
Status
{selectedJob.status}
Parties
{selectedJob.progress.partiesTotal}
Spec selection
{selectedJob.spec.specSelection ?? "random"}{selectedJob.spec.specSelection === "specific" ? ` · ${selectedJob.spec.selectedBuildIds?.length ?? 0} selected` : ""}
Runs per pairing
{selectedJob.spec.simulationsPerParty}
Boss filter
{selectedJob.spec.bossKeys.length ? `${selectedJob.spec.bossKeys.length} selected` : selectedJob.spec.bossCategory}
Fight limit
{selectedJob.spec.durationSeconds}s
{selectedJob.status === "complete" &&
{selectedJob.artifacts.map((name) => )}
} {selectedJob.logTail.length > 0 &&
Server progress log
{selectedJob.logTail.slice(-20).join("\n")}
}