Update Healer Man server

This commit is contained in:
phenom committed 2026-09-20 21:50:50 -04:00
1 parent fdb6739f8d
commit f90ba7d989
41 files changed
+1671 -118

No files matched your search

+24 -10
View File
@@ -31,6 +31,7 @@ import { BrandMark, FrontSurface } from "./FrontSurface";
import { CompanionPoolControls } from "./CompanionPoolControls";
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
import { useCompanionPartyPreferences } from "../game/companionPartyPreferences";
import type { CompanionSpecReadinessPolicy } from "../game/companionSpecReadiness";
import { useCompanionSpecReadinessStore } from "../game/companionSpecReadinessStore";
type BossScope = BalanceLabBossCategory | "specific";
@@ -93,6 +94,17 @@ function buildIdentity(build: SelectableBenchmarkBuildCatalogEntry): string {
return `${build.source}:${build.classId}:${build.role}:${build.orientation}:${build.talentBuildId ?? "default"}`;
}
function readinessEligible(
build: SelectableBenchmarkBuildCatalogEntry,
policy: CompanionSpecReadinessPolicy,
includeBetaSpecs: boolean,
): boolean {
const status = policy.statuses[build.specializationId ?? ""]
?? build.readinessStatus
?? "needs-testing";
return status === "ready" || (includeBetaSpecs && status === "beta");
}
function randomPartyCapacity(builds: readonly SelectableBenchmarkBuildCatalogEntry[]): number {
const defaults = builds.filter((build) => build.defaultTalentBuild);
const pool = defaults.length > 0 ? defaults : builds;
@@ -123,7 +135,7 @@ export function BalanceLabScreen({
const session = useShellStore((state) => state.session);
const local = executionTarget === "local";
const token = local ? "" : session?.accessToken ?? "";
const [source, setSource] = useState<BalanceLabPartySource>("all");
const [source, setSource] = useState<BalanceLabPartySource>("wow");
const [specSelection, setSpecSelection] = useState<BalanceLabSpecSelection>("random");
const [specSearch, setSpecSearch] = useState("");
const [selectedBuildIds, setSelectedBuildIds] = useState<readonly string[]>([]);
@@ -175,11 +187,9 @@ export function BalanceLabScreen({
|| 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 automaticBuilds = useMemo(() => availableBuilds.filter((build) => (
readinessEligible(build, readinessPolicy, includeBetaSpecs)
)), [availableBuilds, includeBetaSpecs, readinessPolicy]);
const selectedBuilds = useMemo(() => availableBuilds.filter((build) => selectedBuildSet.has(build.id)), [
availableBuilds,
selectedBuildSet,
@@ -198,7 +208,8 @@ export function BalanceLabScreen({
? selectedBossKeys.length
: BOSS_TESTING_CATALOG.filter((boss) => bossScope === "all" || boss.category === bossScope).length;
const coverageBuilds = specSelection === "all" ? automaticBuilds : selectedBuilds;
const requestedRandomPartyCount = partyConfigurations * (source === "all" ? 3 : 1);
const eligibleSourceCount = new Set(automaticBuilds.map((build) => build.source)).size;
const requestedRandomPartyCount = partyConfigurations * eligibleSourceCount;
const partyCount = specSelection === "random"
? cappedRandomPartyCount(automaticBuilds, partyConfigurations)
: coveragePartyCount(coverageBuilds);
@@ -310,7 +321,9 @@ export function BalanceLabScreen({
.filter((build) => source === "all" || build.source === source);
const chosenBuilds = specSelection === "specific"
? levelBuilds.filter((build) => selectedIdentities.has(buildIdentity(build)))
: specSelection === "all" ? levelBuilds : [];
: specSelection === "all"
? levelBuilds.filter((build) => readinessEligible(build, readinessPolicy, includeBetaSpecs))
: [];
if (specSelection === "specific" && chosenBuilds.length !== selectedIdentities.size) {
throw new Error(`One or more selected class/spec builds are unavailable at level ${targetLevel}.`);
}
@@ -393,7 +406,7 @@ export function BalanceLabScreen({
setSelectedBuildIds([]);
setSpecSearch("");
}}>
<option value="all">WoW + CoA + RoM</option><option value="wow">WoW only</option><option value="coa">CoA only</option><option value="rom">RoM only</option>
<option value="all">All sources (Ready only)</option><option value="wow">WoW only</option><option value="coa">CoA only (Needs Testing)</option><option value="rom">RoM only (Needs Testing)</option>
</select></label>
<label><span>Level coverage</span><select value={levelCoverage} onChange={(event) => {
setLevelCoverage(event.currentTarget.value as LevelCoverage);
@@ -409,7 +422,7 @@ export function BalanceLabScreen({
setSelectedBuildIds([]);
}}><option value={10}>Every 10 levels</option><option value={5}>Every 5 levels</option><option value={1}>Every 1 level</option></select></label>}
<label className="balance-lab-form-grid__wide"><span>Class/spec selection</span><select value={specSelection} onChange={(event) => setSpecSelection(event.currentTarget.value as BalanceLabSpecSelection)}>
<option value="random">Random parties</option><option value="all">All specs</option><option value="specific">Specific specs</option>
<option value="random">Random Ready parties</option><option value="all">All Ready specs</option><option value="specific">Specific targeted specs</option>
</select></label>
{specSelection === "random"
? <label><span>Parties per source</span><input type="number" min={1} max={100} value={partyConfigurations} onChange={(event) => setPartyConfigurations(event.currentTarget.valueAsNumber)} /></label>
@@ -422,6 +435,7 @@ export function BalanceLabScreen({
<option value="all">Every boss</option><option value="wow">Every WoW boss</option><option value="rom">Every RoM boss</option><option value="specific">Pick specific bosses</option>
</select></label>
</div>
<p className="balance-lab-coverage-note"><strong>Certification scope: Ready only.</strong> Automatic, all-spec, bracket, regression, and seat-audit runs currently use Protection Warrior, Discipline Priest, Marksmanship Hunter, Fire Mage, and Assassination Rogue. CoA and RoM remain Needs Testing.</p>
{levelCoverage === "brackets" && <p className="balance-lab-coverage-note">
{targetLevels.length} separate level jobs: {bracketStep === 1 ? "levels 1 through 80" : targetLevels.join(", ")}. Up to {runtimeStatus?.maxConcurrent ?? 4} run at the same time; each keeps its own report.
</p>}
+16
View File
@@ -0,0 +1,16 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { BalanceTuningScreen } from "./BalanceTuningScreen";
describe("Ability and Talent Tuning screen", () => {
it("renders the searchable local GM catalogs and apply controls", () => {
const html = renderToStaticMarkup(createElement(BalanceTuningScreen, { onClose: () => undefined }));
expect(html).toContain("Ability &amp; Talent Tuning");
expect(html).toContain("Runtime overrides");
expect(html).toContain("Abilities");
expect(html).toContain("Talents");
expect(html).toContain("Apply locally");
expect(html).toContain("Reset all");
});
});
+277
View File
@@ -0,0 +1,277 @@
import { useEffect, useMemo, useState } from "react";
import { CLASSES, classById, type ClassId } from "../app/characterCatalog";
import {
refreshAbilityBalanceTuning,
sourceAbilitiesForClass,
type AbilityDefinition,
} from "../game/abilityCatalog";
import {
currentBalanceTuningPolicy,
normalizeBalanceTuningPolicy,
saveBalanceTuningPolicy,
TALENT_BALANCE_MODIFIER_KINDS,
type AbilityBalanceOverride,
type BalanceTuningPolicy,
type TalentBalanceModifierKind,
} from "../game/balanceTuning";
import { talentUsesExplicitActivation } from "../game/talentModifiers";
import {
RUNTIME_TALENT_NODES,
RUNTIME_TALENT_TREES,
type RuntimeTalentNode,
} from "../game/talentRuntimeCatalog";
import { BrandMark, FrontSurface } from "./FrontSurface";
type TuningTab = "abilities" | "talents";
const ABILITY_FIELDS: readonly {
readonly key: keyof AbilityBalanceOverride;
readonly label: string;
readonly description: string;
}[] = [
{ key: "cooldownPercent", label: "Cooldown", description: "Recharge time" },
{ key: "castTimePercent", label: "Cast time", description: "Cast or channel time" },
{ key: "resourceCostPercent", label: "Resource cost", description: "Mana, Rage, Energy, and other costs" },
{ key: "damagePercent", label: "Damage", description: "Direct, periodic, finisher, and summon damage" },
{ key: "healingPercent", label: "Healing / absorbs", description: "Direct, periodic, and shield amounts" },
{ key: "durationPercent", label: "Duration", description: "Aura, control, summon, and periodic duration" },
{ key: "radiusPercent", label: "Radius / reach", description: "Area radius, jumps, and ground reach" },
];
const TALENT_KIND_LABELS: Readonly<Record<TalentBalanceModifierKind, string>> = {
"damage-percent": "Damage done",
"critical-damage-percent": "Critical damage",
"healing-percent": "Healing done",
"damage-taken-percent": "Damage taken",
"max-health-percent": "Maximum health",
"max-resource-percent": "Maximum resource",
"resource-regeneration-percent": "Resource regeneration",
"resource-cost-percent": "Resource cost",
"critical-chance-percent": "Critical chance",
"haste-percent": "Haste",
"cast-time-percent": "Cast time",
"cooldown-percent": "Cooldown",
"armor-percent": "Armor",
"attribute-percent": "Attributes",
"range-percent": "Range",
"duration-percent": "Duration",
"threat-percent": "Threat",
};
function seconds(milliseconds: number): string {
return milliseconds <= 0 ? "None" : `${Math.round(milliseconds / 100) / 10}s`;
}
function classIdForTalent(node: RuntimeTalentNode): ClassId {
return RUNTIME_TALENT_TREES.find((tree) => tree.id === node.treeId)!.classId;
}
function talentDescription(node: RuntimeTalentNode): string {
return node.rankDescriptions.at(-1) ?? node.name;
}
function withoutKey<T>(record: Readonly<Record<string, T>>, key: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([candidate]) => candidate !== key));
}
export function BalanceTuningScreen({ onClose }: { readonly onClose: () => void }) {
const [tab, setTab] = useState<TuningTab>("abilities");
const [query, setQuery] = useState("");
const [classFilter, setClassFilter] = useState("all");
const [draft, setDraft] = useState<BalanceTuningPolicy>(() => currentBalanceTuningPolicy());
const [published, setPublished] = useState<BalanceTuningPolicy>(() => currentBalanceTuningPolicy());
const [selectedAbilityId, setSelectedAbilityId] = useState("");
const [selectedTalentId, setSelectedTalentId] = useState("");
const [talentKind, setTalentKind] = useState<TalentBalanceModifierKind>("damage-percent");
const [notice, setNotice] = useState("");
const abilities = useMemo(() => CLASSES.flatMap((definition) => (
sourceAbilitiesForClass(definition.id)
)).sort((left, right) => classById(left.classId).name.localeCompare(classById(right.classId).name)
|| left.name.localeCompare(right.name)), []);
const talents = useMemo(() => [...RUNTIME_TALENT_NODES].sort((left, right) => {
const leftClass = classIdForTalent(left);
const rightClass = classIdForTalent(right);
return classById(leftClass).name.localeCompare(classById(rightClass).name)
|| left.treeId.localeCompare(right.treeId)
|| left.row - right.row
|| left.index - right.index;
}), []);
const normalizedQuery = query.trim().toLowerCase();
const visibleAbilities = useMemo(() => abilities.filter((ability) => (
(classFilter === "all" || ability.classId === classFilter)
&& (!normalizedQuery || `${classById(ability.classId).name} ${ability.name} ${ability.description}`.toLowerCase().includes(normalizedQuery))
)), [abilities, classFilter, normalizedQuery]);
const visibleTalents = useMemo(() => talents.filter((talent) => {
const classId = classIdForTalent(talent);
return (classFilter === "all" || classId === classFilter)
&& (!normalizedQuery || `${classById(classId).name} ${talent.name} ${talent.treeId} ${talentDescription(talent)}`.toLowerCase().includes(normalizedQuery));
}), [classFilter, normalizedQuery, talents]);
const classOptions = useMemo(() => {
const ids = new Set((tab === "abilities" ? abilities.map((item) => item.classId) : talents.map(classIdForTalent)));
return CLASSES.filter((definition) => ids.has(definition.id));
}, [abilities, tab, talents]);
useEffect(() => {
if (tab === "abilities" && !visibleAbilities.some((ability) => ability.id === selectedAbilityId)) {
setSelectedAbilityId(visibleAbilities[0]?.id ?? "");
}
if (tab === "talents" && !visibleTalents.some((talent) => talent.id === selectedTalentId)) {
setSelectedTalentId(visibleTalents[0]?.id ?? "");
}
}, [selectedAbilityId, selectedTalentId, tab, visibleAbilities, visibleTalents]);
const selectedAbility = abilities.find((ability) => ability.id === selectedAbilityId) ?? null;
const selectedTalent = talents.find((talent) => talent.id === selectedTalentId) ?? null;
const dirty = JSON.stringify(draft) !== JSON.stringify(published);
const tunedCount = Object.keys(draft.abilityOverrides).length + Object.keys(draft.talentOverrides).length;
const updateAbility = (ability: AbilityDefinition, key: keyof AbilityBalanceOverride, value: number) => {
setDraft((current) => normalizeBalanceTuningPolicy({
...current,
abilityOverrides: {
...current.abilityOverrides,
[ability.id]: { ...current.abilityOverrides[ability.id], [key]: value },
},
}));
setNotice("");
};
const updateTalentScale = (talent: RuntimeTalentNode, value: number) => {
setDraft((current) => normalizeBalanceTuningPolicy({
...current,
talentOverrides: {
...current.talentOverrides,
[talent.id]: { ...current.talentOverrides[talent.id], effectScalePercent: value },
},
}));
setNotice("");
};
const updateTalentModifier = (talent: RuntimeTalentNode, kind: TalentBalanceModifierKind, value: number) => {
setDraft((current) => normalizeBalanceTuningPolicy({
...current,
talentOverrides: {
...current.talentOverrides,
[talent.id]: {
...current.talentOverrides[talent.id],
modifiersPerRank: { ...current.talentOverrides[talent.id]?.modifiersPerRank, [kind]: value },
},
},
}));
setNotice("");
};
const resetSelected = () => {
setDraft((current) => normalizeBalanceTuningPolicy(tab === "abilities" ? {
...current,
abilityOverrides: withoutKey(current.abilityOverrides, selectedAbilityId),
} : {
...current,
talentOverrides: withoutKey(current.talentOverrides, selectedTalentId),
}));
setNotice("");
};
const apply = () => {
const saved = saveBalanceTuningPolicy(draft);
refreshAbilityBalanceTuning();
setDraft(saved);
setPublished(saved);
setNotice("Local tuning is active. New fights and client-side test runs will use it.");
};
const resetAll = () => {
setDraft(normalizeBalanceTuningPolicy({}));
setNotice("");
};
return (
<FrontSurface className="balance-tuning" ariaLabel="Ability and talent balance tuning">
<header className="balance-tuning__header">
<BrandMark compact />
<div><span>Local GM sandbox</span><strong>Ability &amp; Talent Tuning</strong></div>
<div className="balance-tuning__header-actions">
<span>{tunedCount} tuned entries</span>
<button className="front-button front-button--quiet" type="button" onClick={onClose}>Back</button>
</div>
</header>
<section className="balance-tuning__intro">
<div>
<p className="front-kicker">Runtime overrides</p>
<p>Values are percentages of source data: 100 keeps the original, 50 halves it, and 200 doubles it. Changes are saved on this device and affect players, NPC companions, and client-side test runs after you apply them.</p>
</div>
<div className="balance-tuning__tabs" role="tablist" aria-label="Tuning catalog">
<button type="button" role="tab" aria-selected={tab === "abilities"} onClick={() => { setTab("abilities"); setClassFilter("all"); }}>Abilities</button>
<button type="button" role="tab" aria-selected={tab === "talents"} onClick={() => { setTab("talents"); setClassFilter("all"); }}>Talents</button>
</div>
</section>
<section className="balance-tuning__filters" aria-label="Tuning filters">
<label>Search<input type="search" value={query} onChange={(event) => setQuery(event.currentTarget.value)} placeholder={`Find ${tab === "abilities" ? "an ability" : "a talent"}`} /></label>
<label>Class<select value={classFilter} onChange={(event) => setClassFilter(event.currentTarget.value)}><option value="all">All classes</option>{classOptions.map((definition) => <option key={definition.id} value={definition.id}>{definition.name}</option>)}</select></label>
</section>
<main className="balance-tuning__workspace">
<nav className="balance-tuning__catalog" aria-label={`${tab} catalog`}>
{(tab === "abilities" ? visibleAbilities : visibleTalents).map((item) => {
const classId = tab === "abilities" ? (item as AbilityDefinition).classId : classIdForTalent(item as RuntimeTalentNode);
const selected = tab === "abilities" ? item.id === selectedAbilityId : item.id === selectedTalentId;
const overridden = tab === "abilities" ? Boolean(draft.abilityOverrides[item.id]) : Boolean(draft.talentOverrides[item.id]);
return <button key={item.id} type="button" aria-current={selected ? "true" : undefined} onClick={() => tab === "abilities" ? setSelectedAbilityId(item.id) : setSelectedTalentId(item.id)}>
<span style={{ borderColor: classById(classId).color }}><strong>{item.name}</strong><small>{classById(classId).name}{tab === "talents" ? ` · ${(item as RuntimeTalentNode).treeId.split("-").at(-1)}` : ""}</small></span>
{overridden && <em>Tuned</em>}
</button>;
})}
{!(tab === "abilities" ? visibleAbilities : visibleTalents).length && <p>No entries match these filters.</p>}
</nav>
<section className="balance-tuning__details">
{tab === "abilities" && selectedAbility && <>
<header>
<div><span>{classById(selectedAbility.classId).name} ability</span><h1>{selectedAbility.name}</h1></div>
<button type="button" disabled={!draft.abilityOverrides[selectedAbility.id]} onClick={resetSelected}>Reset ability</button>
</header>
<p className="balance-tuning__description">{selectedAbility.description}</p>
<div className="balance-tuning__source-strip">
<span>Cooldown <b>{seconds(selectedAbility.cooldownMs)}</b></span>
<span>Cast <b>{seconds(selectedAbility.castTimeMs)}</b></span>
<span>Cost <b>{selectedAbility.cost.amount} {selectedAbility.cost.resource}</b></span>
<span>Effects <b>{selectedAbility.effects.map((effect) => effect.kind).join(", ") || "utility"}</b></span>
</div>
<div className="balance-tuning__field-grid">
{ABILITY_FIELDS.map((field) => <label key={field.key}>
<span><strong>{field.label}</strong><small>{field.description}</small></span>
<span><input type="number" min="0" max="1000" step="5" value={draft.abilityOverrides[selectedAbility.id]?.[field.key] ?? 100} onChange={(event) => updateAbility(selectedAbility, field.key, Number(event.currentTarget.value))} /><em>%</em></span>
</label>)}
</div>
</>}
{tab === "talents" && selectedTalent && <>
<header>
<div><span>{classById(classIdForTalent(selectedTalent)).name} · {selectedTalent.treeId.split("-").at(-1)} tree</span><h1>{selectedTalent.name}</h1></div>
<button type="button" disabled={!draft.talentOverrides[selectedTalent.id]} onClick={resetSelected}>Reset talent</button>
</header>
<p className="balance-tuning__description">{talentDescription(selectedTalent)}</p>
{talentUsesExplicitActivation(selectedTalent.name) && <p className="balance-tuning__callout"><strong>On-use talent:</strong> its tooltip is not applied passively. Adjust its cooldown and aura duration from the Abilities tab.</p>}
<label className="balance-tuning__scale-field">
<span><strong>Supported passive effect scale</strong><small>Scales parsed damage, healing, haste, cooldown, duration, and similar passive values together.</small></span>
<span><input type="number" min="0" max="1000" step="5" disabled={talentUsesExplicitActivation(selectedTalent.name)} value={draft.talentOverrides[selectedTalent.id]?.effectScalePercent ?? 100} onChange={(event) => updateTalentScale(selectedTalent, Number(event.currentTarget.value))} /><em>%</em></span>
</label>
<section className="balance-tuning__talent-modifiers">
<header><div><strong>Supplemental modifier per rank</strong><small>Add a signed percentage-point adjustment. Negative cooldown or cast-time values make abilities faster.</small></div></header>
<div>
<select aria-label="Talent modifier type" value={talentKind} onChange={(event) => setTalentKind(event.currentTarget.value as TalentBalanceModifierKind)}>{TALENT_BALANCE_MODIFIER_KINDS.map((kind) => <option key={kind} value={kind}>{TALENT_KIND_LABELS[kind]}</option>)}</select>
<input aria-label="Talent modifier amount per rank" type="number" min="-1000" max="1000" step="1" value={draft.talentOverrides[selectedTalent.id]?.modifiersPerRank?.[talentKind] ?? 0} onChange={(event) => updateTalentModifier(selectedTalent, talentKind, Number(event.currentTarget.value))} />
<span>% per rank</span>
</div>
<ul>{Object.entries(draft.talentOverrides[selectedTalent.id]?.modifiersPerRank ?? {}).map(([kind, amount]) => <li key={kind}><span>{TALENT_KIND_LABELS[kind as TalentBalanceModifierKind]}</span><b>{Number(amount) > 0 ? "+" : ""}{amount}% / rank</b><button type="button" aria-label={`Remove ${TALENT_KIND_LABELS[kind as TalentBalanceModifierKind]}`} onClick={() => updateTalentModifier(selectedTalent, kind as TalentBalanceModifierKind, 0)}>×</button></li>)}</ul>
</section>
</>}
</section>
</main>
<footer className="balance-tuning__actions">
<span>{notice || (dirty ? "Unapplied local changes" : "Saved tuning is active")}</span>
<button className="front-button front-button--quiet" type="button" disabled={!Object.keys(draft.abilityOverrides).length && !Object.keys(draft.talentOverrides).length} onClick={resetAll}>Reset all</button>
<button className="front-button front-button--primary" type="button" disabled={!dirty} onClick={apply}>Apply locally</button>
</footer>
</FrontSurface>
);
}
+15 -10
View File
@@ -1,9 +1,10 @@
import { useMemo, useState } from "react";
import { classById } from "../app/characterCatalog";
import { classById, contentCategoryForClass } from "../app/characterCatalog";
import { sessionCanUseGmTools } from "../app/releaseFeatures";
import { useShellStore } from "../app/shellStore";
import {
WOW_COMPANION_SPECIALIZATIONS,
COMPANION_READINESS_SPECIALIZATIONS,
readinessStatusFor,
type CompanionSpecReadinessStatus,
} from "../game/companionSpecReadiness";
import { useCompanionSpecReadinessStore } from "../game/companionSpecReadinessStore";
@@ -35,18 +36,19 @@ export function CompanionReadinessScreen({ onClose }: { readonly onClose: () =>
const [status, setStatusFilter] = useState("all");
const active = editable ? draft : policy;
const rows = useMemo(() => WOW_COMPANION_SPECIALIZATIONS.filter((definition) => {
const rows = useMemo(() => COMPANION_READINESS_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);
&& (status === "all" || readinessStatusFor(definition.id, active) === 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) => {
const totals = COMPANION_READINESS_SPECIALIZATIONS.reduce<Record<CompanionSpecReadinessStatus, number>>((result, definition) => {
const value = readinessStatusFor(definition.id, active);
result[value] += 1;
return result;
}, { ready: 0, beta: 0, "needs-testing": 0 });
@@ -61,7 +63,7 @@ export function CompanionReadinessScreen({ onClose }: { readonly onClose: () =>
<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>
<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. CoA and RoM entries remain Needs Testing and are excluded from automatic certification runs.</p>
</div>
<div className="companion-readiness__totals" aria-label="Readiness totals">
{(["ready", "beta", "needs-testing"] as const).map((value) => (
@@ -81,11 +83,14 @@ export function CompanionReadinessScreen({ onClose }: { readonly onClose: () =>
<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";
const source = contentCategoryForClass(definition.classId);
const profile = source === "wow"
? runtimeDefaultPartyTalentProfile(definition.classId, definition.displayName)
: null;
const value = readinessStatusFor(definition.id, active);
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)}>
<div><strong>{definition.displayName}</strong><small>{source.toUpperCase()} · {definition.role}{profile ? ` · ${profile.characterName} · ${profile.playerbotsSpecName ?? profile.id}` : " · certification not started"}</small></div>
{editable && source === "wow" ? <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>;
+1 -1
View File
@@ -12,7 +12,7 @@ const FALLBACKS: Partial<Record<HudElementId, [number, number, number]>> = {
timeline: [.9, .52, 160], target: [.5, .15, 230], friendly: [.3, .15, 230], threat: [.78, .25, 160], dps: [.13, .8, 230],
cast: [.5, .68, 330], actions: [.5, .86, 420], hints: [.5, .65, 420], experience: [.5, .99, 900],
reticle: [.5, .5, 20], loot: [.5, .3, 320], interaction: [.5, .6, 320], manastorm: [.5, .05, 400],
completion: [.5, .39, 360], assets: [.78, .6, 200], feedback: [.5, .71, 400],
completion: [.5, .39, 360], assets: [.78, .6, 200],
objective: [.5, .1, 400], messages: [.5, .22, 350], cache: [.78, .42, 200],
};
-3
View File
@@ -523,12 +523,9 @@ export function Hud({ surface = "top", onLayoutChange, completion, onFinish, onR
activeLayer={actionLayer}
onActivate={castBinding}
inputMode={surface === "bottom" ? "gamepad" : inputMode}
feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null}
/></EditableHudElement>}
</div>
<DesktopActionBars items={abilityItems} />
<EditableHudElement id="feedback">{desktopBars && settings.showFloatingCombatText && feedback?.message
? <div className="desktop-action-feedback" role="status">{feedback.message}</div> : null}</EditableHudElement>
<EditableHudElement id="experience"><ExperienceBar level={level} current={xp} required={xpToNext} /></EditableHudElement>
<EditableHudElement id="reticle">{cameraLookActive && <span className="reticle" aria-hidden="true" />}</EditableHudElement>
+12 -3
View File
@@ -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 BalanceTuningScreen = lazy(() => import("./BalanceTuningScreen").then((module) => ({ default: module.BalanceTuningScreen })));
const CompanionReadinessScreen = lazy(() => import("./CompanionReadinessScreen").then((module) => ({ default: module.CompanionReadinessScreen })));
const RandomizedBiteSizedScreen = lazy(() => import("./RandomizedBiteSizedScreen").then((module) => ({ default: module.RandomizedBiteSizedScreen })));
@@ -82,26 +83,29 @@ export function MainMenuScreen() {
const [cardsOpen, setCardsOpen] = useState(false);
const [bossTestingOpen, setBossTestingOpen] = useState(false);
const [balanceLabOpen, setBalanceLabOpen] = useState(false);
const [balanceTuningOpen, setBalanceTuningOpen] = useState(false);
const [readinessOpen, setReadinessOpen] = useState(false);
const [randomizedBiteSizedOpen, setRandomizedBiteSizedOpen] = 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 (balanceTuningOpen) return <Suspense fallback={<p role="status">Opening balance tuning…</p>}><BalanceTuningScreen onClose={() => setBalanceTuningOpen(false)} /></Suspense>;
if (readinessOpen) return <Suspense fallback={<p role="status">Opening Companion Readiness…</p>}><CompanionReadinessScreen onClose={() => setReadinessOpen(false)} /></Suspense>;
if (randomizedBiteSizedOpen) return <Suspense fallback={<p role="status">Opening Randomized Bite-Sized…</p>}><RandomizedBiteSizedScreen onClose={() => setRandomizedBiteSizedOpen(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)} onOpenReadiness={() => setReadinessOpen(true)} onOpenRandomizedBiteSized={() => setRandomizedBiteSizedOpen(true)} onOpenEditor={setEditor} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenBalanceTuning={() => setBalanceTuningOpen(true)} onOpenReadiness={() => setReadinessOpen(true)} onOpenRandomizedBiteSized={() => setRandomizedBiteSizedOpen(true)} onOpenEditor={setEditor} />;
}
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenReadiness, onOpenRandomizedBiteSized, onOpenEditor }: {
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenBalanceTuning, onOpenReadiness, onOpenRandomizedBiteSized, onOpenEditor }: {
readonly onOpenDownloads: () => void;
readonly onOpenReports: () => void;
readonly onOpenCards: () => void;
readonly onOpenBossTesting: () => void;
readonly onOpenBalanceLab: () => void;
readonly onOpenBalanceTuning: () => void;
readonly onOpenReadiness: () => void;
readonly onOpenRandomizedBiteSized: () => void;
readonly onOpenEditor: (editor: MainMenuEditor) => void;
@@ -125,6 +129,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
...(isGm ? [{ id: "gm-menu-gm-tools", run: openGmAdmin, neighbors: { up: "gm-menu-close" } }] : []),
{ id: "gm-menu-boss-testing", run: onOpenBossTesting, ...(!isGm ? { neighbors: { up: "gm-menu-close" } } : {}) },
{ id: "gm-menu-companion-readiness", run: onOpenReadiness },
...(isGm ? [{ id: "gm-menu-balance-tuning", run: onOpenBalanceTuning }] : []),
...(isGm ? [{ id: "gm-menu-balance-lab", run: onOpenBalanceLab }] : []),
{ id: "gm-menu-reports", run: onOpenReports },
{ id: "gm-menu-close", run: () => setGmMenuOpen(false), neighbors: { down: isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing" } },
@@ -153,7 +158,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
] : []),
{ id: "mode-downloads", run: onOpenDownloads },
{ id: "back-to-roster", run: returnToCharacters, neighbors: { down: "mode-dungeons" } },
], [character, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
], [character, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
const controller = useMenuController(actions, {
columns: 2,
initialId: gmMenuOpen ? (isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing") : "mode-dungeons",
@@ -303,6 +308,10 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
className="front-button main-menu-mode main-menu-mode--gm" type="button" onClick={onOpenReadiness}>
<b aria-hidden="true">CR</b><span><strong>Companion Readiness</strong><small>Review certified NPC specializations and Playerbots identities.</small></span><em>Open</em>
</ControllerButton>
<ControllerButton controlId="gm-menu-balance-tuning" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={onOpenBalanceTuning}>
<b aria-hidden="true">AT</b><span><strong>Ability &amp; Talent Tuning</strong><small>Adjust local cooldowns, costs, damage, healing, durations, and talent modifiers.</small></span><em>{isGm ? "Local" : "GM account required"}</em>
</ControllerButton>
<ControllerButton controlId="gm-menu-balance-lab" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={onOpenBalanceLab}>
<b aria-hidden="true">BL</b><span><strong>Balance Lab</strong><small>Queue automatic party and boss simulations on the server.</small></span><em>{isGm ? "Server" : "GM account required"}</em>
+1 -1
View File
@@ -65,7 +65,7 @@ const TOGGLES: readonly ToggleOption[] = [
{ id: "showMobAggroRanges", label: "Mob aggro ranges", description: "Show the boundary where idle dungeon enemies automatically attack your party." },
{ id: "showTargetFrame", label: "Target frame", description: "Show detailed status for your current target." },
{ id: "showThreatMeter", label: "Threat meter", description: "Show the compact movable threat table for your selected enemy." },
{ id: "showFloatingCombatText", label: "Combat numbers", description: "Show damage, healing, and absorb feedback." },
{ id: "showFloatingCombatText", label: "Party healing numbers", description: "Float effective healing amounts over party frames during combat." },
{ id: "autoTargetNearest", label: "Automatic targeting", description: "Pick the nearest valid enemy when an offensive ability needs one." },
{ id: "enableScreenShake", label: "Screen shake", description: "Use restrained camera impact for major hits." },
] as const;
+92 -2
View File
@@ -1,4 +1,4 @@
import { useState, type CSSProperties } from "react";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { classById, isRomClassId, raceById } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore";
@@ -21,6 +21,12 @@ import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../ga
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
import { useGameStore } from "../game/store";
import { useHudSurface } from "./HudSurface";
import {
PARTY_HEAL_POPUP_LIFETIME_MS,
addedPartyHealPopups,
appendPartyHealPopups,
type PartyHealPopup,
} from "./partyHealPopups";
const PARTY_COMMANDS: readonly {
command: PartyCommand;
@@ -109,10 +115,85 @@ function healthPercent(current: number, maximum: number): number {
return Math.max(0, Math.min(100, current / maximum * 100));
}
function MemberFrame({ member, selected, onSelect }: {
function HealPopups({ popups, targetId }: {
popups: readonly PartyHealPopup[];
targetId: string;
}) {
const targetPopups = popups.filter((popup) => popup.targetActorId === targetId);
if (targetPopups.length === 0) return null;
return (
<span className="party-frame__heal-popups" aria-hidden="true">
{targetPopups.map((popup) => (
<span
key={popup.key}
className={`party-frame__heal-popup ${popup.critical ? "is-critical" : ""}`}
style={{ "--party-heal-x": `${popup.horizontalOffset}px` } as CSSProperties}
>+{popup.amount}</span>
))}
</span>
);
}
function usePartyHealPopups(enabled: boolean): readonly PartyHealPopup[] {
const [popups, setPopups] = useState<PartyHealPopup[]>([]);
const timers = useRef(new Map<string, number>());
useEffect(() => {
const clearPopups = () => {
for (const timer of timers.current.values()) window.clearTimeout(timer);
timers.current.clear();
setPopups([]);
};
if (!enabled) {
clearPopups();
return undefined;
}
const unsubscribe = useCombatStore.subscribe((state, previous) => {
const inCombat = Object.values(state.mobs).some((mob) => (
mob.engaged && !mob.dead && !mob.despawned
));
const wasInCombat = Object.values(previous.mobs).some((mob) => (
mob.engaged && !mob.dead && !mob.despawned
));
if (!inCombat) {
if (wasInCombat) clearPopups();
return;
}
const visibleTargetIds = new Set([
PLAYER_AGGRO_ID,
...usePartyStore.getState().members.map((member) => member.id),
...(useOnlineSessionStore.getState().snapshot?.players ?? []).map((player) => (
onlinePlayerActorId(player.accountId)
)),
]);
const added = addedPartyHealPopups(previous.combatEvents, state.combatEvents, true)
.filter((popup) => visibleTargetIds.has(popup.targetActorId));
if (added.length === 0) return;
setPopups((current) => appendPartyHealPopups(current, added));
for (const popup of added) {
const existing = timers.current.get(popup.key);
if (existing !== undefined) window.clearTimeout(existing);
timers.current.set(popup.key, window.setTimeout(() => {
timers.current.delete(popup.key);
setPopups((current) => current.filter((candidate) => candidate.key !== popup.key));
}, PARTY_HEAL_POPUP_LIFETIME_MS));
}
});
return () => {
unsubscribe();
for (const timer of timers.current.values()) window.clearTimeout(timer);
timers.current.clear();
};
}, [enabled]);
return popups;
}
function MemberFrame({ member, selected, onSelect, healPopups }: {
member: PartyMember;
selected: boolean;
onSelect: () => void;
healPopups: readonly PartyHealPopup[];
}) {
const characterClass = classById(member.classId);
const race = raceById(member.raceId);
@@ -163,6 +244,7 @@ function MemberFrame({ member, selected, onSelect }: {
highlightDispellableDebuffs
/>
</span>
<HealPopups popups={healPopups} targetId={member.id} />
</button>
);
}
@@ -172,11 +254,13 @@ function OnlineMemberFrame({
player,
selected,
onSelect,
healPopups,
}: {
member: OnlineGroupMember;
player: OnlineSessionPlayer | null;
selected: boolean;
onSelect: () => void;
healPopups: readonly PartyHealPopup[];
}) {
const character = member.character;
if (!character) return null;
@@ -207,6 +291,7 @@ function OnlineMemberFrame({
</>
) : <span className="party-frame__online-status">Connecting…</span>}
</span>
<HealPopups popups={healPopups} targetId={onlinePlayerActorId(member.accountId)} />
</button>
);
}
@@ -221,6 +306,8 @@ export function PartyFrames() {
const playerResource = useCombatStore((state) => state.resource);
const playerMaxResource = useCombatStore((state) => state.maxResource);
const playerResourceName = useCombatStore((state) => state.resourceName);
const showHealPopups = useCombatStore((state) => state.settings.showFloatingCombatText);
const healPopups = usePartyHealPopups(showHealPopups);
const playerAuraAbsorb = useCombatStore((state) => state.auras.reduce((total, aura) => (
aura.targetId === PLAYER_AGGRO_ID
? total + aura.absorbs.reduce((sum, pool) => sum + pool.remaining, 0)
@@ -312,6 +399,7 @@ export function PartyFrames() {
highlightDispellableDebuffs
/>
</span>
<HealPopups popups={healPopups} targetId={PLAYER_AGGRO_ID} />
</button>
{onlineMembers.map((member) => {
const actorId = onlinePlayerActorId(member.accountId);
@@ -322,6 +410,7 @@ export function PartyFrames() {
player={onlinePlayersByAccount.get(member.accountId) ?? null}
selected={selectedOnlineActorId === actorId}
onSelect={() => selectOnlineMember(member.accountId)}
healPopups={healPopups}
/>
);
})}
@@ -331,6 +420,7 @@ export function PartyFrames() {
member={member}
selected={member.id === selectedMemberId}
onSelect={() => selectMember(member.id)}
healPopups={healPopups}
/>
))}
{gameMode === "dungeon" && members.some((member) => member.health <= 0) ? (
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import type { StructuredCombatEvent } from "../game/combatEvents";
import {
MAX_PARTY_HEAL_POPUPS,
addedPartyHealPopups,
appendPartyHealPopups,
} from "./partyHealPopups";
function event(
id: number,
kind: StructuredCombatEvent["kind"] = "healing",
effectiveAmount = 125,
): StructuredCombatEvent {
return {
id,
occurredAt: 1_000 + id,
kind,
sourceActorId: "healer",
targetActorId: `party-${id}`,
rawAmount: effectiveAmount,
effectiveAmount,
overheal: 0,
absorbed: 0,
critical: id % 2 === 0,
};
}
describe("party-frame healing popups", () => {
it("turns newly committed effective heals into rounded green-number events", () => {
const previous = [event(1)];
const next = [...previous, event(2, "damage", 80), event(3, "healing", 149.6)];
expect(addedPartyHealPopups(previous, next, true)).toEqual([{
key: "1003:3",
targetActorId: "party-3",
amount: 150,
critical: false,
horizontalOffset: -14,
}]);
});
it("ignores old heals, pure overheal, and all healing outside combat", () => {
const previous = [event(1)];
const overheal = event(2, "healing", 0);
expect(addedPartyHealPopups(previous, [...previous, overheal], true)).toEqual([]);
expect(addedPartyHealPopups(previous, [...previous, event(3)], false)).toEqual([]);
});
it("deduplicates replayed keys and bounds the active burst", () => {
const first = addedPartyHealPopups([], [event(1)], true)[0];
const burst = Array.from({ length: MAX_PARTY_HEAL_POPUPS + 4 }, (_, index) => (
addedPartyHealPopups([], [event(index + 2)], true)[0]
));
const result = appendPartyHealPopups([first], [first, ...burst]);
expect(result).toHaveLength(MAX_PARTY_HEAL_POPUPS);
expect(new Set(result.map((popup) => popup.key)).size).toBe(MAX_PARTY_HEAL_POPUPS);
expect(result.at(-1)?.key).toBe(`${1_000 + MAX_PARTY_HEAL_POPUPS + 5}:${MAX_PARTY_HEAL_POPUPS + 5}`);
});
});
+49
View File
@@ -0,0 +1,49 @@
import type { StructuredCombatEvent } from "../game/combatEvents";
export const PARTY_HEAL_POPUP_LIFETIME_MS = 1_500;
export const MAX_PARTY_HEAL_POPUPS = 20;
export interface PartyHealPopup {
readonly key: string;
readonly targetActorId: string;
readonly amount: number;
readonly critical: boolean;
readonly horizontalOffset: number;
}
function eventKey(event: StructuredCombatEvent): string {
return `${event.occurredAt}:${event.id}`;
}
/** Convert only newly committed, effective in-combat heals into party-frame UI events. */
export function addedPartyHealPopups(
previousEvents: readonly StructuredCombatEvent[],
nextEvents: readonly StructuredCombatEvent[],
inCombat: boolean,
): PartyHealPopup[] {
if (!inCombat || previousEvents === nextEvents) return [];
const previousKeys = new Set(previousEvents.map(eventKey));
return nextEvents
.filter((event) => (
event.kind === "healing"
&& event.effectiveAmount > 0
&& !previousKeys.has(eventKey(event))
))
.map((event) => ({
key: eventKey(event),
targetActorId: event.targetActorId,
amount: Math.max(1, Math.round(event.effectiveAmount)),
critical: event.critical,
horizontalOffset: ((event.id % 3) - 1) * 14,
}));
}
export function appendPartyHealPopups(
current: readonly PartyHealPopup[],
added: readonly PartyHealPopup[],
): PartyHealPopup[] {
if (added.length === 0) return [...current];
const addedKeys = new Set(added.map((popup) => popup.key));
return [...current.filter((popup) => !addedKeys.has(popup.key)), ...added]
.slice(-MAX_PARTY_HEAL_POPUPS);
}
-1
View File
@@ -84,7 +84,6 @@
.desktop-action-button.is-unavailable .action-slot__icon { opacity: .4; }
.desktop-action-button:is(:hover, :focus-visible, .is-selected) { border-color: #ffe3a0; outline: 2px solid var(--gold); outline-offset: -2px; }
.desktop-action-button__cooldown { position: absolute; inset: 0; display: grid; place-items: center; background: #0006; font-size: 15px; font-weight: bold; text-shadow: 0 1px 2px #000; }
.desktop-action-feedback { position: absolute; top: 65%; left: 50%; transform: translateX(-50%); max-width: 80%; color: #f3dfaa; text-align: center; text-shadow: 0 1px 3px #000; font-size: 12px; }
.ui-layout-editor {
position: absolute;
z-index: 80;