Release Healer Man 0.1.56
This commit is contained in:
1 parent
38b3d4d160
commit
a05a79eb83
16 files changed
+388
-116
No files matched your search
@@ -10,6 +10,10 @@ export interface BalanceLabRuntimeStatus {
|
||||
readonly executable: string;
|
||||
readonly version: string | null;
|
||||
readonly error: string | null;
|
||||
readonly maxConcurrent: number;
|
||||
readonly running: number;
|
||||
readonly queued: number;
|
||||
readonly maxQueued: number;
|
||||
}
|
||||
|
||||
export interface BalanceLabJobSpec {
|
||||
@@ -183,6 +187,17 @@ export async function startBalanceLabJob(spec: BalanceLabJobSpec, token: string)
|
||||
)).job;
|
||||
}
|
||||
|
||||
export async function startBalanceLabJobs(
|
||||
specs: readonly BalanceLabJobSpec[],
|
||||
token: string,
|
||||
): Promise<readonly BalanceLabJob[]> {
|
||||
return (await requestOnlineJson<{ jobs: BalanceLabJob[] }>(
|
||||
"/api/gm/balance-lab/jobs/batch",
|
||||
{ method: "POST", body: JSON.stringify({ specs }) },
|
||||
token,
|
||||
)).jobs;
|
||||
}
|
||||
|
||||
export async function getBalanceLabJob(id: string, token: string): Promise<BalanceLabJob> {
|
||||
return (await requestOnlineJson<{ job: BalanceLabJob }>(
|
||||
`/api/gm/balance-lab/jobs/${encodeURIComponent(id)}`,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { balanceLabBracketLevels } from "./balanceLabSchedules";
|
||||
|
||||
describe("Balance Lab level schedules", () => {
|
||||
it("includes level 1 and every ten-level bracket through 80", () => {
|
||||
expect(balanceLabBracketLevels(10)).toEqual([1, 10, 20, 30, 40, 50, 60, 70, 80]);
|
||||
});
|
||||
|
||||
it("supports every five levels without duplicating level 1", () => {
|
||||
expect(balanceLabBracketLevels(5)).toEqual([
|
||||
1, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80,
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports a job for every level from 1 through 80", () => {
|
||||
const levels = balanceLabBracketLevels(1);
|
||||
expect(levels).toHaveLength(80);
|
||||
expect(levels[0]).toBe(1);
|
||||
expect(levels.at(-1)).toBe(80);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export type BalanceLabBracketStep = 1 | 5 | 10;
|
||||
|
||||
export function balanceLabBracketLevels(step: BalanceLabBracketStep): readonly number[] {
|
||||
if (step === 1) return Array.from({ length: 80 }, (_, index) => index + 1);
|
||||
return [1, ...Array.from({ length: Math.floor(80 / step) }, (_, index) => (index + 1) * step)];
|
||||
}
|
||||
+77
-30
@@ -3,11 +3,11 @@ import { sessionCanUseGmTools } from "../app/releaseFeatures";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import {
|
||||
fetchBalanceLabArtifact,
|
||||
getBalanceLabJob,
|
||||
getBalanceLabReport,
|
||||
getBalanceLabRuntime,
|
||||
listBalanceLabJobs,
|
||||
startBalanceLabJob,
|
||||
startBalanceLabJobs,
|
||||
type BalanceLabBossCategory,
|
||||
type BalanceLabJob,
|
||||
type BalanceLabPartySource,
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
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 {
|
||||
@@ -25,6 +29,7 @@ import { BalanceLabResults } from "./BalanceLabResults";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
|
||||
type BossScope = BalanceLabBossCategory | "specific";
|
||||
type LevelCoverage = "single" | "brackets";
|
||||
|
||||
const ARTIFACT_LABELS: Readonly<Record<string, string>> = {
|
||||
"suite-report.html": "Open full report",
|
||||
@@ -54,7 +59,7 @@ function progressPercent(job: BalanceLabJob): number {
|
||||
}
|
||||
|
||||
function describeJob(job: BalanceLabJob): string {
|
||||
if (job.status === "queued") return "Waiting for the current server job to finish.";
|
||||
if (job.status === "queued") return "Waiting for an available server worker.";
|
||||
if (job.status === "failed") return job.error ?? "The 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.`
|
||||
@@ -77,6 +82,10 @@ function coveragePartyCount(builds: readonly SelectableBenchmarkBuildCatalogEntr
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function buildIdentity(build: SelectableBenchmarkBuildCatalogEntry): string {
|
||||
return `${build.source}:${build.classId}:${build.role}:${build.orientation}`;
|
||||
}
|
||||
|
||||
export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) {
|
||||
const session = useShellStore((state) => state.session);
|
||||
const token = session?.accessToken ?? "";
|
||||
@@ -85,6 +94,8 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
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);
|
||||
@@ -113,14 +124,18 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
&& (!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(
|
||||
Number.isFinite(level) ? level : 60,
|
||||
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)
|
||||
)), [level, source]);
|
||||
)), [catalogLevel, source]);
|
||||
const selectedBuildSet = useMemo(() => new Set(selectedBuildIds), [selectedBuildIds]);
|
||||
const selectedBuilds = useMemo(() => availableBuilds.filter((build) => selectedBuildSet.has(build.id)), [
|
||||
availableBuilds,
|
||||
@@ -144,7 +159,9 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
? partyConfigurations * (source === "all" ? 3 : 1)
|
||||
: coveragePartyCount(coverageBuilds);
|
||||
const coveredSpecCount = specSelection === "all" ? availableBuilds.length : selectedBuilds.length;
|
||||
const estimatedRuns = partyCount * bossCount * simulationsPerParty;
|
||||
const estimatedRunsPerLevel = partyCount * bossCount * simulationsPerParty;
|
||||
const estimatedRuns = estimatedRunsPerLevel * targetLevels.length;
|
||||
const hasPendingJobs = jobs.some((job) => job.status === "queued" || job.status === "running");
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
@@ -165,18 +182,19 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !selectedJob || (selectedJob.status !== "queued" && selectedJob.status !== "running")) return;
|
||||
if (!token || !hasPendingJobs) return;
|
||||
let cancelled = false;
|
||||
const timer = window.setInterval(() => {
|
||||
void getBalanceLabJob(selectedJob.id, token).then((updated) => {
|
||||
void Promise.all([listBalanceLabJobs(token), getBalanceLabRuntime(token)]).then(([nextJobs, runtime]) => {
|
||||
if (cancelled) return;
|
||||
setJobs((current) => [updated, ...current.filter((job) => job.id !== updated.id)]);
|
||||
setJobs(nextJobs);
|
||||
setRuntimeStatus(runtime);
|
||||
}).catch((reason) => {
|
||||
if (!cancelled) setError(errorMessage(reason));
|
||||
});
|
||||
}, 1_500);
|
||||
return () => { cancelled = true; window.clearInterval(timer); };
|
||||
}, [selectedJob, token]);
|
||||
}, [hasPendingJobs, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !selectedJob || selectedJob.status !== "complete" || selectedReport
|
||||
@@ -235,20 +253,35 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const job = await startBalanceLabJob({
|
||||
source,
|
||||
specSelection,
|
||||
selectedBuildIds: specSelection === "specific" ? selectedBuilds.map((build) => build.id) : [],
|
||||
level,
|
||||
partyConfigurations: specSelection === "random" ? partyConfigurations : partyCount,
|
||||
simulationsPerParty,
|
||||
durationSeconds,
|
||||
partySeed,
|
||||
bossCategory: bossScope === "specific" ? "all" : bossScope,
|
||||
bossKeys: bossScope === "specific" ? selectedBossKeys : [],
|
||||
}, token);
|
||||
setJobs((current) => [job, ...current.filter((candidate) => candidate.id !== job.id)]);
|
||||
setSelectedJobId(job.id);
|
||||
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 : [];
|
||||
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 : [],
|
||||
};
|
||||
});
|
||||
const submittedJobs = specs.length === 1
|
||||
? [await startBalanceLabJob(specs[0], token)]
|
||||
: await startBalanceLabJobs(specs, token);
|
||||
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 {
|
||||
@@ -292,7 +325,9 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
<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>
|
||||
{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>
|
||||
<span>{runtimeStatus.available ? runtimeStatus.version : `${runtimeStatus.error} Install Python 3 in the server container, set BALANCE_LAB_PYTHON=python3, and restart the app.`}</span>
|
||||
<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>
|
||||
</div>}
|
||||
<div className="balance-lab-form-grid">
|
||||
<label><span>Party source</span><select value={source} onChange={(event) => {
|
||||
@@ -302,10 +337,19 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
}}>
|
||||
<option value="all">WoW + CoA + RoM</option><option value="wow">WoW only</option><option value="coa">CoA only</option><option value="rom">RoM only</option>
|
||||
</select></label>
|
||||
<label><span>Level</span><input type="number" min={1} max={80} value={level} onChange={(event) => {
|
||||
setLevel(event.currentTarget.valueAsNumber);
|
||||
<label><span>Level coverage</span><select value={levelCoverage} onChange={(event) => {
|
||||
setLevelCoverage(event.currentTarget.value as LevelCoverage);
|
||||
setSelectedBuildIds([]);
|
||||
}} /></label>
|
||||
}}><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 parties</option><option value="all">All specs</option><option value="specific">Specific specs</option>
|
||||
</select></label>
|
||||
@@ -316,9 +360,12 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
<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"><span>Bosses</span><select value={bossScope} onChange={(event) => setBossScope(event.currentTarget.value as BossScope)}>
|
||||
<option value="all">All bosses</option><option value="wow">WoW bosses</option><option value="rom">RoM bosses</option><option value="specific">Specific selection</option>
|
||||
<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>
|
||||
{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.`
|
||||
@@ -349,8 +396,8 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<div className="balance-lab-estimate"><span>Party configurations <b>{partyCount.toLocaleString()}</b></span><span>Class/spec coverage <b>{specSelection === "random" ? "Random" : coveredSpecCount.toLocaleString()}</b></span><span>Bosses <b>{bossCount.toLocaleString()}</b></span><span>Total simulations <b>{estimatedRuns.toLocaleString()}</b></span></div>
|
||||
<button className="front-button front-button--primary balance-lab-run" type="button" disabled={submitting || !runtimeStatus?.available || !Number.isFinite(estimatedRuns) || estimatedRuns < 1} onClick={() => void run()}>{submitting ? "Queuing…" : runtimeStatus && !runtimeStatus.available ? "Server runtime unavailable" : "Run on server"}</button>
|
||||
<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>
|
||||
{error && <p className="front-notice" role="alert">{error}</p>}
|
||||
</section>
|
||||
|
||||
Reference in new issue
Block a user