Update Healer Man server
This commit is contained in:
1 parent
f8314e7de1
commit
1fedb9368d
76 files changed
+20556
-2523
No files matched your search
@@ -105,21 +105,26 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite
|
||||
const [partySort, setPartySort] = useState<keyof BalanceLabPartyResult>("partyDps");
|
||||
const [ascending, setAscending] = useState(false);
|
||||
const query = search.trim().toLowerCase();
|
||||
const reportedClasses = report.npcClassSpecs?.length ? report.npcClassSpecs : report.classSpecs;
|
||||
const reportedAbilities = report.npcClassAbilities?.length
|
||||
? report.npcClassAbilities
|
||||
: report.classAbilities ?? [];
|
||||
const npcOnly = Boolean(report.npcClassSpecs?.length);
|
||||
|
||||
const bosses = useMemo(() => report.bosses.filter((boss) => (
|
||||
(bossSource === "all" || boss.category === bossSource)
|
||||
&& (!query || `${boss.bossName} ${boss.dungeonTitle} ${boss.bossKey} ${boss.pressureFlag}`.toLowerCase().includes(query))
|
||||
)), [bossSource, query, report.bosses]);
|
||||
const classes = useMemo(() => report.classSpecs.filter((row) => (
|
||||
const classes = useMemo(() => reportedClasses.filter((row) => (
|
||||
(partySource === "all" || row.source === partySource)
|
||||
&& (role === "all" || row.role === role)
|
||||
&& (!query || `${row.className} ${row.specialization} ${row.role} ${row.source} ${row.damageFlag}`.toLowerCase().includes(query))
|
||||
)), [partySource, query, report.classSpecs, role]);
|
||||
)), [partySource, query, reportedClasses, role]);
|
||||
const parties = useMemo(() => report.partyConfigurations.filter((row) => (
|
||||
(partySource === "all" || row.source === partySource)
|
||||
&& (!query || `${row.partyConfigurationId} ${row.source} ${row.members}`.toLowerCase().includes(query))
|
||||
)), [partySource, query, report.partyConfigurations]);
|
||||
const allAbilities = report.classAbilities ?? [];
|
||||
const allAbilities = reportedAbilities;
|
||||
const abilityBuildOptions = useMemo(() => {
|
||||
const unique = new Map<string, BalanceLabClassAbilityResult>();
|
||||
for (const row of allAbilities) unique.set(`${row.source}/${row.buildId}`, row);
|
||||
@@ -205,7 +210,7 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite
|
||||
<div>
|
||||
<p className="front-kicker">Run analysis</p>
|
||||
<h2>Level {report.level} balance report</h2>
|
||||
<p>{new Date(report.generatedAt).toLocaleString()} · {report.summary.runs.toLocaleString()} simulations · every filtered row remains available below.</p>
|
||||
<p>{new Date(report.generatedAt).toLocaleString()} · {report.summary.runs.toLocaleString()} simulations · {npcOnly ? "class and ability views use NPC-only observations" : "every filtered row remains available below"}.</p>
|
||||
</div>
|
||||
<div className="balance-results__filters">
|
||||
<label><span>Find anything</span><input type="search" value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder="Boss, dungeon, class, spec, party…" /></label>
|
||||
|
||||
+77
-37
@@ -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">
|
||||
|
||||
@@ -12,6 +12,8 @@ import { FrontSurface, BrandMark, ControllerLegend } from "./FrontSurface";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
import "./bossTesting.css";
|
||||
import { CompanionPoolControls } from "./CompanionPoolControls";
|
||||
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
|
||||
|
||||
export function BossTestingScreen({ onClose }: { readonly onClose: () => void }) {
|
||||
const character = useShellStore(selectedCharacter);
|
||||
@@ -27,6 +29,7 @@ export function BossTestingScreen({ onClose }: { readonly onClose: () => void })
|
||||
const [prepared, setPrepared] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [role, setRole] = useState<PartyRole>(character ? defaultPartyRoleForClass(character.classId) : "damage");
|
||||
const [readinessOpen, setReadinessOpen] = useState(false);
|
||||
const busy = useRef(false);
|
||||
const alive = useRef(true);
|
||||
const bosses = useMemo(() => BOSS_TESTING_CATALOG.filter((boss) =>
|
||||
@@ -88,12 +91,14 @@ export function BossTestingScreen({ onClose }: { readonly onClose: () => void })
|
||||
const button = (controlId: string, label: string, run: () => void, disabled = false) =>
|
||||
<ControllerButton controlId={controlId} selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button" type="button" onClick={run} disabled={disabled}>{label}</ControllerButton>;
|
||||
if (readinessOpen) return <CompanionReadinessScreen onClose={() => setReadinessOpen(false)} />;
|
||||
return <DualDisplayFrame contextLabel="Boss Testing" top={
|
||||
<FrontSurface className="boss-testing-screen" ariaLabel="Boss Testing selection">
|
||||
<header className="boss-testing-header"><BrandMark compact /><div><span>Main menu / Mechanics laboratory</span><h1>Boss Testing</h1></div>
|
||||
{button("boss-testing-back", "Back to main menu", onClose)}</header>
|
||||
<main className="boss-testing-main">
|
||||
<p>Choose a boss to test with your party in an empty room. Missing content downloads automatically when you select a boss.</p>
|
||||
<CompanionPoolControls onOpenReadiness={() => setReadinessOpen(true)} compact />
|
||||
<div className="boss-testing-filters">
|
||||
<label>Search bosses<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Boss or dungeon name" disabled={preparing} /></label>
|
||||
<label>Game<select {...selectProps("boss-testing-category")} value={category} onChange={(event) => setCategory(event.target.value)}>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useCompanionPartyPreferences } from "../game/companionPartyPreferences";
|
||||
import { useCompanionSpecReadinessStore } from "../game/companionSpecReadinessStore";
|
||||
|
||||
export function CompanionPoolControls({ onOpenReadiness, compact = false }: {
|
||||
readonly onOpenReadiness: () => void;
|
||||
readonly compact?: boolean;
|
||||
}) {
|
||||
const policy = useCompanionSpecReadinessStore((state) => state.config);
|
||||
const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs);
|
||||
const setIncludeBetaSpecs = useCompanionPartyPreferences((state) => state.setIncludeBetaSpecs);
|
||||
const betaCount = Object.values(policy.statuses).filter((status) => status === "beta").length;
|
||||
return <div className={`companion-pool-controls ${compact ? "companion-pool-controls--compact" : ""}`}>
|
||||
<label title={betaCount ? "Allow certified beta specializations in random NPC seats." : "No beta specs available"}>
|
||||
<input type="checkbox" checked={includeBetaSpecs && betaCount > 0} disabled={betaCount === 0} onChange={(event) => setIncludeBetaSpecs(event.target.checked)} />
|
||||
<span><strong>Include Beta Specs</strong><small>{betaCount ? `${betaCount} beta ${betaCount === 1 ? "spec" : "specs"} available` : "No beta specs available"}</small></span>
|
||||
</label>
|
||||
<button className="text-action" type="button" onClick={onOpenReadiness}>View companion readiness</button>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { classById } from "../app/characterCatalog";
|
||||
import { sessionCanUseGmTools } from "../app/releaseFeatures";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import {
|
||||
WOW_COMPANION_SPECIALIZATIONS,
|
||||
type CompanionSpecReadinessStatus,
|
||||
} from "../game/companionSpecReadiness";
|
||||
import { useCompanionSpecReadinessStore } from "../game/companionSpecReadinessStore";
|
||||
import { runtimeDefaultPartyTalentProfile } from "../game/partyTalentRuntime";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
|
||||
const STATUS_LABELS: Readonly<Record<CompanionSpecReadinessStatus, string>> = {
|
||||
ready: "Ready",
|
||||
beta: "Beta",
|
||||
"needs-testing": "Needs Testing",
|
||||
};
|
||||
|
||||
export function CompanionReadinessScreen({ onClose }: { readonly onClose: () => void }) {
|
||||
const session = useShellStore((state) => state.session);
|
||||
const localEditor = import.meta.env.VITE_LOCAL_BALANCE_LAB_GUI === "true";
|
||||
const editable = localEditor || sessionCanUseGmTools(session);
|
||||
const policy = useCompanionSpecReadinessStore((state) => state.config);
|
||||
const draft = useCompanionSpecReadinessStore((state) => state.draft);
|
||||
const revision = useCompanionSpecReadinessStore((state) => state.revision);
|
||||
const updatedBy = useCompanionSpecReadinessStore((state) => state.updatedBy);
|
||||
const dirty = useCompanionSpecReadinessStore((state) => state.dirty);
|
||||
const saving = useCompanionSpecReadinessStore((state) => state.saving);
|
||||
const error = useCompanionSpecReadinessStore((state) => state.error);
|
||||
const setStatus = useCompanionSpecReadinessStore((state) => state.setStatus);
|
||||
const resetDraft = useCompanionSpecReadinessStore((state) => state.resetDraft);
|
||||
const publish = useCompanionSpecReadinessStore((state) => state.publish);
|
||||
const [query, setQuery] = useState("");
|
||||
const [role, setRole] = useState("all");
|
||||
const [status, setStatusFilter] = useState("all");
|
||||
const active = editable ? draft : policy;
|
||||
|
||||
const rows = useMemo(() => WOW_COMPANION_SPECIALIZATIONS.filter((definition) => {
|
||||
const className = classById(definition.classId).name;
|
||||
const search = `${className} ${definition.displayName}`.toLowerCase();
|
||||
return (!query.trim() || search.includes(query.trim().toLowerCase()))
|
||||
&& (role === "all" || definition.role === role)
|
||||
&& (status === "all" || active.statuses[definition.id] === status);
|
||||
}), [active, query, role, status]);
|
||||
const groups = useMemo(() => [...new Set(rows.map((definition) => definition.classId))].map((classId) => ({
|
||||
classId,
|
||||
definitions: rows.filter((definition) => definition.classId === classId),
|
||||
})), [rows]);
|
||||
const totals = Object.values(active.statuses).reduce<Record<CompanionSpecReadinessStatus, number>>((result, value) => {
|
||||
result[value] += 1;
|
||||
return result;
|
||||
}, { ready: 0, beta: 0, "needs-testing": 0 });
|
||||
|
||||
return (
|
||||
<FrontSurface className="companion-readiness" ariaLabel="Companion readiness">
|
||||
<header className="companion-readiness__header">
|
||||
<BrandMark compact />
|
||||
<div><span>NPC certification</span><strong>Companion Readiness</strong></div>
|
||||
<button className="front-button front-button--quiet" type="button" onClick={onClose}>Back</button>
|
||||
</header>
|
||||
<section className="companion-readiness__intro">
|
||||
<div>
|
||||
<p className="front-kicker">Certification standard</p>
|
||||
<p>Ready companions have a legal Playerbots talent build at every bracket, verified gear and resources, a complete priority rotation, role reactions, and proven offensive and defensive cooldown use without unexplained idle windows.</p>
|
||||
</div>
|
||||
<div className="companion-readiness__totals" aria-label="Readiness totals">
|
||||
{(["ready", "beta", "needs-testing"] as const).map((value) => (
|
||||
<span key={value} className={`readiness-badge readiness-badge--${value}`}><b>{totals[value]}</b> {STATUS_LABELS[value]}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="companion-readiness__filters" aria-label="Readiness filters">
|
||||
<label>Search<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Class or specialization" /></label>
|
||||
<label>Role<select value={role} onChange={(event) => setRole(event.target.value)}><option value="all">All roles</option><option value="tank">Tank</option><option value="healer">Healer</option><option value="damage">Damage</option></select></label>
|
||||
<label>Status<select value={status} onChange={(event) => setStatusFilter(event.target.value)}><option value="all">All statuses</option><option value="ready">Ready</option><option value="beta">Beta</option><option value="needs-testing">Needs Testing</option></select></label>
|
||||
<small>Published revision {revision}{updatedBy ? ` · ${updatedBy}` : ""}</small>
|
||||
</section>
|
||||
<main className="companion-readiness__catalog">
|
||||
{groups.map(({ classId, definitions }) => (
|
||||
<section key={classId} className="companion-readiness__class">
|
||||
<h2 style={{ color: classById(classId).color }}>{classById(classId).name}</h2>
|
||||
<div>
|
||||
{definitions.map((definition) => {
|
||||
const profile = runtimeDefaultPartyTalentProfile(definition.classId, definition.displayName);
|
||||
const value = active.statuses[definition.id] ?? "needs-testing";
|
||||
return <article key={definition.id} className="companion-readiness__spec">
|
||||
<div><strong>{definition.displayName}</strong><small>{definition.role} · {profile.characterName} · {profile.playerbotsSpecName ?? profile.id}</small></div>
|
||||
{editable ? <select aria-label={`${definition.displayName} readiness`} value={value} onChange={(event) => setStatus(definition.id, event.target.value as CompanionSpecReadinessStatus)}>
|
||||
<option value="ready">Ready</option><option value="beta">Beta</option><option value="needs-testing">Needs Testing</option>
|
||||
</select> : <span className={`readiness-badge readiness-badge--${value}`}>{STATUS_LABELS[value]}</span>}
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{!rows.length && <p className="front-notice">No specializations match those filters.</p>}
|
||||
</main>
|
||||
{editable && <footer className="companion-readiness__actions">
|
||||
<span>{dirty ? "Unpublished changes" : "Published policy is active"}</span>
|
||||
{error && <strong role="alert">{error}</strong>}
|
||||
<button className="front-button front-button--quiet" type="button" disabled={!dirty || saving} onClick={resetDraft}>Discard</button>
|
||||
<button className="front-button front-button--primary" type="button" disabled={!dirty || saving} onClick={() => void publish(session)}>{saving ? "Publishing…" : "Publish policy"}</button>
|
||||
</footer>}
|
||||
</FrontSurface>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,9 @@ import { DUNGEON_RELEASE_STAGE_LABELS } from "../game/dungeonReleaseStage";
|
||||
import { useDungeonReports } from "./useDungeonReports";
|
||||
import { DungeonDifficultySlider } from "./DungeonDifficultySlider";
|
||||
import { bossDifficultyLabel } from "../game/dungeonDifficulty";
|
||||
import { useCompanionPartyPreferences } from "../game/companionPartyPreferences";
|
||||
import { CompanionPoolControls } from "./CompanionPoolControls";
|
||||
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
|
||||
@@ -85,7 +88,9 @@ export function DungeonSelectScreen() {
|
||||
const [clearFilter, setClearFilter] = useState<DungeonClearFilter>("final");
|
||||
const [difficultyId, setDifficultyId] = useState("");
|
||||
const [bossDifficulty, setBossDifficulty] = useState(1);
|
||||
const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs);
|
||||
const [expandedRuns, setExpandedRuns] = useState<ReadonlySet<string>>(new Set());
|
||||
const [readinessOpen, setReadinessOpen] = useState(false);
|
||||
const leaderboardDifficulty = selectedDefinition?.difficultyVariants.find((variant) => variant.id === difficultyId)
|
||||
?? selectedDefinition?.difficultyVariants.find((variant) => variant.id === selectedDefinition.defaultDifficultyId);
|
||||
const leaderboard = useDungeonLeaderboard(selectedDungeon?.id ?? "", leaderboardDifficulty?.id ?? "normal", clearFilter, bossDifficulty);
|
||||
@@ -138,6 +143,7 @@ export function DungeonSelectScreen() {
|
||||
difficultyId: leaderboardDifficulty?.id,
|
||||
bossDifficulty,
|
||||
partySize: 5,
|
||||
includeBetaSpecs,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -236,6 +242,7 @@ export function DungeonSelectScreen() {
|
||||
|
||||
const race = character ? raceById(character.raceId) : null;
|
||||
const characterClass = character ? classById(character.classId) : null;
|
||||
if (readinessOpen) return <CompanionReadinessScreen onClose={() => setReadinessOpen(false)} />;
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
contextLabel="Dungeon details"
|
||||
@@ -342,6 +349,7 @@ export function DungeonSelectScreen() {
|
||||
<p className="front-notice" role="status">Select this expedition to download its required files.</p>
|
||||
)}
|
||||
<OnlineGroupPanel />
|
||||
<CompanionPoolControls onOpenReadiness={() => setReadinessOpen(true)} compact />
|
||||
{rolePickerOpen && characterClass ? (
|
||||
<section className="dungeon-role-picker" aria-labelledby="dungeon-role-title">
|
||||
<header>
|
||||
|
||||
@@ -12,6 +12,9 @@ import { sessionCanUseGmTools } from "../app/releaseFeatures";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { DUNGEON_CATALOG } from "../game/dungeonCatalog";
|
||||
import { DUNGEON_ROUTE_CATALOG } from "../game/dungeonRoutes";
|
||||
import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { useCompanionCooldownPolicyStore } from "../game/companionCooldownPolicyStore";
|
||||
import type {
|
||||
DungeonReplaySummary,
|
||||
DungeonRouteReview,
|
||||
@@ -20,6 +23,21 @@ import type {
|
||||
DungeonSimulationReportV1,
|
||||
} from "../game/dungeonSimulationTypes";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
import { CompanionPoolControls } from "./CompanionPoolControls";
|
||||
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
|
||||
|
||||
const COOLDOWN_MECHANICS = BOSS_TESTING_CATALOG.flatMap((boss) => {
|
||||
const entity = requireDungeonDefinition(boss.dungeonId).entities[boss.entityId];
|
||||
return (entity?.combat?.attacks ?? []).map((attack) => ({
|
||||
key: `${boss.key}\u0000${attack.id}`,
|
||||
bossKey: boss.key,
|
||||
bossName: boss.name,
|
||||
bossServerEntry: entity.combat?.serverEntry ?? null,
|
||||
attackId: attack.id,
|
||||
attackName: attack.name,
|
||||
label: `${boss.dungeonTitle} — ${boss.name}: ${attack.name}`,
|
||||
}));
|
||||
});
|
||||
|
||||
function saveReport(report: DungeonSimulationReportV1): void {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2)], { type: "application/json" }));
|
||||
@@ -43,6 +61,16 @@ export function DungeonSimulationAdminScreen() {
|
||||
const [overrideNote, setOverrideNote] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [readinessOpen, setReadinessOpen] = useState(false);
|
||||
const [selectedCooldownMechanic, setSelectedCooldownMechanic] = useState(COOLDOWN_MECHANICS[0]?.key ?? "");
|
||||
const cooldownDraft = useCompanionCooldownPolicyStore((state) => state.draft);
|
||||
const cooldownRevision = useCompanionCooldownPolicyStore((state) => state.revision);
|
||||
const cooldownDirty = useCompanionCooldownPolicyStore((state) => state.dirty);
|
||||
const cooldownSaving = useCompanionCooldownPolicyStore((state) => state.saving);
|
||||
const cooldownError = useCompanionCooldownPolicyStore((state) => state.error);
|
||||
const replaceCooldownDraft = useCompanionCooldownPolicyStore((state) => state.replaceDraft);
|
||||
const resetCooldownDraft = useCompanionCooldownPolicyStore((state) => state.resetDraft);
|
||||
const publishCooldownPolicy = useCompanionCooldownPolicyStore((state) => state.publish);
|
||||
const route = DUNGEON_ROUTE_CATALOG.routes.find((candidate) => candidate.dungeonId === selectedDungeonId)
|
||||
?? DUNGEON_ROUTE_CATALOG.routes[0];
|
||||
const dungeon = DUNGEON_CATALOG.find((candidate) => candidate.id === route?.dungeonId);
|
||||
@@ -152,6 +180,39 @@ export function DungeonSimulationAdminScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateGenericCooldown = (key: keyof typeof cooldownDraft.generic, value: number | boolean) => {
|
||||
replaceCooldownDraft({
|
||||
...cooldownDraft,
|
||||
generic: { ...cooldownDraft.generic, [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
const addCooldownMechanic = () => {
|
||||
const mechanic = COOLDOWN_MECHANICS.find((candidate) => candidate.key === selectedCooldownMechanic);
|
||||
if (!mechanic) return;
|
||||
const idBase = `${mechanic.bossKey}-${mechanic.attackId}`.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||
const existingIds = new Set(cooldownDraft.mechanics.map((rule) => rule.id));
|
||||
let id = idBase;
|
||||
for (let suffix = 2; existingIds.has(id); suffix += 1) id = `${idBase}-${suffix}`;
|
||||
replaceCooldownDraft({
|
||||
...cooldownDraft,
|
||||
mechanics: [...cooldownDraft.mechanics, {
|
||||
id,
|
||||
enabled: true,
|
||||
bossKey: mechanic.bossKey,
|
||||
bossName: mechanic.bossName,
|
||||
bossServerEntry: mechanic.bossServerEntry,
|
||||
attackId: mechanic.attackId,
|
||||
attackName: mechanic.attackName,
|
||||
scope: "tank",
|
||||
leadTimeMs: 2_500,
|
||||
minimumCooldownSeconds: 20,
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
if (readinessOpen) return <CompanionReadinessScreen onClose={() => setReadinessOpen(false)} />;
|
||||
|
||||
return (
|
||||
<FrontSurface className="dungeon-sim-admin" ariaLabel="Dungeon simulation route review">
|
||||
<header className="dungeon-sim-admin__header">
|
||||
@@ -188,6 +249,7 @@ export function DungeonSimulationAdminScreen() {
|
||||
|
||||
<fieldset>
|
||||
<legend>Launch live simulation</legend>
|
||||
<CompanionPoolControls onOpenReadiness={() => setReadinessOpen(true)} compact />
|
||||
<label><span>Mode</span><select value={mode} onChange={(event) => setMode(event.target.value as DungeonSimulationMode)}>
|
||||
<option value="navigation-only">Navigation only</option>
|
||||
<option value="full-combat">Full combat</option>
|
||||
@@ -200,6 +262,32 @@ export function DungeonSimulationAdminScreen() {
|
||||
onClick={() => route && enterDungeonSimulation(route.dungeonId, mode, seed)}
|
||||
>Launch spectate mode</button>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Companion cooldown policy · revision {cooldownRevision}</legend>
|
||||
<label><span>Use defensive below health %</span><input type="number" min="1" max="100" value={cooldownDraft.generic.defensiveHealthPercent} onChange={(event) => updateGenericCooldown("defensiveHealthPercent", Number(event.target.value))} /></label>
|
||||
<label><span>Emergency defensive below health %</span><input type="number" min="1" max="100" value={cooldownDraft.generic.emergencyHealthPercent} onChange={(event) => updateGenericCooldown("emergencyHealthPercent", Number(event.target.value))} /></label>
|
||||
<label><span>Healer mitigation below health %</span><input type="number" min="1" max="100" value={cooldownDraft.generic.healerMitigationHealthPercent} onChange={(event) => updateGenericCooldown("healerMitigationHealthPercent", Number(event.target.value))} /></label>
|
||||
<label><span>Major offensive cooldown seconds</span><input type="number" min="0" max="600" value={cooldownDraft.generic.offensiveMajorCooldownSeconds} onChange={(event) => updateGenericCooldown("offensiveMajorCooldownSeconds", Number(event.target.value))} /></label>
|
||||
<label><input type="checkbox" checked={cooldownDraft.generic.conserveOffensiveCooldownsForDurableTargets} onChange={(event) => updateGenericCooldown("conserveOffensiveCooldownsForDurableTargets", event.target.checked)} /><span>Save major offensive cooldowns for durable targets</span></label>
|
||||
<label><span>Pre-empt boss mechanic</span><select value={selectedCooldownMechanic} onChange={(event) => setSelectedCooldownMechanic(event.target.value)}>
|
||||
{COOLDOWN_MECHANICS.map((mechanic) => <option key={mechanic.key} value={mechanic.key}>{mechanic.label}</option>)}
|
||||
</select></label>
|
||||
<button className="front-button" type="button" disabled={!selectedCooldownMechanic} onClick={addCooldownMechanic}>Add mechanic rule</button>
|
||||
{cooldownDraft.mechanics.map((rule, index) => (
|
||||
<article key={rule.id} className="dungeon-sim-admin__route-meta">
|
||||
<strong>{rule.bossName}: {rule.attackName}</strong>
|
||||
<label><span>Scope</span><select value={rule.scope} onChange={(event) => replaceCooldownDraft({ ...cooldownDraft, mechanics: cooldownDraft.mechanics.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, scope: event.target.value === "party" ? "party" : "tank" } : candidate) })}><option value="tank">Tank</option><option value="party">Party</option></select></label>
|
||||
<label><span>Lead time ms</span><input type="number" min="0" max="15000" step="100" value={rule.leadTimeMs} onChange={(event) => replaceCooldownDraft({ ...cooldownDraft, mechanics: cooldownDraft.mechanics.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, leadTimeMs: Number(event.target.value) } : candidate) })} /></label>
|
||||
<label><span>Minimum defensive cooldown seconds</span><input type="number" min="0" max="600" value={rule.minimumCooldownSeconds} onChange={(event) => replaceCooldownDraft({ ...cooldownDraft, mechanics: cooldownDraft.mechanics.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, minimumCooldownSeconds: Number(event.target.value) } : candidate) })} /></label>
|
||||
<button className="front-button" type="button" onClick={() => replaceCooldownDraft({ ...cooldownDraft, mechanics: cooldownDraft.mechanics.filter((_, candidateIndex) => candidateIndex !== index) })}>Remove</button>
|
||||
</article>
|
||||
))}
|
||||
<div className="dungeon-sim-admin__header-actions">
|
||||
<button className="front-button front-button--primary" type="button" disabled={!cooldownDirty || cooldownSaving} onClick={() => void publishCooldownPolicy(session)}>Publish cooldown rules</button>
|
||||
<button className="front-button" type="button" disabled={!cooldownDirty || cooldownSaving} onClick={resetCooldownDraft}>Discard</button>
|
||||
</div>
|
||||
{cooldownError && <p className="front-notice" role="alert">{cooldownError}</p>}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Exact-hash review</legend>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MainMenuCharacterEditor, type MainMenuEditor } from "./MainMenuCharacte
|
||||
const BossCardCollectionScreen = lazy(() => import("./BossCardCollection"));
|
||||
const BossTestingScreen = lazy(() => import("./BossTestingScreen").then((module) => ({ default: module.BossTestingScreen })));
|
||||
const BalanceLabScreen = lazy(() => import("./BalanceLabScreen").then((module) => ({ default: module.BalanceLabScreen })));
|
||||
const CompanionReadinessScreen = lazy(() => import("./CompanionReadinessScreen").then((module) => ({ default: module.CompanionReadinessScreen })));
|
||||
|
||||
const GAME_MODES = [
|
||||
{
|
||||
@@ -73,22 +74,25 @@ export function MainMenuScreen() {
|
||||
const [cardsOpen, setCardsOpen] = useState(false);
|
||||
const [bossTestingOpen, setBossTestingOpen] = useState(false);
|
||||
const [balanceLabOpen, setBalanceLabOpen] = useState(false);
|
||||
const [readinessOpen, setReadinessOpen] = useState(false);
|
||||
const [editor, setEditor] = useState<MainMenuEditor | null>(null);
|
||||
if (editor) return <MainMenuCharacterEditor editor={editor} onClose={() => setEditor(null)} />;
|
||||
if (bossTestingOpen) return <Suspense fallback={<p role="status">Opening Boss Testing…</p>}><BossTestingScreen onClose={() => setBossTestingOpen(false)} /></Suspense>;
|
||||
if (balanceLabOpen) return <Suspense fallback={<p role="status">Opening Balance Lab…</p>}><BalanceLabScreen onClose={() => setBalanceLabOpen(false)} /></Suspense>;
|
||||
if (readinessOpen) return <Suspense fallback={<p role="status">Opening Companion Readiness…</p>}><CompanionReadinessScreen onClose={() => setReadinessOpen(false)} /></Suspense>;
|
||||
if (cardsOpen) return <Suspense fallback={<p role="status">Opening collection log…</p>}><BossCardCollectionScreen onClose={() => setCardsOpen(false)} /></Suspense>;
|
||||
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
|
||||
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
|
||||
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenEditor={setEditor} />;
|
||||
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenReadiness={() => setReadinessOpen(true)} onOpenEditor={setEditor} />;
|
||||
}
|
||||
|
||||
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenEditor }: {
|
||||
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenReadiness, onOpenEditor }: {
|
||||
readonly onOpenDownloads: () => void;
|
||||
readonly onOpenReports: () => void;
|
||||
readonly onOpenCards: () => void;
|
||||
readonly onOpenBossTesting: () => void;
|
||||
readonly onOpenBalanceLab: () => void;
|
||||
readonly onOpenReadiness: () => void;
|
||||
readonly onOpenEditor: (editor: MainMenuEditor) => void;
|
||||
}) {
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
@@ -143,7 +147,8 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
{ id: "mode-reports", run: onOpenReports },
|
||||
{ id: "mode-cards", run: onOpenCards },
|
||||
{ id: "mode-boss-testing", run: onOpenBossTesting },
|
||||
], [character, isGm, onOpenBalanceLab, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
{ id: "mode-companion-readiness", run: onOpenReadiness },
|
||||
], [character, isGm, onOpenBalanceLab, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: "mode-dungeons",
|
||||
onBack: returnToCharacters,
|
||||
@@ -199,6 +204,10 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
</header>
|
||||
<OnlineGroupPanel />
|
||||
<nav className="main-menu-mode-grid" aria-label="Main menu destinations">
|
||||
<ControllerButton controlId="mode-companion-readiness" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenReadiness}>
|
||||
<b aria-hidden="true">CR</b><span><strong>Companion Readiness</strong><small>Review the certified NPC specialization pool and Playerbots identities.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="mode-boss-testing" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenBossTesting}>
|
||||
<b aria-hidden="true">BT</b><span><strong>Boss Testing</strong><small>Choose a boss or fight a random encounter in an empty testing room.</small></span><em>Open</em>
|
||||
|
||||
@@ -42,6 +42,10 @@ import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import { useCompanionPartyPreferences } from "../game/companionPartyPreferences";
|
||||
import { useCompanionSpecReadinessStore } from "../game/companionSpecReadinessStore";
|
||||
import { CompanionPoolControls } from "./CompanionPoolControls";
|
||||
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
|
||||
|
||||
installManastormShellRuntime();
|
||||
|
||||
@@ -71,6 +75,9 @@ export function ManastormSelectScreen() {
|
||||
? character.categoryId === "rom" ? partyRolesForClass(character.classId) : savedSpecialization ? [savedSpecialization.role] : []
|
||||
: [];
|
||||
const [partySize, setPartySize] = useState<ManastormPartySize>(5);
|
||||
const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs);
|
||||
const readinessPolicy = useCompanionSpecReadinessStore((state) => state.config);
|
||||
const readinessPolicyRevision = useCompanionSpecReadinessStore((state) => state.revision);
|
||||
const groupHumanCount = onlineGroup?.members.length ?? 1;
|
||||
const effectivePartySize = onlineGroup
|
||||
? (fillWithAi ? Math.max(partySize, groupHumanCount) : groupHumanCount) as ManastormPartySize
|
||||
@@ -145,6 +152,7 @@ export function ManastormSelectScreen() {
|
||||
const [pendingDownloadRole, setPendingDownloadRole] = useState<PartyRole | null>(null);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadError, setDownloadError] = useState("");
|
||||
const [readinessOpen, setReadinessOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSpellIds(persistedSpellIds);
|
||||
@@ -170,6 +178,12 @@ export function ManastormSelectScreen() {
|
||||
role,
|
||||
effectivePartySize,
|
||||
character.categoryId ?? "wow",
|
||||
{
|
||||
readinessPolicy,
|
||||
readinessPolicyRevision,
|
||||
includeBetaSpecs,
|
||||
excludedPlayerSpecializationId: character.specializationId,
|
||||
},
|
||||
);
|
||||
void preloadAvatarModels([character, ...party]).catch((error) => {
|
||||
console.warn("Selected party avatar preloading could not be completed", error);
|
||||
@@ -192,6 +206,7 @@ export function ManastormSelectScreen() {
|
||||
contentId: "manastorm",
|
||||
partySize: effectivePartySize,
|
||||
startingLevel,
|
||||
includeBetaSpecs,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -273,6 +288,7 @@ export function ManastormSelectScreen() {
|
||||
|
||||
const race = character ? raceById(character.raceId) : null;
|
||||
const characterClass = character ? classById(character.classId) : null;
|
||||
if (readinessOpen) return <CompanionReadinessScreen onClose={() => setReadinessOpen(false)} />;
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
@@ -339,6 +355,7 @@ export function ManastormSelectScreen() {
|
||||
<small>Progress and first clears are tracked separately for every party size.</small>
|
||||
</header>
|
||||
<OnlineGroupPanel />
|
||||
<CompanionPoolControls onOpenReadiness={() => setReadinessOpen(true)} compact />
|
||||
|
||||
<div className="manastorm-setup-block">
|
||||
<strong>Party size</strong>
|
||||
|
||||
Reference in new issue
Block a user