Release Healer Man 0.1.51
This commit is contained in:
1 parent
79153ca40d
commit
dd583023ac
13 files changed
+405
-31
No files matched your search
@@ -2,6 +2,7 @@ import { onlineApiOrigin, requestOnlineJson } from "./onlineAccountClient";
|
||||
|
||||
export type BalanceLabPartySource = "wow" | "coa" | "rom" | "all";
|
||||
export type BalanceLabBossCategory = "all" | "wow" | "rom";
|
||||
export type BalanceLabSpecSelection = "random" | "all" | "specific";
|
||||
export type BalanceLabJobStatus = "queued" | "running" | "complete" | "failed";
|
||||
|
||||
export interface BalanceLabRuntimeStatus {
|
||||
@@ -13,6 +14,8 @@ export interface BalanceLabRuntimeStatus {
|
||||
|
||||
export interface BalanceLabJobSpec {
|
||||
readonly source: BalanceLabPartySource;
|
||||
readonly specSelection?: BalanceLabSpecSelection;
|
||||
readonly selectedBuildIds?: readonly string[];
|
||||
readonly level: number;
|
||||
readonly partyConfigurations: number;
|
||||
readonly simulationsPerParty: number;
|
||||
|
||||
+5
-1
@@ -3236,6 +3236,9 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
.balance-lab-boss-picker { margin-top: 10px; border: 1px solid var(--front-line); border-radius: 8px; overflow: hidden; }
|
||||
.balance-lab-boss-picker > header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid var(--front-line); }
|
||||
.balance-lab-boss-picker > header strong { color: #d7c68c; font-size: 8px; white-space: nowrap; }
|
||||
.balance-lab-picker-actions { display: flex; align-items: center; gap: 5px; }
|
||||
.balance-lab-picker-actions button { padding: 5px 7px; border: 1px solid var(--front-line); border-radius: 5px; color: var(--front-dim); background: rgba(149, 190, 118, .06); cursor: pointer; font: inherit; font-size: 7px; white-space: nowrap; }
|
||||
.balance-lab-picker-actions button:hover { color: var(--front-cream); background: rgba(149, 190, 118, .12); }
|
||||
.balance-lab-boss-picker > div { display: grid; max-height: 220px; overflow: auto; }
|
||||
.balance-lab-boss-picker label { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8px; padding: 7px 8px; border-bottom: 1px solid rgba(190, 213, 172, .08); cursor: pointer; }
|
||||
.balance-lab-boss-picker label:hover, .balance-lab-boss-picker label.is-selected { background: rgba(149, 190, 118, .1); }
|
||||
@@ -3243,7 +3246,8 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
.balance-lab-boss-picker label span, .balance-lab-boss-picker label strong, .balance-lab-boss-picker label small { display: block; min-width: 0; }
|
||||
.balance-lab-boss-picker label strong { overflow: hidden; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.balance-lab-boss-picker label small { margin-top: 2px; color: var(--front-dim); font-size: 7px; }
|
||||
.balance-lab-estimate { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; margin-top: 10px; }
|
||||
.balance-lab-coverage-note { margin: 8px 0 0; color: var(--front-dim); font-size: 7px; line-height: 1.45; }
|
||||
.balance-lab-estimate { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 5px; margin-top: 10px; }
|
||||
.balance-lab-estimate span { display: grid; gap: 3px; padding: 8px; border: 1px solid rgba(190, 213, 172, .11); border-radius: 6px; color: var(--front-dim); font-size: 7px; }
|
||||
.balance-lab-estimate b { color: var(--front-cream); font-size: 11px; }
|
||||
.balance-lab-run { width: 100%; margin-top: 10px; }
|
||||
|
||||
@@ -12,9 +12,15 @@ import {
|
||||
type BalanceLabJob,
|
||||
type BalanceLabPartySource,
|
||||
type BalanceLabRuntimeStatus,
|
||||
type BalanceLabSpecSelection,
|
||||
type BalanceLabSuiteReport,
|
||||
} from "../app/balanceLabClient";
|
||||
import { classById } from "../app/characterCatalog";
|
||||
import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
|
||||
import {
|
||||
selectableBenchmarkBuildCatalog,
|
||||
type SelectableBenchmarkBuildCatalogEntry,
|
||||
} from "../game/benchmark/benchmarkBuilds";
|
||||
import { BalanceLabResults } from "./BalanceLabResults";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
|
||||
@@ -60,10 +66,24 @@ function describeJob(job: BalanceLabJob): string {
|
||||
return `Party ${party}/${job.progress.partiesTotal}${simulation}`;
|
||||
}
|
||||
|
||||
function coveragePartyCount(builds: readonly SelectableBenchmarkBuildCatalogEntry[]): number {
|
||||
const bySource = new Map<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);
|
||||
}
|
||||
|
||||
export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) {
|
||||
const session = useShellStore((state) => state.session);
|
||||
const token = session?.accessToken ?? "";
|
||||
const [source, setSource] = useState<BalanceLabPartySource>("all");
|
||||
const [specSelection, setSpecSelection] = useState<BalanceLabSpecSelection>("random");
|
||||
const [specSearch, setSpecSearch] = useState("");
|
||||
const [selectedBuildIds, setSelectedBuildIds] = useState<readonly string[]>([]);
|
||||
const [level, setLevel] = useState(60);
|
||||
const [partyConfigurations, setPartyConfigurations] = useState(10);
|
||||
const [simulationsPerParty, setSimulationsPerParty] = useState(3);
|
||||
@@ -90,9 +110,26 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
const query = jobSearch.trim().toLowerCase();
|
||||
return jobs.filter((job) => (
|
||||
(jobStatus === "all" || job.status === jobStatus)
|
||||
&& (!query || `${job.requestedBy} ${job.spec.source} ${job.spec.level} ${job.id} ${job.status}`.toLowerCase().includes(query))
|
||||
&& (!query || `${job.requestedBy} ${job.spec.source} ${job.spec.level} ${job.spec.specSelection ?? "random"} ${job.id} ${job.status}`.toLowerCase().includes(query))
|
||||
));
|
||||
}, [jobSearch, jobStatus, jobs]);
|
||||
const availableBuilds = useMemo(() => selectableBenchmarkBuildCatalog(
|
||||
Number.isFinite(level) ? level : 60,
|
||||
).filter((build) => source === "all" || build.source === source).sort((left, right) => (
|
||||
left.source.localeCompare(right.source)
|
||||
|| classById(left.classId).name.localeCompare(classById(right.classId).name)
|
||||
|| left.orientation.localeCompare(right.orientation)
|
||||
|| left.role.localeCompare(right.role)
|
||||
)), [level, source]);
|
||||
const selectedBuildSet = useMemo(() => new Set(selectedBuildIds), [selectedBuildIds]);
|
||||
const selectedBuilds = useMemo(() => availableBuilds.filter((build) => selectedBuildSet.has(build.id)), [
|
||||
availableBuilds,
|
||||
selectedBuildSet,
|
||||
]);
|
||||
const filteredBuilds = useMemo(() => {
|
||||
const query = specSearch.trim().toLowerCase();
|
||||
return availableBuilds.filter((build) => !query || `${classById(build.classId).name} ${build.orientation} ${build.role} ${build.source} ${build.id}`.toLowerCase().includes(query));
|
||||
}, [availableBuilds, specSearch]);
|
||||
const filteredBosses = useMemo(() => {
|
||||
const query = bossSearch.trim().toLowerCase();
|
||||
return BOSS_TESTING_CATALOG.filter((boss) => !query
|
||||
@@ -102,7 +139,11 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
const bossCount = bossScope === "specific"
|
||||
? selectedBossKeys.length
|
||||
: BOSS_TESTING_CATALOG.filter((boss) => bossScope === "all" || boss.category === bossScope).length;
|
||||
const partyCount = partyConfigurations * (source === "all" ? 3 : 1);
|
||||
const coverageBuilds = specSelection === "all" ? availableBuilds : selectedBuilds;
|
||||
const partyCount = specSelection === "random"
|
||||
? partyConfigurations * (source === "all" ? 3 : 1)
|
||||
: coveragePartyCount(coverageBuilds);
|
||||
const coveredSpecCount = specSelection === "all" ? availableBuilds.length : selectedBuilds.length;
|
||||
const estimatedRuns = partyCount * bossCount * simulationsPerParty;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -172,6 +213,12 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
: [...current, key]);
|
||||
};
|
||||
|
||||
const toggleBuild = (id: string) => {
|
||||
setSelectedBuildIds((current) => current.includes(id)
|
||||
? current.filter((candidate) => candidate !== id)
|
||||
: [...current, id]);
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
if (!runtimeStatus?.available) {
|
||||
setError(runtimeStatus?.error ?? "The server Balance Lab runtime is unavailable.");
|
||||
@@ -181,13 +228,19 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
setError("Select at least one boss for a specific-boss run.");
|
||||
return;
|
||||
}
|
||||
if (specSelection === "specific" && selectedBuilds.length === 0) {
|
||||
setError("Select at least one class/spec for a specific-spec run.");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const job = await startBalanceLabJob({
|
||||
source,
|
||||
specSelection,
|
||||
selectedBuildIds: specSelection === "specific" ? selectedBuilds.map((build) => build.id) : [],
|
||||
level,
|
||||
partyConfigurations,
|
||||
partyConfigurations: specSelection === "random" ? partyConfigurations : partyCount,
|
||||
simulationsPerParty,
|
||||
durationSeconds,
|
||||
partySeed,
|
||||
@@ -242,11 +295,23 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
<span>{runtimeStatus.available ? runtimeStatus.version : `${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)}>
|
||||
<label><span>Party source</span><select value={source} onChange={(event) => {
|
||||
setSource(event.currentTarget.value as BalanceLabPartySource);
|
||||
setSelectedBuildIds([]);
|
||||
setSpecSearch("");
|
||||
}}>
|
||||
<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>
|
||||
<label><span>Parties per source</span><input type="number" min={1} max={100} value={partyConfigurations} onChange={(event) => setPartyConfigurations(event.currentTarget.valueAsNumber)} /></label>
|
||||
<label><span>Level</span><input type="number" min={1} max={80} value={level} onChange={(event) => {
|
||||
setLevel(event.currentTarget.valueAsNumber);
|
||||
setSelectedBuildIds([]);
|
||||
}} /></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>
|
||||
{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>
|
||||
@@ -254,6 +319,25 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
<option value="all">All bosses</option><option value="wow">WoW bosses</option><option value="rom">RoM bosses</option><option value="specific">Specific selection</option>
|
||||
</select></label>
|
||||
</div>
|
||||
{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 === "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}</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>
|
||||
@@ -265,7 +349,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<div className="balance-lab-estimate"><span>Party configurations <b>{partyCount.toLocaleString()}</b></span><span>Bosses <b>{bossCount.toLocaleString()}</b></span><span>Total simulations <b>{estimatedRuns.toLocaleString()}</b></span></div>
|
||||
<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>
|
||||
<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>}
|
||||
@@ -279,7 +363,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
<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-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}</small>{job.summary && <small>{job.summary.bosses.toLocaleString()} bosses · {job.summary.runs.toLocaleString()} simulations</small>}</span><em className={`is-${job.status}`}>{job.status}</em>
|
||||
<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>
|
||||
@@ -291,7 +375,7 @@ export function BalanceLabScreen({ onClose }: { readonly onClose: () => void })
|
||||
<p>{describeJob(selectedJob)}</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>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>
|
||||
<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>}
|
||||
|
||||
Reference in new issue
Block a user