import { useEffect, useMemo, useState } from "react"; import { sessionCanUseGmTools } from "../app/releaseFeatures"; import { useShellStore } from "../app/shellStore"; import { fetchBalanceLabArtifact, getBalanceLabReport, getBalanceLabRuntime, listBalanceLabJobs, startBalanceLabJob, startBalanceLabJobs, type BalanceLabBossCategory, type BalanceLabExecutionTarget, type BalanceLabJob, type BalanceLabPartySource, type BalanceLabRuntimeStatus, type BalanceLabSpecSelection, type BalanceLabSuiteReport, } from "../app/balanceLabClient"; import { balanceLabBracketLevels, type BalanceLabBracketStep, } from "../app/balanceLabSchedules"; 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"; import { CompanionPoolControls } from "./CompanionPoolControls"; import { CompanionReadinessScreen } from "./CompanionReadinessScreen"; import { useCompanionPartyPreferences } from "../game/companionPartyPreferences"; import type { CompanionSpecReadinessPolicy } from "../game/companionSpecReadiness"; import { useCompanionSpecReadinessStore } from "../game/companionSpecReadinessStore"; type BossScope = BalanceLabBossCategory | "specific"; type LevelCoverage = "single" | "brackets"; const ARTIFACT_LABELS: Readonly> = { "suite-report.html": "Open full report", "suite-report.json": "Download report JSON", "suite-report.md": "Download report Markdown", "party_configurations.csv": "Party configurations CSV", "class_specs_across_parties.csv": "Class/spec totals CSV", "class_abilities_across_parties.csv": "Class/spec ability breakdown CSV", "npc_class_specs_across_parties.csv": "NPC-only class/spec totals CSV", "npc_class_abilities_across_parties.csv": "NPC-only rotation and ability CSV", "class_spec_by_boss.csv": "Class/spec by boss CSV", "bosses_across_parties.csv": "Boss totals CSV", "boss_by_party.csv": "Boss by party CSV", "suite_runs.csv": "Every simulation CSV", "suite-config.json": "Exact rerun configuration", }; function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "The Balance Lab request failed."; } function progressPercent(job: BalanceLabJob): number { if (job.status === "complete") return 100; if (job.status === "queued" || job.progress.partiesTotal < 1) return 0; const currentFraction = job.progress.simulationsTotal > 0 ? job.progress.simulationsCompleted / job.progress.simulationsTotal : 0; return Math.max(0, Math.min(99, ((job.progress.partiesCompleted + currentFraction) / job.progress.partiesTotal) * 100)); } function describeJob(job: BalanceLabJob, local = false): string { if (job.status === "queued") return `Waiting for an available ${local ? "local" : "server"} worker.`; if (job.status === "failed") return job.error ?? `The ${local ? "local" : "server"} job failed.`; if (job.status === "complete") return job.summary ? `Completed ${job.summary.runs.toLocaleString()} simulations across ${job.summary.bosses.toLocaleString()} bosses and ${job.summary.partyConfigurations.toLocaleString()} party configurations.` : `Completed ${job.progress.partiesTotal} party configurations.`; const party = job.progress.party || job.progress.partiesCompleted + 1; const simulation = job.progress.simulationsTotal > 0 ? ` · current party ${job.progress.simulationsCompleted}/${job.progress.simulationsTotal} boss simulations` : ""; 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); } function buildIdentity(build: SelectableBenchmarkBuildCatalogEntry): string { return `${build.source}:${build.classId}:${build.role}:${build.orientation}:${build.talentBuildId ?? "default"}`; } function readinessEligible( build: SelectableBenchmarkBuildCatalogEntry, policy: CompanionSpecReadinessPolicy, includeBetaSpecs: boolean, ): boolean { const status = policy.statuses[build.specializationId ?? ""] ?? build.readinessStatus ?? "needs-testing"; return status === "ready" || (includeBetaSpecs && status === "beta"); } function randomPartyCapacity(builds: readonly SelectableBenchmarkBuildCatalogEntry[]): number { const defaults = builds.filter((build) => build.defaultTalentBuild); const pool = defaults.length > 0 ? defaults : builds; const tanks = pool.filter((build) => build.role === "tank").length; const healers = pool.filter((build) => build.role === "healer").length; const damage = pool.filter((build) => build.role === "damage").length; return damage < 3 ? 0 : tanks * healers * (damage * (damage - 1) * (damage - 2)) / 6; } function cappedRandomPartyCount( builds: readonly SelectableBenchmarkBuildCatalogEntry[], requestedPerSource: number, ): 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) => ( total + Math.min(requestedPerSource, randomPartyCapacity(rows)) ), 0); } export function BalanceLabScreen({ onClose, executionTarget = "server", }: { readonly onClose: () => void; readonly executionTarget?: BalanceLabExecutionTarget; }) { const session = useShellStore((state) => state.session); const local = executionTarget === "local"; const token = local ? "" : session?.accessToken ?? ""; const [source, setSource] = useState("wow"); const [specSelection, setSpecSelection] = useState("random"); const [specSearch, setSpecSearch] = useState(""); const [selectedBuildIds, setSelectedBuildIds] = useState([]); const [level, setLevel] = useState(60); const [levelCoverage, setLevelCoverage] = useState("single"); const [bracketStep, setBracketStep] = useState(10); const [partyConfigurations, setPartyConfigurations] = useState(10); const [simulationsPerParty, setSimulationsPerParty] = useState(3); const [durationSeconds, setDurationSeconds] = useState(60); const [seatSweep, setSeatSweep] = useState(false); const [partySeed, setPartySeed] = useState(1); const [bossScope, setBossScope] = useState("all"); const [bossSearch, setBossSearch] = useState(""); const [selectedBossKeys, setSelectedBossKeys] = useState([]); const [jobs, setJobs] = useState([]); const [selectedJobId, setSelectedJobId] = useState(null); const [jobSearch, setJobSearch] = useState(""); const [jobStatus, setJobStatus] = useState<"all" | BalanceLabJob["status"]>("all"); const [reports, setReports] = useState>>({}); const [reportLoadingId, setReportLoadingId] = useState(null); const [reportError, setReportError] = useState(""); const [runtimeStatus, setRuntimeStatus] = useState(null); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); const [readinessOpen, setReadinessOpen] = useState(false); const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs); const readinessPolicy = useCompanionSpecReadinessStore((state) => state.config); const selectedJob = jobs.find((job) => job.id === selectedJobId) ?? jobs[0] ?? null; const selectedReport = selectedJob ? reports[selectedJob.id] ?? null : null; const visibleJobs = useMemo(() => { const query = jobSearch.trim().toLowerCase(); return jobs.filter((job) => ( (jobStatus === "all" || job.status === jobStatus) && (!query || `${job.requestedBy} ${job.spec.source} ${job.spec.level} ${job.spec.specSelection ?? "random"} ${job.id} ${job.status}`.toLowerCase().includes(query)) )); }, [jobSearch, jobStatus, jobs]); const targetLevels = useMemo(() => levelCoverage === "single" ? [level] : balanceLabBracketLevels(bracketStep), [bracketStep, level, levelCoverage]); const catalogLevel = levelCoverage === "single" && Number.isFinite(level) ? level : 80; const availableBuilds = useMemo(() => selectableBenchmarkBuildCatalog( catalogLevel, ).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) )), [catalogLevel, source]); const selectedBuildSet = useMemo(() => new Set(selectedBuildIds), [selectedBuildIds]); const automaticBuilds = useMemo(() => availableBuilds.filter((build) => ( readinessEligible(build, readinessPolicy, includeBetaSpecs) )), [availableBuilds, includeBetaSpecs, readinessPolicy]); 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 || `${boss.name} ${boss.dungeonTitle} ${boss.key} ${boss.category}`.toLowerCase().includes(query)); }, [bossSearch]); const selectedBossSet = useMemo(() => new Set(selectedBossKeys), [selectedBossKeys]); const bossCount = bossScope === "specific" ? selectedBossKeys.length : BOSS_TESTING_CATALOG.filter((boss) => bossScope === "all" || boss.category === bossScope).length; const coverageBuilds = specSelection === "all" ? automaticBuilds : selectedBuilds; const eligibleSourceCount = new Set(automaticBuilds.map((build) => build.source)).size; const requestedRandomPartyCount = partyConfigurations * eligibleSourceCount; const partyCount = specSelection === "random" ? cappedRandomPartyCount(automaticBuilds, partyConfigurations) : coveragePartyCount(coverageBuilds); const coveredSpecCount = specSelection === "all" ? automaticBuilds.length : selectedBuilds.length; const estimatedRunsPerLevel = partyCount * (seatSweep ? 5 : 1) * bossCount * simulationsPerParty; const estimatedRuns = estimatedRunsPerLevel * targetLevels.length; const hasPendingJobs = jobs.some((job) => job.status === "queued" || job.status === "running"); useEffect(() => { if (!local && !token) return; let cancelled = false; setLoading(true); void Promise.all([ listBalanceLabJobs(token, executionTarget), getBalanceLabRuntime(token, executionTarget), ]).then(([nextJobs, runtime]) => { if (cancelled) return; setJobs(nextJobs); setRuntimeStatus(runtime); setSelectedJobId((current) => current ?? nextJobs[0]?.id ?? null); setError(""); }).catch((reason) => { if (!cancelled) setError(errorMessage(reason)); }).finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [executionTarget, local, token]); useEffect(() => { if ((!local && !token) || !hasPendingJobs) return; let cancelled = false; const timer = window.setInterval(() => { void Promise.all([ listBalanceLabJobs(token, executionTarget), getBalanceLabRuntime(token, executionTarget), ]).then(([nextJobs, runtime]) => { if (cancelled) return; setJobs(nextJobs); setRuntimeStatus(runtime); }).catch((reason) => { if (!cancelled) setError(errorMessage(reason)); }); }, 1_500); return () => { cancelled = true; window.clearInterval(timer); }; }, [executionTarget, hasPendingJobs, local, token]); useEffect(() => { if ((!local && !token) || !selectedJob || selectedJob.status !== "complete" || selectedReport || !selectedJob.artifacts.includes("suite-report.json")) return; let cancelled = false; setReportLoadingId(selectedJob.id); setReportError(""); void getBalanceLabReport(selectedJob.id, token, executionTarget).then((report) => { if (!cancelled) setReports((current) => ({ ...current, [selectedJob.id]: report })); }).catch((reason) => { if (!cancelled) setReportError(errorMessage(reason)); }).finally(() => { if (!cancelled) setReportLoadingId((current) => current === selectedJob.id ? null : current); }); return () => { cancelled = true; }; }, [executionTarget, local, selectedJob, selectedReport, token]); if (!local && !sessionCanUseGmTools(session)) { return (

Game Master access required

Server simulations are available only to server-authorized GM accounts.

); } if (readinessOpen) return setReadinessOpen(false)} />; const toggleBoss = (key: string) => { setSelectedBossKeys((current) => current.includes(key) ? current.filter((candidate) => candidate !== key) : [...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 ${local ? "local" : "server"} Balance Lab runtime is unavailable.`); return; } if (bossScope === "specific" && selectedBossKeys.length === 0) { 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 selectedIdentities = new Set(selectedBuilds.map(buildIdentity)); const specs = targetLevels.map((targetLevel) => { const levelBuilds = selectableBenchmarkBuildCatalog(targetLevel) .filter((build) => source === "all" || build.source === source); const chosenBuilds = specSelection === "specific" ? levelBuilds.filter((build) => selectedIdentities.has(buildIdentity(build))) : specSelection === "all" ? levelBuilds.filter((build) => readinessEligible(build, readinessPolicy, includeBetaSpecs)) : []; if (specSelection === "specific" && chosenBuilds.length !== selectedIdentities.size) { throw new Error(`One or more selected class/spec builds are unavailable at level ${targetLevel}.`); } return { source, specSelection, selectedBuildIds: specSelection === "specific" ? chosenBuilds.map((build) => build.id) : [], level: targetLevel, partyConfigurations: specSelection === "random" ? partyConfigurations : coveragePartyCount(chosenBuilds), simulationsPerParty, durationSeconds, partySeed, bossCategory: bossScope === "specific" ? "all" as const : bossScope, bossKeys: bossScope === "specific" ? selectedBossKeys : [], seatSweep, includeBetaSpecs, }; }); const submittedJobs = specs.length === 1 ? [await startBalanceLabJob(specs[0], token, executionTarget)] : await startBalanceLabJobs(specs, token, executionTarget); const submittedIds = new Set(submittedJobs.map((job) => job.id)); setJobs((current) => [...submittedJobs, ...current.filter((job) => !submittedIds.has(job.id))]); setSelectedJobId(submittedJobs[0]?.id ?? null); } catch (reason) { setError(errorMessage(reason)); } finally { setSubmitting(false); } }; const openArtifact = async (job: BalanceLabJob, name: string) => { const preview = name.endsWith(".html") ? window.open("", "_blank") : null; setError(""); try { const blob = await fetchBalanceLabArtifact(job.id, name, token, executionTarget); const url = URL.createObjectURL(blob); if (preview) preview.location.href = url; else { const anchor = document.createElement("a"); anchor.href = url; anchor.download = name; anchor.click(); } window.setTimeout(() => URL.revokeObjectURL(url), 60_000); } catch (reason) { preview?.close(); setError(errorMessage(reason)); } }; return (
{local ? "Local development tool" : "Server-authorized GM tool"}Balance Lab
{!local && }

New automatic sweep

Simulation setup

{local ? "This mode runs simulations on this computer. The browser may be closed, but keep the local launcher open while jobs are running." : "This mode runs on the game server. The browser submits the job and may be closed while the server continues."}

setReadinessOpen(true)} /> {runtimeStatus &&
{runtimeStatus.available ? `${local ? "Local" : "Server"} runtime ready` : `${local ? "Local" : "Server"} runtime unavailable`} {runtimeStatus.available ? `${runtimeStatus.version} · ${runtimeStatus.running}/${runtimeStatus.maxConcurrent} workers active · ${runtimeStatus.queued} queued` : local ? `${runtimeStatus.error} Install Python 3 or set BALANCE_LAB_PYTHON, then restart the local GUI.` : `${runtimeStatus.error} Install Python 3 in the server container, set BALANCE_LAB_PYTHON=python3, and restart the app.`}
}
{levelCoverage === "single" ? : } {specSelection === "random" ? : }

Certification scope: Ready only. Automatic, all-spec, bracket, regression, and seat-audit runs currently use Protection Warrior, Discipline Priest, Marksmanship Hunter, Fire Mage, and Assassination Rogue. CoA and RoM remain Needs Testing.

{levelCoverage === "brackets" &&

{targetLevels.length} separate level jobs: {bracketStep === 1 ? "levels 1 through 80" : targetLevels.join(", ")}. Up to {runtimeStatus?.maxConcurrent ?? 4} run at the same time; each keeps its own report.

} {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 === "random" && partyCount < requestedRandomPartyCount &&

The eligible pools provide {partyCount.toLocaleString()} unique party configuration{partyCount === 1 ? "" : "s"}. Each will run once; identical parties will not be duplicated to reach the requested {requestedRandomPartyCount.toLocaleString()}.

} {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
{filteredBosses.map((boss) => )}
)}
Level jobs {targetLevels.length.toLocaleString()}Parties / level {partyCount.toLocaleString()}Bosses / level {bossCount.toLocaleString()}Total simulations {estimatedRuns.toLocaleString()}

{local ? "Reports and run history are stored under artifacts/balance-lab-gui-runtime." : <>To open the local GUI instead, run npm run balance:gui or double-click scripts/Launch Balance Lab GUI.cmd.}

{error &&

{error}

}

Saved {local ? "on this computer" : "on server"}

Run history

setJobSearch(event.currentTarget.value)} />
{visibleJobs.map((job) => )} {!loading && visibleJobs.length === 0 &&

No saved runs match these filters.

}
{selectedJob?.status === "complete" &&
{reportLoadingId === selectedJob.id &&

Loading this run's complete analysis…

} {reportError && !selectedReport &&

{reportError}

} {selectedReport && }
}
); }