518 lines
33 KiB
TypeScript
518 lines
33 KiB
TypeScript
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<Record<string, string>> = {
|
|
"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<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);
|
|
}
|
|
|
|
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<string, SelectableBenchmarkBuildCatalogEntry[]>();
|
|
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<BalanceLabPartySource>("wow");
|
|
const [specSelection, setSpecSelection] = useState<BalanceLabSpecSelection>("random");
|
|
const [specSearch, setSpecSearch] = useState("");
|
|
const [selectedBuildIds, setSelectedBuildIds] = useState<readonly string[]>([]);
|
|
const [level, setLevel] = useState(60);
|
|
const [levelCoverage, setLevelCoverage] = useState<LevelCoverage>("single");
|
|
const [bracketStep, setBracketStep] = useState<BalanceLabBracketStep>(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<BossScope>("all");
|
|
const [bossSearch, setBossSearch] = useState("");
|
|
const [selectedBossKeys, setSelectedBossKeys] = useState<readonly string[]>([]);
|
|
const [jobs, setJobs] = useState<readonly BalanceLabJob[]>([]);
|
|
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
|
const [jobSearch, setJobSearch] = useState("");
|
|
const [jobStatus, setJobStatus] = useState<"all" | BalanceLabJob["status"]>("all");
|
|
const [reports, setReports] = useState<Readonly<Record<string, BalanceLabSuiteReport>>>({});
|
|
const [reportLoadingId, setReportLoadingId] = useState<string | null>(null);
|
|
const [reportError, setReportError] = useState("");
|
|
const [runtimeStatus, setRuntimeStatus] = useState<BalanceLabRuntimeStatus | null>(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 (
|
|
<FrontSurface className="gm-admin-screen" ariaLabel="Balance Lab access denied">
|
|
<section className="gm-admin-denied">
|
|
<BrandMark />
|
|
<h1>Game Master access required</h1>
|
|
<p>Server simulations are available only to server-authorized GM accounts.</p>
|
|
<button className="front-button" type="button" onClick={onClose}>Back to main menu</button>
|
|
</section>
|
|
</FrontSurface>
|
|
);
|
|
}
|
|
if (readinessOpen) return <CompanionReadinessScreen onClose={() => 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 (
|
|
<FrontSurface className="balance-lab-screen" ariaLabel={local ? "Local Balance Lab" : "GM Balance Lab"}>
|
|
<header className="gm-admin-header">
|
|
<BrandMark compact />
|
|
<div><span>{local ? "Local development tool" : "Server-authorized GM tool"}</span><strong>Balance Lab</strong></div>
|
|
<div className="gm-admin-header__actions">
|
|
{!local && <button className="front-button" type="button" onClick={onClose}>Main menu</button>}
|
|
</div>
|
|
</header>
|
|
<main className="balance-lab-layout">
|
|
<section className="balance-lab-config">
|
|
<p className="front-kicker">New automatic sweep</p>
|
|
<h2>Simulation setup</h2>
|
|
<p className="balance-lab-help">{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."}</p>
|
|
<CompanionPoolControls onOpenReadiness={() => setReadinessOpen(true)} />
|
|
{runtimeStatus && <div className={`balance-lab-runtime ${runtimeStatus.available ? "is-ready" : "is-unavailable"}`} role={runtimeStatus.available ? undefined : "alert"}>
|
|
<strong>{runtimeStatus.available ? `${local ? "Local" : "Server"} runtime ready` : `${local ? "Local" : "Server"} runtime unavailable`}</strong>
|
|
<span>{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.`}</span>
|
|
</div>}
|
|
<div className="balance-lab-form-grid">
|
|
<label><span>Party source</span><select value={source} onChange={(event) => {
|
|
setSource(event.currentTarget.value as BalanceLabPartySource);
|
|
setSelectedBuildIds([]);
|
|
setSpecSearch("");
|
|
}}>
|
|
<option value="all">All sources (Ready only)</option><option value="wow">WoW only</option><option value="coa">CoA only (Needs Testing)</option><option value="rom">RoM only (Needs Testing)</option>
|
|
</select></label>
|
|
<label><span>Level coverage</span><select value={levelCoverage} onChange={(event) => {
|
|
setLevelCoverage(event.currentTarget.value as LevelCoverage);
|
|
setSelectedBuildIds([]);
|
|
}}><option value="single">Single level</option><option value="brackets">Run all level brackets</option></select></label>
|
|
{levelCoverage === "single"
|
|
? <label><span>Level</span><input type="number" min={1} max={80} value={level} onChange={(event) => {
|
|
setLevel(event.currentTarget.valueAsNumber);
|
|
setSelectedBuildIds([]);
|
|
}} /></label>
|
|
: <label><span>Bracket spacing</span><select value={bracketStep} onChange={(event) => {
|
|
setBracketStep(Number(event.currentTarget.value) as BalanceLabBracketStep);
|
|
setSelectedBuildIds([]);
|
|
}}><option value={10}>Every 10 levels</option><option value={5}>Every 5 levels</option><option value={1}>Every 1 level</option></select></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 Ready parties</option><option value="all">All Ready specs</option><option value="specific">Specific targeted 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>
|
|
<label className="balance-lab-form-grid__wide"><input type="checkbox" checked={seatSweep} onChange={(event) => setSeatSweep(event.currentTarget.checked)} /><span>NPC seat audit: run each party with every member active once</span></label>
|
|
<label className="balance-lab-form-grid__wide"><span>Bosses</span><select value={bossScope} onChange={(event) => setBossScope(event.currentTarget.value as BossScope)}>
|
|
<option value="all">Every boss</option><option value="wow">Every WoW boss</option><option value="rom">Every RoM boss</option><option value="specific">Pick specific bosses</option>
|
|
</select></label>
|
|
</div>
|
|
<p className="balance-lab-coverage-note"><strong>Certification scope: Ready only.</strong> 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.</p>
|
|
{levelCoverage === "brackets" && <p className="balance-lab-coverage-note">
|
|
{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.
|
|
</p>}
|
|
{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 === "random" && partyCount < requestedRandomPartyCount && <p className="balance-lab-coverage-note">
|
|
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()}.
|
|
</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}{build.readinessStatus ? ` · ${readinessPolicy.statuses[build.specializationId ?? ""] ?? build.readinessStatus}` : ""}{build.talentBuildName ? ` · ${build.talentBuildName}` : ""}{build.characterName ? ` · ${build.characterName}` : ""}</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>
|
|
<div role="listbox" aria-label="Balance Lab bosses" aria-multiselectable="true">
|
|
{filteredBosses.map((boss) => <label key={boss.key} role="option" aria-selected={selectedBossSet.has(boss.key)} className={selectedBossSet.has(boss.key) ? "is-selected" : ""}>
|
|
<input type="checkbox" checked={selectedBossSet.has(boss.key)} onChange={() => toggleBoss(boss.key)} />
|
|
<span><strong>{boss.name}</strong><small>{boss.dungeonTitle} · {boss.category.toUpperCase()}</small></span>
|
|
</label>)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
<div className="balance-lab-estimate"><span>Level jobs <b>{targetLevels.length.toLocaleString()}</b></span><span>Parties / level <b>{partyCount.toLocaleString()}</b></span><span>Bosses / level <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 ${targetLevels.length} job${targetLevels.length === 1 ? "" : "s"}…` : runtimeStatus && !runtimeStatus.available ? `${local ? "Local" : "Server"} runtime unavailable` : targetLevels.length === 1 ? `Run ${local ? "locally" : "on server"}` : `Queue ${targetLevels.length} ${local ? "local " : ""}level jobs`}</button>
|
|
<p className="balance-lab-local-note">{local
|
|
? "Reports and run history are stored under artifacts/balance-lab-gui-runtime."
|
|
: <>To open the local GUI instead, run <code>npm run balance:gui</code> or double-click <code>scripts/Launch Balance Lab GUI.cmd</code>.</>}</p>
|
|
{error && <p className="front-notice" role="alert">{error}</p>}
|
|
</section>
|
|
|
|
<section className="balance-lab-jobs">
|
|
<header><div><p className="front-kicker">Saved {local ? "on this computer" : "on server"}</p><h2>Run history</h2></div><button type="button" disabled={loading} onClick={() => {
|
|
setLoading(true);
|
|
void Promise.all([listBalanceLabJobs(token, executionTarget), getBalanceLabRuntime(token, executionTarget)]).then(([nextJobs, runtime]) => { setJobs(nextJobs); setRuntimeStatus(runtime); }).catch((reason) => setError(errorMessage(reason))).finally(() => setLoading(false));
|
|
}}>Refresh</button></header>
|
|
<div className="balance-lab-history-filters"><input type="search" aria-label="Search Balance Lab run history" placeholder={`Find source, level, ${local ? "run" : "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} · {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>
|
|
</section>
|
|
|
|
<aside className="balance-lab-detail">
|
|
{selectedJob ? <>
|
|
<p className="front-kicker">Selected job</p><h2>{selectedJob.spec.source.toUpperCase()} level {selectedJob.spec.level}</h2>
|
|
<p>{describeJob(selectedJob, local)}</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>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>{local ? "Local" : "Server"} progress log</summary><pre>{selectedJob.logTail.slice(-20).join("\n")}</pre></details>}
|
|
</> : <p className="balance-lab-empty">Select a job to inspect its progress and reports.</p>}
|
|
</aside>
|
|
{selectedJob?.status === "complete" && <div className="balance-lab-analysis" id="balance-lab-analysis">
|
|
{reportLoadingId === selectedJob.id && <p className="balance-results-loading">Loading this run's complete analysis…</p>}
|
|
{reportError && !selectedReport && <p className="front-notice" role="alert">{reportError}</p>}
|
|
{selectedReport && <BalanceLabResults report={selectedReport} />}
|
|
</div>}
|
|
</main>
|
|
</FrontSurface>
|
|
);
|
|
}
|