Update Healer Man server

This commit is contained in:
phenom committed 2026-09-19 21:01:51 -04:00
1 parent f8314e7de1
commit 1fedb9368d
76 files changed
+20556 -2523

No files matched your search

+77 -37
View File
@@ -9,6 +9,7 @@ import {
startBalanceLabJob,
startBalanceLabJobs,
type BalanceLabBossCategory,
type BalanceLabExecutionTarget,
type BalanceLabJob,
type BalanceLabPartySource,
type BalanceLabRuntimeStatus,
@@ -27,6 +28,10 @@ import {
} 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 { useCompanionSpecReadinessStore } from "../game/companionSpecReadinessStore";
type BossScope = BalanceLabBossCategory | "specific";
type LevelCoverage = "single" | "brackets";
@@ -38,6 +43,8 @@ const ARTIFACT_LABELS: Readonly<Record<string, string>> = {
"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",
@@ -58,9 +65,9 @@ function progressPercent(job: BalanceLabJob): number {
return Math.max(0, Math.min(99, ((job.progress.partiesCompleted + currentFraction) / job.progress.partiesTotal) * 100));
}
function describeJob(job: BalanceLabJob): string {
if (job.status === "queued") return "Waiting for an available server worker.";
if (job.status === "failed") return job.error ?? "The server job failed.";
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.`;
@@ -83,12 +90,19 @@ function coveragePartyCount(builds: readonly SelectableBenchmarkBuildCatalogEntr
}
function buildIdentity(build: SelectableBenchmarkBuildCatalogEntry): string {
return `${build.source}:${build.classId}:${build.role}:${build.orientation}`;
return `${build.source}:${build.classId}:${build.role}:${build.orientation}:${build.talentBuildId ?? "default"}`;
}
export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) {
export function BalanceLabScreen({
onClose,
executionTarget = "server",
}: {
readonly onClose: () => void;
readonly executionTarget?: BalanceLabExecutionTarget;
}) {
const session = useShellStore((state) => state.session);
const token = session?.accessToken ?? "";
const local = executionTarget === "local";
const token = local ? "" : session?.accessToken ?? "";
const [source, setSource] = useState<BalanceLabPartySource>("all");
const [specSelection, setSpecSelection] = useState<BalanceLabSpecSelection>("random");
const [specSearch, setSpecSearch] = useState("");
@@ -99,6 +113,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
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("");
@@ -114,6 +129,9 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
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;
@@ -137,6 +155,11 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|| left.role.localeCompare(right.role)
)), [catalogLevel, source]);
const selectedBuildSet = useMemo(() => new Set(selectedBuildIds), [selectedBuildIds]);
const automaticBuilds = useMemo(() => availableBuilds.filter((build) => {
if (build.source !== "wow" || !build.specializationId) return true;
const status = readinessPolicy.statuses[build.specializationId] ?? "needs-testing";
return status === "ready" || (includeBetaSpecs && status === "beta");
}), [availableBuilds, includeBetaSpecs, readinessPolicy]);
const selectedBuilds = useMemo(() => availableBuilds.filter((build) => selectedBuildSet.has(build.id)), [
availableBuilds,
selectedBuildSet,
@@ -154,20 +177,23 @@ 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 coverageBuilds = specSelection === "all" ? availableBuilds : selectedBuilds;
const coverageBuilds = specSelection === "all" ? automaticBuilds : selectedBuilds;
const partyCount = specSelection === "random"
? partyConfigurations * (source === "all" ? 3 : 1)
: coveragePartyCount(coverageBuilds);
const coveredSpecCount = specSelection === "all" ? availableBuilds.length : selectedBuilds.length;
const estimatedRunsPerLevel = partyCount * bossCount * simulationsPerParty;
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 (!token) return;
if (!local && !token) return;
let cancelled = false;
setLoading(true);
void Promise.all([listBalanceLabJobs(token), getBalanceLabRuntime(token)]).then(([nextJobs, runtime]) => {
void Promise.all([
listBalanceLabJobs(token, executionTarget),
getBalanceLabRuntime(token, executionTarget),
]).then(([nextJobs, runtime]) => {
if (cancelled) return;
setJobs(nextJobs);
setRuntimeStatus(runtime);
@@ -179,13 +205,16 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [token]);
}, [executionTarget, local, token]);
useEffect(() => {
if (!token || !hasPendingJobs) return;
if ((!local && !token) || !hasPendingJobs) return;
let cancelled = false;
const timer = window.setInterval(() => {
void Promise.all([listBalanceLabJobs(token), getBalanceLabRuntime(token)]).then(([nextJobs, runtime]) => {
void Promise.all([
listBalanceLabJobs(token, executionTarget),
getBalanceLabRuntime(token, executionTarget),
]).then(([nextJobs, runtime]) => {
if (cancelled) return;
setJobs(nextJobs);
setRuntimeStatus(runtime);
@@ -194,15 +223,15 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
});
}, 1_500);
return () => { cancelled = true; window.clearInterval(timer); };
}, [hasPendingJobs, token]);
}, [executionTarget, hasPendingJobs, local, token]);
useEffect(() => {
if (!token || !selectedJob || selectedJob.status !== "complete" || selectedReport
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).then((report) => {
void getBalanceLabReport(selectedJob.id, token, executionTarget).then((report) => {
if (!cancelled) setReports((current) => ({ ...current, [selectedJob.id]: report }));
}).catch((reason) => {
if (!cancelled) setReportError(errorMessage(reason));
@@ -210,9 +239,9 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
if (!cancelled) setReportLoadingId((current) => current === selectedJob.id ? null : current);
});
return () => { cancelled = true; };
}, [selectedJob, selectedReport, token]);
}, [executionTarget, local, selectedJob, selectedReport, token]);
if (!sessionCanUseGmTools(session)) {
if (!local && !sessionCanUseGmTools(session)) {
return (
<FrontSurface className="gm-admin-screen" ariaLabel="Balance Lab access denied">
<section className="gm-admin-denied">
@@ -224,6 +253,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
</FrontSurface>
);
}
if (readinessOpen) return <CompanionReadinessScreen onClose={() => setReadinessOpen(false)} />;
const toggleBoss = (key: string) => {
setSelectedBossKeys((current) => current.includes(key)
@@ -239,7 +269,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
const run = async () => {
if (!runtimeStatus?.available) {
setError(runtimeStatus?.error ?? "The server Balance Lab runtime is unavailable.");
setError(runtimeStatus?.error ?? `The ${local ? "local" : "server"} Balance Lab runtime is unavailable.`);
return;
}
if (bossScope === "specific" && selectedBossKeys.length === 0) {
@@ -274,11 +304,13 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
partySeed,
bossCategory: bossScope === "specific" ? "all" as const : bossScope,
bossKeys: bossScope === "specific" ? selectedBossKeys : [],
seatSweep,
includeBetaSpecs,
};
});
const submittedJobs = specs.length === 1
? [await startBalanceLabJob(specs[0], token)]
: await startBalanceLabJobs(specs, token);
? [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);
@@ -293,7 +325,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
const preview = name.endsWith(".html") ? window.open("", "_blank") : null;
setError("");
try {
const blob = await fetchBalanceLabArtifact(job.id, name, token);
const blob = await fetchBalanceLabArtifact(job.id, name, token, executionTarget);
const url = URL.createObjectURL(blob);
if (preview) preview.location.href = url;
else {
@@ -310,24 +342,29 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
};
return (
<FrontSurface className="balance-lab-screen" ariaLabel="GM Balance Lab">
<FrontSurface className="balance-lab-screen" ariaLabel={local ? "Local Balance Lab" : "GM Balance Lab"}>
<header className="gm-admin-header">
<BrandMark compact />
<div><span>Server-authorized GM tool</span><strong>Balance Lab</strong></div>
<div><span>{local ? "Local development tool" : "Server-authorized GM tool"}</span><strong>Balance Lab</strong></div>
<div className="gm-admin-header__actions">
<button className="front-button" type="button" onClick={onClose}>Main menu</button>
{!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">This mode runs on the game server. The browser submits the job and may be closed while the server continues.</p>
<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 ? "Server runtime ready" : "Server runtime unavailable"}</strong>
<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`
: `${runtimeStatus.error} Install Python 3 in the server container, set BALANCE_LAB_PYTHON=python3, and restart the app.`}</span>
: 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) => {
@@ -359,6 +396,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
<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>
@@ -380,7 +418,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
<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>
<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>
@@ -397,17 +435,19 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
</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 ? "Server runtime unavailable" : targetLevels.length === 1 ? "Run on server" : `Queue ${targetLevels.length} level jobs`}</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>
<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 on server</p><h2>Run history</h2></div><button type="button" disabled={loading} onClick={() => {
<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), getBalanceLabRuntime(token)]).then(([nextJobs, runtime]) => { setJobs(nextJobs); setRuntimeStatus(runtime); }).catch((reason) => setError(errorMessage(reason))).finally(() => setLoading(false));
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, GM…" value={jobSearch} onChange={(event) => setJobSearch(event.currentTarget.value)} /><select aria-label="Filter Balance Lab run status" value={jobStatus} onChange={(event) => setJobStatus(event.currentTarget.value as "all" | BalanceLabJob["status"])}><option value="all">All statuses</option><option value="complete">Complete</option><option value="running">Running</option><option value="queued">Queued</option><option value="failed">Failed</option></select></div>
<div className="balance-lab-history-filters"><input type="search" aria-label="Search Balance Lab run history" placeholder={`Find source, level, ${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>
@@ -419,13 +459,13 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
<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)}</p>
<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>Server progress log</summary><pre>{selectedJob.logTail.slice(-20).join("\n")}</pre></details>}
{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">