Release Healer Man 0.1.46

This commit is contained in:
phenom committed 2026-09-16 14:30:58 -04:00
1 parent f82daf1c13
commit 9ff574ac0b
73 files changed
+33486 -174

No files matched your search

+263
View File
@@ -0,0 +1,263 @@
import { useMemo, useState } from "react";
import type {
BalanceLabBossResult,
BalanceLabClassAbilityResult,
BalanceLabClassSpecResult,
BalanceLabPartyResult,
BalanceLabSuiteReport,
} from "../app/balanceLabClient";
type ResultsTab = "overview" | "bosses" | "classes" | "abilities" | "parties";
type PartySourceFilter = "all" | "wow" | "coa" | "rom";
type BossSourceFilter = "all" | "wow" | "rom";
interface ChartDatum {
readonly key: string;
readonly label: string;
readonly detail: string;
readonly value: number;
}
const number = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });
const wholeNumber = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
function formatNumber(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : number.format(value);
}
function formatWhole(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : wholeNumber.format(value);
}
function formatPercent(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : `${number.format(value * 100)}%`;
}
function formatSeconds(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : `${number.format(value)}s`;
}
function formatRatio(value: number | null | undefined): string {
return value === null || value === undefined || !Number.isFinite(value) ? "—" : `${number.format(value)}×`;
}
function flagClass(flag: string): string {
if (flag === "high" || flag === "very high") return "is-high";
if (flag === "low" || flag === "very low") return "is-low";
return "is-baseline";
}
function BarChart({
title,
description,
rows,
format = formatNumber,
tone = "damage",
}: {
readonly title: string;
readonly description: string;
readonly rows: readonly ChartDatum[];
readonly format?: (value: number) => string;
readonly tone?: "damage" | "party" | "time" | "class" | "taken";
}) {
const sorted = [...rows].filter((row) => Number.isFinite(row.value)).sort((left, right) => right.value - left.value);
const visible = sorted.slice(0, 15);
const maximum = Math.max(1, ...visible.map((row) => row.value));
return (
<article className={`balance-results-chart is-${tone}`} aria-label={title}>
<header><div><h4>{title}</h4><p>{description}</p></div><strong>{rows.length}</strong></header>
<div className="balance-results-chart__plot">
{visible.map((row) => (
<div className="balance-results-bar" key={row.key} title={`${row.label}: ${format(row.value)}`}>
<span className="balance-results-bar__label"><b>{row.label}</b><small>{row.detail}</small></span>
<span className="balance-results-bar__track"><i style={{ width: `${Math.max(1.5, (row.value / maximum) * 100)}%` }} /></span>
<strong>{format(row.value)}</strong>
</div>
))}
{visible.length === 0 && <p className="balance-results-empty">No matching data for this graph.</p>}
</div>
{sorted.length > visible.length && <footer>Showing the highest 15 of {sorted.length}. The complete filtered set is available in the tables.</footer>}
</article>
);
}
function sortNumeric<T>(rows: readonly T[], read: (row: T) => number | null, ascending: boolean): T[] {
return [...rows].sort((left, right) => {
const leftValue = read(left);
const rightValue = read(right);
const leftComparable = leftValue === null || !Number.isFinite(leftValue) ? Number.NEGATIVE_INFINITY : leftValue;
const rightComparable = rightValue === null || !Number.isFinite(rightValue) ? Number.NEGATIVE_INFINITY : rightValue;
return (leftComparable - rightComparable) * (ascending ? 1 : -1);
});
}
export function BalanceLabResults({ report }: { readonly report: BalanceLabSuiteReport }) {
const [tab, setTab] = useState<ResultsTab>("overview");
const [search, setSearch] = useState("");
const [partySource, setPartySource] = useState<PartySourceFilter>("all");
const [bossSource, setBossSource] = useState<BossSourceFilter>("all");
const [role, setRole] = useState("all");
const [bossSort, setBossSort] = useState<keyof BalanceLabBossResult>("incomingDtps");
const [classSort, setClassSort] = useState<keyof BalanceLabClassSpecResult>("bossDps");
const [abilityBuild, setAbilityBuild] = useState("all");
const [abilityStatus, setAbilityStatus] = useState("all");
const [abilitySort, setAbilitySort] = useState<keyof BalanceLabClassAbilityResult>("effectiveDamage");
const [partySort, setPartySort] = useState<keyof BalanceLabPartyResult>("partyDps");
const [ascending, setAscending] = useState(false);
const query = search.trim().toLowerCase();
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) => (
(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]);
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 abilityBuildOptions = useMemo(() => {
const unique = new Map<string, BalanceLabClassAbilityResult>();
for (const row of allAbilities) unique.set(`${row.source}/${row.buildId}`, row);
return [...unique.entries()].sort((left, right) => (
left[1].source.localeCompare(right[1].source)
|| left[1].className.localeCompare(right[1].className)
|| left[1].specialization.localeCompare(right[1].specialization)
));
}, [allAbilities]);
const abilities = useMemo(() => allAbilities.filter((row) => {
const buildKey = `${row.source}/${row.buildId}`;
const matchesStatus = abilityStatus === "all"
|| (abilityStatus === "used" && row.activeRuns > 0)
|| (abilityStatus === "unused" && row.unusedAvailable)
|| (abilityStatus === "unused-damage" && row.unusedAvailable && /damage|execute|debuff/i.test(row.categories))
|| (abilityStatus === "damage" && row.effectiveDamage > 0)
|| (abilityStatus === "support" && (row.effectiveHealing > 0 || row.absorbs > 0));
return (partySource === "all" || row.source === partySource)
&& (role === "all" || row.role === role)
&& (abilityBuild === "all" || buildKey === abilityBuild)
&& matchesStatus
&& (!query || `${row.className} ${row.specialization} ${row.role} ${row.source} ${row.abilityName} ${row.abilityId} ${row.categories} ${row.status}`.toLowerCase().includes(query));
}), [abilityBuild, abilityStatus, allAbilities, partySource, query, role]);
const sortedBosses = useMemo(() => sortNumeric(bosses, (row) => {
const value = row[bossSort];
return typeof value === "number" ? value : null;
}, ascending), [ascending, bossSort, bosses]);
const sortedClasses = useMemo(() => sortNumeric(classes, (row) => {
const value = row[classSort];
return typeof value === "number" ? value : null;
}, ascending), [ascending, classSort, classes]);
const sortedAbilities = useMemo(() => sortNumeric(abilities, (row) => {
const value = row[abilitySort];
return typeof value === "number" ? value : null;
}, ascending), [abilities, abilitySort, ascending]);
const sortedParties = useMemo(() => sortNumeric(parties, (row) => {
const value = row[partySort];
return typeof value === "number" ? value : null;
}, ascending), [ascending, parties, partySort]);
const bossChart = (metric: keyof BalanceLabBossResult): ChartDatum[] => bosses.map((boss) => ({
key: boss.bossKey,
label: boss.bossName,
detail: boss.dungeonTitle,
value: typeof boss[metric] === "number" ? boss[metric] as number : Number.NaN,
}));
const classChart = (metric: keyof BalanceLabClassSpecResult): ChartDatum[] => classes.map((row) => ({
key: `${row.source}/${row.buildId}/${metric}`,
label: `${row.className} · ${row.specialization}`,
detail: `${row.source.toUpperCase()} · ${row.role}`,
value: typeof row[metric] === "number" ? row[metric] as number : Number.NaN,
}));
const abilityChart = (metric: keyof BalanceLabClassAbilityResult): ChartDatum[] => abilities.map((row) => ({
key: `${row.source}/${row.buildId}/${row.abilityId}/${metric}`,
label: row.abilityName,
detail: `${row.className} · ${row.specialization}`,
value: typeof row[metric] === "number" ? row[metric] as number : Number.NaN,
}));
const availableAbilityCount = abilities.filter((row) => row.availableRuns > 0).length;
const unusedAbilityCount = abilities.filter((row) => row.unusedAvailable).length;
const usedAbilityCount = abilities.filter((row) => row.activeRuns > 0).length;
const filteredAbilityDamage = abilities.reduce((total, row) => total + row.effectiveDamage, 0);
return (
<section className="balance-results" aria-label="Balance Lab report analysis">
<header className="balance-results__header">
<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>
</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>
<label><span>Party source</span><select value={partySource} onChange={(event) => setPartySource(event.currentTarget.value as PartySourceFilter)}><option value="all">All party sources</option><option value="wow">WoW</option><option value="coa">CoA</option><option value="rom">RoM</option></select></label>
<label><span>Boss source</span><select value={bossSource} onChange={(event) => setBossSource(event.currentTarget.value as BossSourceFilter)}><option value="all">All boss sources</option><option value="wow">WoW</option><option value="rom">RoM</option></select></label>
<label><span>Class role</span><select value={role} onChange={(event) => setRole(event.currentTarget.value)}><option value="all">All roles</option><option value="tank">Tank</option><option value="healer">Healer</option><option value="damage">Damage</option></select></label>
</div>
</header>
<nav className="balance-results__tabs" aria-label="Balance report sections">
{(["overview", "bosses", "classes", "abilities", "parties"] as const).map((value) => <button key={value} type="button" className={tab === value ? "is-active" : ""} onClick={() => setTab(value)}>{value === "classes" ? "Class / spec" : value}</button>)}
</nav>
{tab === "overview" && <div className="balance-results-overview">
<div className="balance-results-summary">
<article><span>Party configurations</span><strong>{report.summary.partyConfigurations.toLocaleString()}</strong></article>
<article><span>Bosses tested</span><strong>{report.summary.bosses.toLocaleString()}</strong></article>
<article><span>Class/spec builds</span><strong>{report.summary.classSpecs.toLocaleString()}</strong></article>
<article><span>Median boss pressure</span><strong>{formatNumber(report.summary.bossPressureMedianDtps)} <small>DTPS</small></strong></article>
</div>
<div className="balance-results-charts">
<BarChart title="Boss damage to party" description="Incoming party damage per second; fastest way to spot healer-pressure outliers." rows={bossChart("incomingDtps")} tone="damage" />
<BarChart title="Party damage to boss" description="Damage per second applied directly to each boss, excluding unrelated targets." rows={bossChart("bossDpsTaken")} tone="party" />
<BarChart title="Kill times" description="Average seconds to defeat; bosses never defeated are listed in the boss table as —." rows={bossChart("averageTimeToDefeat")} format={formatSeconds} tone="time" />
<BarChart title="Class/spec boss damage" description="Every tested build ranked by DPS applied directly to bosses." rows={classChart("bossDps")} tone="class" />
<BarChart title="Damage taken by class/spec" description="Incoming damage per second by build; useful for survivability and healer-load checks." rows={classChart("dtps")} tone="taken" />
</div>
</div>}
{tab === "bosses" && <div className="balance-results-table-panel">
<header><div><h3>Every boss</h3><p>{sortedBosses.length} matching bosses · pressure, outgoing damage, party output, kill time, survival, and outcomes.</p></div><div className="balance-results-sort"><select aria-label="Sort bosses" value={bossSort} onChange={(event) => setBossSort(event.currentTarget.value as keyof BalanceLabBossResult)}><option value="incomingDtps">Boss → party DTPS</option><option value="averageIncomingDamage">Average party damage</option><option value="bossDpsTaken">Party → boss DPS</option><option value="partyDps">Total party DPS</option><option value="averageTimeToDefeat">Kill time</option><option value="wipeRate">Wipe rate</option><option value="defeatRate">Defeat rate</option><option value="averageDeaths">Average deaths</option></select><button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button></div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Boss</th><th>Source</th><th>Runs</th><th>Boss → party DTPS</th><th>Avg party damage</th><th>Pressure</th><th>Party → boss DPS</th><th>Total party DPS</th><th>Kill time</th><th>Defeat</th><th>Wipe</th><th>Avg deaths</th><th>Boss health</th></tr></thead><tbody>{sortedBosses.map((boss) => <tr key={boss.bossKey}><td><strong>{boss.bossName}</strong><small>{boss.dungeonTitle}</small></td><td>{boss.category.toUpperCase()}</td><td>{boss.runs.toLocaleString()}</td><td>{formatNumber(boss.incomingDtps)}</td><td>{formatWhole(boss.averageIncomingDamage)}</td><td><span className={`balance-results-flag ${flagClass(boss.pressureFlag)}`}>{formatRatio(boss.pressureVsMedian)} · {boss.pressureFlag}</span></td><td>{formatNumber(boss.bossDpsTaken)}</td><td>{formatNumber(boss.partyDps)}</td><td>{formatSeconds(boss.averageTimeToDefeat)}</td><td>{formatPercent(boss.defeatRate)}</td><td>{formatPercent(boss.wipeRate)}</td><td>{formatNumber(boss.averageDeaths)}</td><td>{formatWhole(boss.bossMaxHealth)}</td></tr>)}</tbody></table></div>
</div>}
{tab === "classes" && <div className="balance-results-table-panel">
<header><div><h3>Every class and specialization</h3><p>{sortedClasses.length} matching builds · output, healing, mitigation, survivability, and role-relative tuning.</p></div><div className="balance-results-sort"><select aria-label="Sort class specs" value={classSort} onChange={(event) => setClassSort(event.currentTarget.value as keyof BalanceLabClassSpecResult)}><option value="bossDps">Boss DPS</option><option value="dps">Total DPS</option><option value="hps">HPS</option><option value="dtps">Damage taken / sec</option><option value="totalDamageTaken">Total damage taken</option><option value="totalDamage">Total damage dealt</option><option value="totalHealing">Total healing</option><option value="totalAbsorbs">Total absorbs</option><option value="deathEvents">Deaths</option></select><button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button></div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Class / specialization</th><th>Source</th><th>Role</th><th>Parties</th><th>Runs</th><th>Boss DPS</th><th>Total DPS</th><th>HPS</th><th>Damage taken / sec</th><th>Total damage taken</th><th>Role median</th><th>Total damage dealt</th><th>Total healing</th><th>Absorbs</th><th>Deaths</th></tr></thead><tbody>{sortedClasses.map((row) => <tr key={`${row.source}/${row.buildId}`}><td><strong>{row.className}</strong><small>{row.specialization}</small></td><td>{row.source.toUpperCase()}</td><td>{row.role}</td><td>{row.partyConfigurations.toLocaleString()}</td><td>{row.runs.toLocaleString()}</td><td>{formatNumber(row.bossDps)}</td><td>{formatNumber(row.dps)}</td><td>{formatNumber(row.hps)}</td><td>{formatNumber(row.dtps)}</td><td>{formatWhole(row.totalDamageTaken)}</td><td><span className={`balance-results-flag ${flagClass(row.damageFlag)}`}>{formatRatio(row.damageVsSourceRoleMedian)} · {row.damageFlag}</span></td><td>{formatWhole(row.totalDamage)}</td><td>{formatWhole(row.totalHealing)}</td><td>{formatWhole(row.totalAbsorbs)}</td><td>{row.deathEvents.toLocaleString()}</td></tr>)}</tbody></table></div>
</div>}
{tab === "abilities" && <div className="balance-results-abilities">
{allAbilities.length === 0 ? <p className="balance-results-legacy-note">This saved run predates ability-level reporting. Run the same configuration again to populate ability use, unused abilities, damage share, healing, and absorbs.</p> : <>
<div className="balance-results-summary balance-results-summary--abilities">
<article><span>Matching abilities</span><strong>{abilities.length.toLocaleString()}</strong></article>
<article><span>Used in at least one run</span><strong>{usedAbilityCount.toLocaleString()}</strong></article>
<article><span>Available but unused</span><strong>{unusedAbilityCount.toLocaleString()}</strong></article>
<article><span>Filtered effective damage</span><strong>{formatWhole(filteredAbilityDamage)}</strong></article>
</div>
<BarChart title="Ability damage contribution" description="Effective damage by ability for the selected source, role, class/spec, status, and text filters." rows={abilityChart("effectiveDamage")} tone="class" />
<div className="balance-results-table-panel">
<header><div><h3>Every class/spec ability</h3><p>{sortedAbilities.length} matching abilities · {availableAbilityCount} are available to the tested builds. Zero-use rows are deliberately retained.</p></div><div className="balance-results-ability-controls">
<select aria-label="Filter ability class spec" value={abilityBuild} onChange={(event) => setAbilityBuild(event.currentTarget.value)}><option value="all">All class/specs</option>{abilityBuildOptions.map(([key, row]) => <option key={key} value={key}>{row.source.toUpperCase()} · {row.className} — {row.specialization}</option>)}</select>
<select aria-label="Filter ability usage" value={abilityStatus} onChange={(event) => setAbilityStatus(event.currentTarget.value)}><option value="all">All ability states</option><option value="used">Used in any run</option><option value="unused">Available but unused</option><option value="unused-damage">Unused damage abilities</option><option value="damage">Dealt damage</option><option value="support">Healed or absorbed</option></select>
<select aria-label="Sort abilities" value={abilitySort} onChange={(event) => setAbilitySort(event.currentTarget.value as keyof BalanceLabClassAbilityResult)}><option value="effectiveDamage">Effective damage</option><option value="damageShare">Damage share</option><option value="dps">DPS</option><option value="averageDamagePerHit">Damage per hit</option><option value="activeRuns">Active runs</option><option value="usageRate">Usage rate</option><option value="casts">Casts</option><option value="castsPerMinute">Casts per minute</option><option value="hits">Hits</option><option value="effectiveHealing">Effective healing</option><option value="overheal">Overheal</option><option value="absorbs">Absorbs</option></select>
<button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button>
</div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Ability</th><th>Class / specialization</th><th>Source</th><th>Role</th><th>AI category</th><th>Usage state</th><th>Active runs</th><th>Usage rate</th><th>Casts</th><th>Casts / min</th><th>Hits</th><th>Effective damage</th><th>Damage share</th><th>DPS</th><th>Damage / hit</th><th>Raw damage</th><th>Effective healing</th><th>Overheal</th><th>Absorbs</th><th>Cooldown</th></tr></thead><tbody>{sortedAbilities.map((row) => <tr key={`${row.source}/${row.buildId}/${row.abilityId}`}>
<td><span className="balance-results-ability-name">{row.icon && <img src={row.icon} alt="" />}<span><strong>{row.abilityName}</strong><small>{row.abilityId}</small></span></span></td>
<td><strong>{row.className}</strong><small>{row.specialization}</small></td><td>{row.source.toUpperCase()}</td><td>{row.role}</td><td>{row.categories || "—"}</td><td><span className={`balance-results-flag ${row.unusedAvailable ? "is-high" : row.status === "support" ? "is-low" : "is-baseline"}`}>{row.unusedAvailable ? "available · unused" : row.status}</span></td><td>{row.activeRuns}/{row.runs}</td><td>{formatPercent(row.usageRate)}</td><td>{row.casts.toLocaleString()}</td><td>{formatNumber(row.castsPerMinute)}</td><td>{row.hits.toLocaleString()}</td><td>{formatWhole(row.effectiveDamage)}</td><td>{formatPercent(row.damageShare)}</td><td>{formatNumber(row.dps)}</td><td>{formatNumber(row.averageDamagePerHit)}</td><td>{formatWhole(row.rawDamage)}</td><td>{formatWhole(row.effectiveHealing)}</td><td>{formatWhole(row.overheal)}</td><td>{formatWhole(row.absorbs)}</td><td>{row.cooldownSeconds === null ? "—" : formatSeconds(row.cooldownSeconds)}</td>
</tr>)}</tbody></table></div>
</div>
</>}
</div>}
{tab === "parties" && <div className="balance-results-table-panel">
<header><div><h3>Every party configuration</h3><p>{sortedParties.length} matching parties · compare composition output, healer pressure, outcomes, and deaths.</p></div><div className="balance-results-sort"><select aria-label="Sort parties" value={partySort} onChange={(event) => setPartySort(event.currentTarget.value as keyof BalanceLabPartyResult)}><option value="partyDps">Party DPS</option><option value="incomingDtps">Incoming DTPS</option><option value="defeatRate">Defeat rate</option><option value="wipeRate">Wipe rate</option><option value="averageDeaths">Average deaths</option><option value="runs">Runs</option></select><button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button></div></header>
<div className="balance-results-table-wrap"><table><thead><tr><th>Party</th><th>Source</th><th>Members</th><th>Runs</th><th>Party DPS</th><th>Incoming DTPS</th><th>Defeat</th><th>Wipe</th><th>Avg deaths</th></tr></thead><tbody>{sortedParties.map((row) => <tr key={row.partyConfigurationId}><td><strong>{row.partyConfigurationId}</strong></td><td>{row.source.toUpperCase()}</td><td className="balance-results-members">{row.members}</td><td>{row.runs.toLocaleString()}</td><td>{formatNumber(row.partyDps)}</td><td>{formatNumber(row.incomingDtps)}</td><td>{formatPercent(row.defeatRate)}</td><td>{formatPercent(row.wipeRate)}</td><td>{formatNumber(row.averageDeaths)}</td></tr>)}</tbody></table></div>
</div>}
</section>
);
}
+296
View File
@@ -0,0 +1,296 @@
import { useEffect, useMemo, useState } from "react";
import { sessionCanUseGmTools } from "../app/releaseFeatures";
import { useShellStore } from "../app/shellStore";
import {
fetchBalanceLabArtifact,
getBalanceLabJob,
getBalanceLabReport,
listBalanceLabJobs,
startBalanceLabJob,
type BalanceLabBossCategory,
type BalanceLabJob,
type BalanceLabPartySource,
type BalanceLabSuiteReport,
} from "../app/balanceLabClient";
import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
import { BalanceLabResults } from "./BalanceLabResults";
import { BrandMark, FrontSurface } from "./FrontSurface";
type BossScope = BalanceLabBossCategory | "specific";
const ARTIFACT_LABELS: Readonly<Record<string, string>> = {
"suite-report.html": "Open full report",
"suite-report.json": "Download report JSON",
"suite-report.md": "Download report Markdown",
"party_configurations.csv": "Party configurations CSV",
"class_specs_across_parties.csv": "Class/spec totals CSV",
"class_abilities_across_parties.csv": "Class/spec ability breakdown CSV",
"class_spec_by_boss.csv": "Class/spec by boss CSV",
"bosses_across_parties.csv": "Boss totals CSV",
"boss_by_party.csv": "Boss by party CSV",
"suite_runs.csv": "Every simulation CSV",
"suite-config.json": "Exact rerun configuration",
};
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "The Balance Lab request failed.";
}
function progressPercent(job: BalanceLabJob): number {
if (job.status === "complete") return 100;
if (job.status === "queued" || job.progress.partiesTotal < 1) return 0;
const currentFraction = job.progress.simulationsTotal > 0
? job.progress.simulationsCompleted / job.progress.simulationsTotal
: 0;
return Math.max(0, Math.min(99, ((job.progress.partiesCompleted + currentFraction) / job.progress.partiesTotal) * 100));
}
function describeJob(job: BalanceLabJob): string {
if (job.status === "queued") return "Waiting for the current server job to finish.";
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.`
: `Completed ${job.progress.partiesTotal} party configurations.`;
const party = job.progress.party || job.progress.partiesCompleted + 1;
const simulation = job.progress.simulationsTotal > 0
? ` · current party ${job.progress.simulationsCompleted}/${job.progress.simulationsTotal} boss simulations`
: "";
return `Party ${party}/${job.progress.partiesTotal}${simulation}`;
}
export function BalanceLabScreen({ onClose }: { readonly onClose: () => void }) {
const session = useShellStore((state) => state.session);
const token = session?.accessToken ?? "";
const [source, setSource] = useState<BalanceLabPartySource>("all");
const [level, setLevel] = useState(60);
const [partyConfigurations, setPartyConfigurations] = useState(10);
const [simulationsPerParty, setSimulationsPerParty] = useState(3);
const [durationSeconds, setDurationSeconds] = useState(60);
const [partySeed, setPartySeed] = useState(1);
const [bossScope, setBossScope] = useState<BossScope>("all");
const [bossSearch, setBossSearch] = useState("");
const [selectedBossKeys, setSelectedBossKeys] = useState<readonly string[]>([]);
const [jobs, setJobs] = useState<readonly BalanceLabJob[]>([]);
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [jobSearch, setJobSearch] = useState("");
const [jobStatus, setJobStatus] = useState<"all" | BalanceLabJob["status"]>("all");
const [reports, setReports] = useState<Readonly<Record<string, BalanceLabSuiteReport>>>({});
const [reportLoadingId, setReportLoadingId] = useState<string | null>(null);
const [reportError, setReportError] = useState("");
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const selectedJob = jobs.find((job) => job.id === selectedJobId) ?? jobs[0] ?? null;
const selectedReport = selectedJob ? reports[selectedJob.id] ?? null : null;
const visibleJobs = useMemo(() => {
const query = jobSearch.trim().toLowerCase();
return jobs.filter((job) => (
(jobStatus === "all" || job.status === jobStatus)
&& (!query || `${job.requestedBy} ${job.spec.source} ${job.spec.level} ${job.id} ${job.status}`.toLowerCase().includes(query))
));
}, [jobSearch, jobStatus, jobs]);
const filteredBosses = useMemo(() => {
const query = bossSearch.trim().toLowerCase();
return BOSS_TESTING_CATALOG.filter((boss) => !query
|| `${boss.name} ${boss.dungeonTitle} ${boss.key} ${boss.category}`.toLowerCase().includes(query));
}, [bossSearch]);
const selectedBossSet = useMemo(() => new Set(selectedBossKeys), [selectedBossKeys]);
const bossCount = bossScope === "specific"
? selectedBossKeys.length
: BOSS_TESTING_CATALOG.filter((boss) => bossScope === "all" || boss.category === bossScope).length;
const partyCount = partyConfigurations * (source === "all" ? 3 : 1);
const estimatedRuns = partyCount * bossCount * simulationsPerParty;
useEffect(() => {
if (!token) return;
let cancelled = false;
setLoading(true);
void listBalanceLabJobs(token).then((nextJobs) => {
if (cancelled) return;
setJobs(nextJobs);
setSelectedJobId((current) => current ?? nextJobs[0]?.id ?? null);
setError("");
}).catch((reason) => {
if (!cancelled) setError(errorMessage(reason));
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [token]);
useEffect(() => {
if (!token || !selectedJob || (selectedJob.status !== "queued" && selectedJob.status !== "running")) return;
let cancelled = false;
const timer = window.setInterval(() => {
void getBalanceLabJob(selectedJob.id, token).then((updated) => {
if (cancelled) return;
setJobs((current) => [updated, ...current.filter((job) => job.id !== updated.id)]);
}).catch((reason) => {
if (!cancelled) setError(errorMessage(reason));
});
}, 1_500);
return () => { cancelled = true; window.clearInterval(timer); };
}, [selectedJob, token]);
useEffect(() => {
if (!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) => {
if (!cancelled) setReports((current) => ({ ...current, [selectedJob.id]: report }));
}).catch((reason) => {
if (!cancelled) setReportError(errorMessage(reason));
}).finally(() => {
if (!cancelled) setReportLoadingId((current) => current === selectedJob.id ? null : current);
});
return () => { cancelled = true; };
}, [selectedJob, selectedReport, token]);
if (!sessionCanUseGmTools(session)) {
return (
<FrontSurface className="gm-admin-screen" ariaLabel="Balance Lab access denied">
<section className="gm-admin-denied">
<BrandMark />
<h1>Game Master access required</h1>
<p>Server simulations are available only to server-authorized GM accounts.</p>
<button className="front-button" type="button" onClick={onClose}>Back to main menu</button>
</section>
</FrontSurface>
);
}
const toggleBoss = (key: string) => {
setSelectedBossKeys((current) => current.includes(key)
? current.filter((candidate) => candidate !== key)
: [...current, key]);
};
const run = async () => {
if (bossScope === "specific" && selectedBossKeys.length === 0) {
setError("Select at least one boss for a specific-boss run.");
return;
}
setSubmitting(true);
setError("");
try {
const job = await startBalanceLabJob({
source,
level,
partyConfigurations,
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);
} catch (reason) {
setError(errorMessage(reason));
} finally {
setSubmitting(false);
}
};
const openArtifact = async (job: BalanceLabJob, name: string) => {
const preview = name.endsWith(".html") ? window.open("", "_blank") : null;
setError("");
try {
const blob = await fetchBalanceLabArtifact(job.id, name, token);
const url = URL.createObjectURL(blob);
if (preview) preview.location.href = url;
else {
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
anchor.click();
}
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (reason) {
preview?.close();
setError(errorMessage(reason));
}
};
return (
<FrontSurface className="balance-lab-screen" ariaLabel="GM Balance Lab">
<header className="gm-admin-header">
<BrandMark compact />
<div><span>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>
</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>
<div className="balance-lab-form-grid">
<label><span>Party source</span><select value={source} onChange={(event) => setSource(event.currentTarget.value as BalanceLabPartySource)}>
<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>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"><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>
</select></label>
</div>
{bossScope === "specific" && (
<section className="balance-lab-boss-picker">
<header><input aria-label="Search Balance Lab bosses" value={bossSearch} onChange={(event) => setBossSearch(event.currentTarget.value)} placeholder="Search boss, dungeon, or key" /><strong>{selectedBossKeys.length} selected</strong></header>
<div role="listbox" aria-label="Balance Lab bosses" aria-multiselectable="true">
{filteredBosses.map((boss) => <label key={boss.key} role="option" aria-selected={selectedBossSet.has(boss.key)} className={selectedBossSet.has(boss.key) ? "is-selected" : ""}>
<input type="checkbox" checked={selectedBossSet.has(boss.key)} onChange={() => toggleBoss(boss.key)} />
<span><strong>{boss.name}</strong><small>{boss.dungeonTitle} · {boss.category.toUpperCase()}</small></span>
</label>)}
</div>
</section>
)}
<div className="balance-lab-estimate"><span>Party configurations <b>{partyCount.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 || !Number.isFinite(estimatedRuns) || estimatedRuns < 1} onClick={() => void run()}>{submitting ? "Queuing…" : "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>}
</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={() => {
setLoading(true);
void listBalanceLabJobs(token).then(setJobs).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-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>
</button>)}
{!loading && visibleJobs.length === 0 && <p className="balance-lab-empty">No saved runs match these filters.</p>}
</div>
</section>
<aside className="balance-lab-detail">
{selectedJob ? <>
<p className="front-kicker">Selected job</p><h2>{selectedJob.spec.source.toUpperCase()} level {selectedJob.spec.level}</h2>
<p>{describeJob(selectedJob)}</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>
</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>}
</> : <p className="balance-lab-empty">Select a job to inspect its progress and reports.</p>}
</aside>
{selectedJob?.status === "complete" && <div className="balance-lab-analysis" id="balance-lab-analysis">
{reportLoadingId === selectedJob.id && <p className="balance-results-loading">Loading this run's complete analysis…</p>}
{reportError && !selectedReport && <p className="front-notice" role="alert">{reportError}</p>}
{selectedReport && <BalanceLabResults report={selectedReport} />}
</div>}
</main>
</FrontSurface>
);
}
+26 -6
View File
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from "react";
import { useShellStore } from "../app/shellStore";
import { BOSS_TESTING_STATUSES, BOSS_TESTING_STATUS_LABELS, useBossTestingReviews, type BossTestingStatus } from "../app/bossTestingReviews";
import { bossTestIsDefeated } from "../game/BossTestingBridge";
import { enterBossTesting, prepareBossTesting } from "../app/bossTestingRuntime";
import { anotherRandomTestingBoss, BOSS_TESTING_CATALOG } from "../game/bossTesting";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
@@ -14,6 +16,7 @@ export function BossTestingReviewPanel() {
const save = useBossTestingReviews((state) => state.save);
const refresh = useBossTestingReviews((state) => state.refresh);
const [saving, setSaving] = useState(false);
const [startingRandom, setStartingRandom] = useState(false);
const [message, setMessage] = useState("");
const busy = useRef(false);
const mark = async (status: BossTestingStatus) => {
@@ -30,21 +33,37 @@ export function BossTestingReviewPanel() {
return () => window.removeEventListener("online", reconnect);
}, [refresh, session]);
const defeated = bossTestIsDefeated();
const disabled = saving || startingRandom;
const back = () => { if (!busy.current) useGameStore.getState().openOverlay("pause"); };
const retry = () => useGameStore.getState().resetAtEntrance();
const leave = () => useShellStore.getState().returnToMainMenu();
const fightRandom = async () => {
if (busy.current || !boss) return;
const next = anotherRandomTestingBoss(BOSS_TESTING_CATALOG, boss.key);
if (!next) { setMessage("No other bosses are available."); return; }
busy.current = true; setStartingRandom(true); setMessage(`Preparing ${next.name}…`);
try {
await prepareBossTesting(next);
enterBossTesting(next, useShellStore.getState().activeDungeonRole ?? undefined);
} catch (failure) {
setMessage(failure instanceof Error ? failure.message : "The next boss could not be started. Please retry.");
} finally {
busy.current = false; setStartingRandom(false);
}
};
const actions: MenuAction[] = [
...BOSS_TESTING_STATUSES.map((status) => ({ id: `boss-review-${status}`, run: () => void mark(status), enabled: !saving })),
{ id: "boss-review-report", run: () => useGameStore.getState().openOverlay("report-issue"), enabled: !saving },
{ id: "boss-review-retry", run: retry, enabled: !saving },
{ id: "boss-review-leave", run: leave, enabled: !saving },
{ id: "boss-review-back", run: back, enabled: !saving },
...BOSS_TESTING_STATUSES.map((status) => ({ id: `boss-review-${status}`, run: () => void mark(status), enabled: !disabled })),
{ id: "boss-review-report", run: () => useGameStore.getState().openOverlay("report-issue"), enabled: !disabled },
{ id: "boss-review-retry", run: retry, enabled: !disabled },
{ id: "boss-review-random", run: () => void fightRandom(), enabled: !disabled },
{ id: "boss-review-leave", run: leave, enabled: !disabled },
{ id: "boss-review-back", run: back, enabled: !disabled },
];
const controller = useMenuController(actions, { initialId: "boss-review-needs-testing", onBack: back });
if (!boss) return null;
const button = (controlId: string, label: string, run: () => void, pressed?: boolean) =>
<ControllerButton controlId={controlId} selectedId={controller.selectedId} select={controller.select}
type="button" className="button" onClick={run} disabled={saving} aria-pressed={pressed}>{label}</ControllerButton>;
type="button" className="button" onClick={run} disabled={disabled} aria-pressed={pressed}>{label}</ControllerButton>;
return <div className="modal-backdrop" role="presentation">
<section className="pause-menu boss-testing-review" role="dialog" aria-modal="true" aria-labelledby="boss-review-title">
<p className="eyebrow">{defeated ? "Boss defeated" : "Boss Testing paused"}</p><h2 id="boss-review-title">{boss.name}</h2>
@@ -56,6 +75,7 @@ export function BossTestingReviewPanel() {
<div className="pause-menu__actions">
{button("boss-review-report", "Submit a boss report", () => useGameStore.getState().openOverlay("report-issue"))}
{button("boss-review-retry", "Fight this boss again", retry)}
{button("boss-review-random", startingRandom ? "Preparing random boss…" : "Fight another random boss", () => void fightRandom())}
{button("boss-review-leave", "Return to main menu", leave)}
{button("boss-review-back", "Back to pause menu", back)}
</div>
+249
View File
@@ -0,0 +1,249 @@
import { useEffect, useMemo, useState } from "react";
import {
deleteDungeonSimulationReplay,
fetchDungeonSimulationReplay,
fetchDungeonSimulationReplays,
fetchDungeonSimulationReport,
fetchGmDungeonRoutes,
putDungeonRouteReview,
} from "../app/dungeonSimulationClient";
import { enterDungeonSimulation, enterDungeonSimulationReplay } from "../app/dungeonSimulationRuntime";
import { sessionCanUseGmTools } from "../app/releaseFeatures";
import { useShellStore } from "../app/shellStore";
import { DUNGEON_CATALOG } from "../game/dungeonCatalog";
import { DUNGEON_ROUTE_CATALOG } from "../game/dungeonRoutes";
import type {
DungeonReplaySummary,
DungeonRouteReview,
DungeonRouteReviewStatus,
DungeonSimulationMode,
DungeonSimulationReportV1,
} from "../game/dungeonSimulationTypes";
import { BrandMark, FrontSurface } from "./FrontSurface";
function saveReport(report: DungeonSimulationReportV1): void {
const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2)], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${report.dungeonId}-${report.seed}-SimulationReportV1.json`;
anchor.click();
URL.revokeObjectURL(url);
}
export function DungeonSimulationAdminScreen() {
const session = useShellStore((state) => state.session);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
const [selectedDungeonId, setSelectedDungeonId] = useState(DUNGEON_ROUTE_CATALOG.routes[0]?.dungeonId ?? "");
const [mode, setMode] = useState<DungeonSimulationMode>("navigation-only");
const [seed, setSeed] = useState("route-review-001");
const [reviews, setReviews] = useState<readonly DungeonRouteReview[]>([]);
const [replays, setReplays] = useState<readonly DungeonReplaySummary[]>([]);
const [reviewStatus, setReviewStatus] = useState<DungeonRouteReviewStatus>("Candidate");
const [reviewNote, setReviewNote] = useState("");
const [overrideNote, setOverrideNote] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
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);
const storedReview = reviews.find((candidate) => candidate.dungeonId === route?.dungeonId
&& candidate.routeId === route?.id) ?? null;
const review = storedReview?.routeHash === route?.hash ? storedReview : null;
const routeReplays = useMemo(() => replays.filter((replay) => (
replay.dungeonId === route?.dungeonId && replay.routeId === route?.id && replay.routeHash === route?.hash
)), [replays, route?.dungeonId, route?.hash, route?.id]);
const refresh = async () => {
if (!sessionCanUseGmTools(session)) return;
setBusy(true);
try {
const [routes, recordings] = await Promise.all([
fetchGmDungeonRoutes(session),
fetchDungeonSimulationReplays(session),
]);
setReviews(routes.reviews);
setReplays(recordings);
setMessage("");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Unable to load route reviews.");
} finally {
setBusy(false);
}
};
useEffect(() => { void refresh(); }, [session]);
useEffect(() => {
setReviewStatus(review?.status ?? (route?.generationStatus === "Rejected" ? "NeedsWork" : "Candidate"));
setReviewNote(review?.note ?? "");
setOverrideNote(review?.criticalOverrideNote ?? "");
}, [review?.revision, route?.hash]);
if (!sessionCanUseGmTools(session)) {
return (
<FrontSurface className="gm-admin-screen" ariaLabel="GM access denied">
<section className="gm-admin-denied">
<BrandMark />
<h1>Game Master access required</h1>
<p>Dungeon simulation artifacts require a server-authenticated GM account.</p>
<button className="front-button" type="button" onClick={returnToMainMenu}>Return to main menu</button>
</section>
</FrontSurface>
);
}
const saveReview = async () => {
if (!route) return;
setBusy(true);
try {
const updated = await putDungeonRouteReview(session, {
dungeonId: route.dungeonId,
routeId: route.id,
routeHash: route.hash,
}, {
status: reviewStatus,
expectedRevision: storedReview?.revision ?? 0,
note: reviewNote,
criticalOverrideNote: overrideNote,
});
setReviews((current) => [...current.filter((candidate) => !(
candidate.dungeonId === updated.dungeonId && candidate.routeId === updated.routeId
)), updated]);
setMessage(`Review saved at revision ${updated.revision}.`);
} catch (error) {
setMessage(error instanceof Error ? error.message : "Unable to save review.");
} finally {
setBusy(false);
}
};
const openReplay = async (id: string) => {
setBusy(true);
try {
const replay = await fetchDungeonSimulationReplay(session, id);
if (!enterDungeonSimulationReplay(replay)) setMessage("The replay could not be opened.");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Unable to load replay.");
setBusy(false);
}
};
const removeReplay = async (id: string) => {
setBusy(true);
try {
await deleteDungeonSimulationReplay(session, id);
setReplays((current) => current.filter((candidate) => candidate.id !== id));
setMessage("Recording deleted.");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Unable to delete replay.");
} finally {
setBusy(false);
}
};
const downloadReplayReport = async (id: string) => {
setBusy(true);
try {
saveReport(await fetchDungeonSimulationReport(session, id));
setMessage("");
} catch (error) {
setMessage(error instanceof Error ? error.message : "Unable to download report.");
} finally {
setBusy(false);
}
};
return (
<FrontSurface className="dungeon-sim-admin" ariaLabel="Dungeon simulation route review">
<header className="dungeon-sim-admin__header">
<BrandMark />
<div><p className="eyebrow">Game Master tools</p><h1>Dungeon route simulations</h1></div>
<div className="dungeon-sim-admin__header-actions">
<button className="front-button" type="button" onClick={() => void refresh()} disabled={busy}>Refresh</button>
<button className="front-button" type="button" onClick={returnToMainMenu}>Main menu</button>
</div>
</header>
<main className="dungeon-sim-admin__layout">
<section className="dungeon-sim-admin__catalog">
<label>
<span>Dungeon</span>
<select value={selectedDungeonId} onChange={(event) => setSelectedDungeonId(event.target.value)}>
{DUNGEON_ROUTE_CATALOG.routes.map((candidate) => {
const entry = DUNGEON_CATALOG.find((item) => item.id === candidate.dungeonId);
return <option key={candidate.dungeonId} value={candidate.dungeonId}>{entry?.title ?? candidate.dungeonId}</option>;
})}
</select>
</label>
<div className="dungeon-sim-admin__route-meta">
<span className={`route-review-status route-review-status--${(
review?.status ?? (route?.generationStatus === "Rejected" ? "NeedsWork" : "Candidate")
).toLowerCase()}`}>
{review?.status ?? (route?.generationStatus === "Rejected" ? "NeedsWork" : "Candidate")}
</span>
<strong>{dungeon?.title ?? route?.dungeonId}</strong>
<code>{route?.hash}</code>
<small>{route?.legs.length ?? 0} required legs · {route?.legs.reduce((sum, leg) => sum + leg.checkpoints.length, 0) ?? 0} checkpoints</small>
{route?.generationStatus === "Rejected" && <small>Offline rejection: {route.generationIssues.join(", ")}</small>}
</div>
<fieldset>
<legend>Launch live simulation</legend>
<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>
</select></label>
<label><span>Deterministic seed</span><input value={seed} maxLength={96} onChange={(event) => setSeed(event.target.value)} /></label>
<button
className="front-button front-button--primary"
type="button"
disabled={busy || !route || !seed.trim()}
onClick={() => route && enterDungeonSimulation(route.dungeonId, mode, seed)}
>Launch spectate mode</button>
</fieldset>
<fieldset>
<legend>Exact-hash review</legend>
<label><span>Status</span><select value={reviewStatus} onChange={(event) => setReviewStatus(event.target.value as DungeonRouteReviewStatus)}>
<option>Candidate</option><option disabled={route?.generationStatus === "Rejected"}>Approved</option><option>NeedsWork</option>
</select></label>
<label><span>Reviewer note</span><textarea value={reviewNote} maxLength={1_000} onChange={(event) => setReviewNote(event.target.value)} /></label>
<label><span>Critical-finding override</span><textarea value={overrideNote} maxLength={1_000} placeholder="Required only when approving a run with critical findings." onChange={(event) => setOverrideNote(event.target.value)} /></label>
<button className="front-button" type="button" onClick={() => void saveReview()} disabled={busy || !route}>Save review</button>
</fieldset>
{message && <p className="front-notice" role="status">{message}</p>}
</section>
<section className="dungeon-sim-admin__details">
<header><div><p className="eyebrow">Server recordings</p><h2>{routeReplays.length} runs for this route</h2></div></header>
<div className="dungeon-sim-admin__replays">
{routeReplays.map((replay) => (
<article key={replay.id}>
<div>
<strong>{replay.mode.replace("-", " ")} · {replay.result}</strong>
<small>{new Date(replay.createdAt).toLocaleString()} · seed {replay.seed}</small>
<small>{Math.round(replay.durationMs / 100) / 10}s · {replay.criticalFindings} critical · {(replay.compressedBytes / 1024).toFixed(1)} KiB</small>
</div>
<div>
<button className="front-button" type="button" disabled={busy} onClick={() => void openReplay(replay.id)}>Watch</button>
<button className="front-button" type="button" disabled={busy} onClick={() => void downloadReplayReport(replay.id)}>JSON</button>
<button className="front-button" type="button" disabled={busy} onClick={() => void removeReplay(replay.id)}>Delete</button>
</div>
</article>
))}
{!routeReplays.length && <p className="front-notice">No recordings exist for this route hash yet.</p>}
</div>
<h2>Canonical route legs</h2>
<ol className="dungeon-sim-admin__legs">
{route?.legs.map((leg) => (
<li key={leg.id}>
<strong>{leg.objectiveName}</strong>
<span>{leg.checkpoints.length} checkpoints · {leg.expectedDistance.toFixed(1)} m</span>
<small>{leg.actions.length ? `${leg.actions.length} required actions` : "No explicit interaction"}</small>
</li>
))}
</ol>
</section>
</main>
</FrontSurface>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useDungeonSimulationStore } from "../game/dungeonSimulationStore";
import { dungeonRouteDefinition } from "../game/dungeonRoutes";
import type { DungeonSimulationSpeed } from "../game/dungeonSimulationTypes";
import { finalizeDungeonSimulation } from "../scene/DungeonSimulationSceneBridge";
import { useShellStore } from "../app/shellStore";
const SPEEDS: readonly DungeonSimulationSpeed[] = [0.5, 1, 2, 4];
function formatTime(elapsedMs: number): string {
const seconds = Math.floor(elapsedMs / 1_000);
return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}.${Math.floor((elapsedMs % 1_000) / 100)}`;
}
function downloadReport(): void {
const simulation = useDungeonSimulationStore.getState();
const report = simulation.replay?.report;
if (!report) return;
const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2)], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${report.dungeonId}-${report.seed}-SimulationReportV1.json`;
anchor.click();
URL.revokeObjectURL(url);
}
export function DungeonSimulationHud() {
const simulation = useDungeonSimulationStore();
const route = simulation.dungeonId
? dungeonRouteDefinition(simulation.dungeonId, simulation.routeId ?? "primary")
: null;
const leg = route?.legs.find((candidate) => candidate.objectiveId === simulation.objectiveId) ?? null;
const replayDuration = simulation.replay?.durationMs ?? 0;
const returnToGmTools = () => {
useDungeonSimulationStore.getState().leave();
useShellStore.getState().openGmAdmin();
};
return (
<aside className="dungeon-sim-hud" aria-label="Dungeon simulation controls">
<div className="dungeon-sim-hud__header">
<div>
<small>{simulation.sessionMode === "spectate-replay" ? "Recorded replay" : simulation.mode.replace("-", " ")}</small>
<strong>{simulation.dungeonId ?? "Dungeon simulation"}</strong>
</div>
<time>{formatTime(simulation.elapsedMs)}</time>
</div>
<div className="dungeon-sim-hud__controls">
<button type="button" onClick={simulation.toggleRunning} disabled={simulation.runState === "finished"}>
{simulation.runState === "running" ? "Pause" : "Play"}
</button>
<button type="button" onClick={simulation.step} disabled={simulation.runState !== "paused"}>Step</button>
{SPEEDS.map((speed) => (
<button
type="button"
key={speed}
className={simulation.speed === speed ? "is-active" : undefined}
onClick={() => simulation.setSpeed(speed)}
>
{speed}×
</button>
))}
<button type="button" onClick={() => simulation.setCameraMode(
simulation.cameraMode === "follow-leader" ? "free-fly" : "follow-leader",
)}>
{simulation.cameraMode === "follow-leader" ? "Follow camera" : "Free-fly camera"}
</button>
{simulation.sessionMode === "spectate-live" && simulation.runState !== "finished" ? (
<button type="button" className="is-danger" onClick={() => void finalizeDungeonSimulation("stopped")}>
Stop & save
</button>
) : null}
{simulation.replay && <button type="button" onClick={downloadReport}>Download JSON</button>}
{simulation.runState === "finished" && <button type="button" onClick={returnToGmTools}>Return to GM Tools</button>}
</div>
{simulation.sessionMode === "spectate-replay" && (
<label className="dungeon-sim-hud__seek">
<span>Replay position</span>
<input
type="range"
min={0}
max={replayDuration}
step={100}
value={Math.min(simulation.elapsedMs, replayDuration)}
onChange={(event) => simulation.seekReplay(Number(event.target.value))}
/>
</label>
)}
<dl className="dungeon-sim-hud__status">
<div><dt>State</dt><dd>{simulation.runState}{simulation.result ? ` · ${simulation.result}` : ""}</dd></div>
<div><dt>Objective</dt><dd>{leg?.objectiveName ?? simulation.objectiveId ?? "Complete"}</dd></div>
<div><dt>Checkpoint</dt><dd>{leg ? `${Math.min(simulation.checkpointIndex + 1, leg.checkpoints.length)} / ${leg.checkpoints.length}` : "—"}</dd></div>
<div><dt>Route</dt><dd>{simulation.routeHash?.slice(0, 12) ?? "—"}</dd></div>
</dl>
<section className="dungeon-sim-hud__findings">
<header><strong>Findings</strong><span>{simulation.findings.length}</span></header>
<ol>
{[...simulation.findings].reverse().slice(0, 12).map((finding) => (
<li key={finding.id} data-severity={finding.severity}>
<time>{formatTime(finding.atMs)}</time>
<span>{finding.message}</span>
</li>
))}
</ol>
</section>
</aside>
);
}
+39 -18
View File
@@ -9,11 +9,12 @@ import { ControllerButton } from "./ControllerButton";
import { ContentManagerScreen } from "./ContentManagerScreen";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { sessionCanUseGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { DungeonReportsScreen } from "./DungeonReportsScreen";
import { MainMenuCharacterEditor, type MainMenuEditor } from "./MainMenuCharacterEditor";
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 GAME_MODES = [
{
@@ -71,20 +72,23 @@ export function MainMenuScreen() {
const [reportsOpen, setReportsOpen] = useState(false);
const [cardsOpen, setCardsOpen] = useState(false);
const [bossTestingOpen, setBossTestingOpen] = useState(false);
const [balanceLabOpen, setBalanceLabOpen] = 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 (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)} onOpenEditor={setEditor} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenEditor={setEditor} />;
}
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenEditor }: {
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenEditor }: {
readonly onOpenDownloads: () => void;
readonly onOpenReports: () => void;
readonly onOpenCards: () => void;
readonly onOpenBossTesting: () => void;
readonly onOpenBalanceLab: () => void;
readonly onOpenEditor: (editor: MainMenuEditor) => void;
}) {
const activeCharacter = useShellStore((state) => state.activeCharacter);
@@ -99,7 +103,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
const openGmAdmin = useShellStore((state) => state.openGmAdmin);
const character = activeCharacter ?? rosterCharacter;
const isRom = character?.categoryId === "rom";
const isGm = sessionCanUseShelvedGmTools(session);
const isGm = sessionCanUseGmTools(session);
const actions = useMemo<MenuAction[]>(() => [
{
@@ -132,11 +136,14 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
{ id: "mode-keybinds", run: () => onOpenEditor("keybinds") },
{ id: "mode-bindings", run: () => onOpenEditor("bindings") },
] : []),
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
...(isGm ? [
{ id: "mode-gm-tools", run: openGmAdmin },
{ id: "mode-balance-lab", run: onOpenBalanceLab },
] : []),
{ id: "mode-reports", run: onOpenReports },
{ id: "mode-cards", run: onOpenCards },
{ id: "mode-boss-testing", run: onOpenBossTesting },
], [character, isGm, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
], [character, isGm, onOpenBalanceLab, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
const controller = useMenuController(actions, {
initialId: "mode-dungeons",
onBack: returnToCharacters,
@@ -221,18 +228,32 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
<em>Open</em>
</ControllerButton>
{isGm && (
<ControllerButton
controlId="mode-gm-tools"
selectedId={controller.selectedId}
select={controller.select}
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm"
type="button"
onClick={openGmAdmin}
>
<b aria-hidden="true">GM</b>
<span><strong>GM Tools</strong><small>Manage Manastorm rules, boss readiness, and encounter placement.</small></span>
<em>Authorized</em>
</ControllerButton>
<div className="main-menu-gm-stack">
<ControllerButton
controlId="mode-gm-tools"
selectedId={controller.selectedId}
select={controller.select}
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm"
type="button"
onClick={openGmAdmin}
>
<b aria-hidden="true">GM</b>
<span><strong>GM Tools</strong><small>Run dungeon route simulations, inspect findings, and review recordings.</small></span>
<em>Authorized</em>
</ControllerButton>
<ControllerButton
controlId="mode-balance-lab"
selectedId={controller.selectedId}
select={controller.select}
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm"
type="button"
onClick={onOpenBalanceLab}
>
<b aria-hidden="true">BL</b>
<span><strong>Balance Lab</strong><small>Queue automatic party and boss simulations on the game server.</small></span>
<em>Server</em>
</ControllerButton>
</div>
)}
{character && (
<ControllerButton