Release Healer Man 0.1.44

This commit is contained in:
phenom committed 2026-09-14 22:12:49 -04:00
1 parent ccc01396cd
commit 756ee4f64a
24 files changed
+575 -46

No files matched your search

+7 -2
View File
@@ -12,7 +12,7 @@ import {
import { useCombatStore } from "../game/combatStore";
import { abilityAtLevel, defaultActionBarForCharacter, type AbilityDefinition } from "../game/abilityCatalog";
import { useGameStore } from "../game/store";
import { actionKeyLabel, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout";
import { actionKeyLabel, assignedActionBarAbilityIds, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller";
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
@@ -130,6 +130,10 @@ function AbilityBindingsDialog({ onClose }: { onClose?: () => void }) {
const [notice, setNotice] = useState("Choose a button, then choose the ability it should use.");
const classColor = classById(classId).color;
const isWow = layout === "wow";
const assignedIds = assignedActionBarAbilityIds(pcLayout, bindings, isWow);
const isMissing = (ability: AbilityDefinition) => ability.unlockLevel <= level
&& (ability.talentEntryId === undefined || Boolean(talentRanks[`coa-entry-${ability.talentEntryId}`]))
&& !assignedIds.has(ability.id);
const assignment = (control: BindingControl) => typeof control === "number"
? pcLayout.bars[bar].slots[control].abilityId : bindings[actionBindingId(layer, control)];
const controlLabel = (control: BindingControl) => typeof control === "number"
@@ -490,10 +494,11 @@ function AbilityBindingsDialog({ onClose }: { onClose?: () => void }) {
<>
<p className="eyebrow">Assign {controlLabel(editingControl)}</p>
<h3>Choose an ability</h3>
<p className="action-bar-missing-legend">Light blue: missing from action bars</p>
<div className="ability-bindings__choices">
{pickerAbilities.map((ability) => (
<AbilityTooltipTarget key={ability.id} ability={tooltipData(ability)}>
<ControllerButton controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""}`} onClick={() => assign(ability.id)}>
<ControllerButton controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""} ${isMissing(ability) ? "is-missing-from-bars" : ""}`} aria-label={`${ability.name}${isMissing(ability) ? ", missing from action bars" : ""}`} onClick={() => assign(ability.id)}>
<AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} />
<span><strong>{ability.name}</strong><small>{ability.unlockLevel > level ? `Unlocks at level ${ability.unlockLevel}` : ability.id === editingAbilityId ? "Current assignment" : ability.target === "friendly" ? "Friendly / self" : ability.target}</small></span>
</ControllerButton>
+13 -2
View File
@@ -1,5 +1,8 @@
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { useGameStore } from "../game/store";
import { useCombatStore } from "../game/combatStore";
import { assignedActionBarAbilityIds } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import { AbilityTooltipTarget } from "./AbilityTooltip";
@@ -80,6 +83,11 @@ function SpellbookDialog({
unlockedOnly,
onUnlockedOnlyChange,
}: SpellbookDialogProps) {
const bindings = useCombatStore((state) => state.actionBindings);
const ui = useUiLayoutStore();
const device = currentUiDevice();
const layout = ui.draft ?? ui.preferences.current[device];
const assignedIds = assignedActionBarAbilityIds(layout, bindings, device === "pc" && layout.wowBars);
const orderedAbilities = useMemo(
() => spellbookAbilitiesForDisplay(abilities, playerLevel, false),
[abilities, playerLevel],
@@ -131,6 +139,7 @@ function SpellbookDialog({
</header>
<div className="spellbook-panel__toolbar">
<span className="action-bar-missing-legend">Light blue: missing from action bars</span>
<button
type="button"
className={`spellbook-panel__filter ${unlockedOnly ? "is-active" : ""}`}
@@ -146,6 +155,7 @@ function SpellbookDialog({
<div className="spellbook-panel__list" aria-label={unlockedOnly ? "Unlocked abilities" : "Known and locked abilities"}>
{visibleAbilities.map((ability) => {
const locked = !ability.known || ability.level > playerLevel;
const missing = !locked && !ability.passive && !assignedIds.has(ability.id);
const lockLabel = ability.talentLocked && !ability.known
? "Learn in talents"
: `Unlocks at level ${ability.level}`;
@@ -163,9 +173,9 @@ function SpellbookDialog({
setInspectedId(ability.id);
}}
type="button"
className={`spellbook-ability ${locked ? "spellbook-ability--locked" : ""}`}
className={`spellbook-ability ${locked ? "spellbook-ability--locked" : ""} ${missing ? "is-missing-from-bars" : ""}`}
aria-disabled={locked}
aria-label={locked ? `${ability.name}, ${lockLabel.toLowerCase()}` : `${ability.name}, unlocked`}
aria-label={locked ? `${ability.name}, ${lockLabel.toLowerCase()}` : `${ability.name}, unlocked${missing ? ", missing from action bars" : ""}`}
onFocus={() => setInspectedId(ability.id)}
onClick={() => {
setInspectedId(ability.id);
@@ -214,6 +224,7 @@ function SpellbookDialog({
? "Learn in talents"
: `Unlocks at level ${inspected.level}`
: inspected.passive ? "Passive" : "Unlocked"}</dd></div>
{!inspectedLocked && !inspected.passive && <div><dt>Action bars</dt><dd>{assignedIds.has(inspected.id) ? "Assigned" : "Missing from action bars"}</dd></div>}
{inspected.rank !== undefined && <div><dt>Current rank</dt><dd>{inspected.rank} / {inspected.rankCount ?? inspected.rank}</dd></div>}
{inspected.resourceCost !== undefined && <div><dt>Cost</dt><dd>{inspected.resourceCost} {inspected.resourceLabel}</dd></div>}
{inspected.cooldownSeconds !== undefined && <div><dt>Cooldown</dt><dd>{inspected.cooldownSeconds || "None"}{inspected.cooldownSeconds ? " sec" : ""}</dd></div>}
+2 -2
View File
@@ -5,7 +5,7 @@ import { useCombatStore } from "../game/combatStore";
import { abilityAtLevel, defaultActionBarForCharacter } from "../game/abilityCatalog";
import { useGameStore } from "../game/store";
import {
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout,
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout, resetDesktopAbilities,
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
resolveHudScreen, type HudScreen,
} from "../game/uiLayout";
@@ -176,7 +176,7 @@ export function UiLayoutEditor() {
const combat = useCombatStore.getState();
const defaults = defaultActionBarForCharacter(combat.classId, combat.secondaryClassId, combat.level);
const empty = layout.bars.every((bar) => bar.slots.every((button) => !button.abilityId));
return { ...layout, wowBars: event.target.checked, bars: empty && event.target.checked ? layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((button, s) => ({ ...button, abilityId: defaults[b * 12 + s] ?? null })) })) : layout.bars };
return { ...(empty && event.target.checked ? resetDesktopAbilities(layout, defaults) : layout), wowBars: event.target.checked };
})} />Use WoW action bars</label>
{draft.wowBars && <>
<label>Visible bar count<select value={draft.barCount} onChange={(event) => { ui.updateDraft((layout) => ({ ...layout, barCount: Number(event.target.value) })); useUiLayoutStore.setState({ selectedButton: null }); }}>{[1, 2, 3, 4, 5].map((count) => <option key={count}>{count}</option>)}</select></label>
+39 -1
View File
@@ -1,12 +1,27 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
SpellbookPanel,
spellbookAbilitiesForDisplay,
type SpellbookAbilityView,
} from "./SpellbookPanel";
const barState = vi.hoisted(() => ({ pcIds: [] as (string | null)[], controllerIds: [] as string[], wowBars: true }));
vi.mock("../game/uiLayoutStore", async (importOriginal) => {
const actual = await importOriginal<typeof import("../game/uiLayoutStore")>();
const { normalizeUiPreferences } = await import("../game/uiLayout");
return { ...actual, currentUiDevice: () => "pc", useUiLayoutStore: () => {
const preferences = normalizeUiPreferences(null);
preferences.current.pc.wowBars = barState.wowBars;
preferences.current.pc.bars[0].slots.forEach((slot, index) => { slot.abilityId = barState.pcIds[index] ?? null; });
return { preferences, draft: null };
} };
});
vi.mock("../game/combatStore", () => ({ useCombatStore: (selector: (state: unknown) => unknown) => selector({
actionBindings: Object.fromEntries(barState.controllerIds.map((id, index) => [`layer:${index}`, id])),
}) }));
const abilities: readonly SpellbookAbilityView[] = [
{
id: "known-one",
@@ -47,6 +62,29 @@ const abilities: readonly SpellbookAbilityView[] = [
];
describe("spellbook ability progression", () => {
it("highlights only usable spells missing from the active layout and updates after assignment", () => {
barState.wowBars = true;
barState.pcIds = [null, null, null, null, "known-one"];
barState.controllerIds = ["known-two"];
try {
const render = () => renderToStaticMarkup(createElement(SpellbookPanel, {
abilities: [...abilities, { ...abilities[1], id: "passive", passive: true }],
playerLevel: 1, onActivate: () => undefined, open: true, onClose: () => undefined,
}));
let markup = render();
expect(markup).toContain("Known Two, unlocked, missing from action bars");
expect(markup).not.toContain("Known One, unlocked, missing from action bars");
expect(markup.match(/is-missing-from-bars/g)).toHaveLength(1);
barState.pcIds[11] = "known-two";
markup = render();
expect(markup).not.toContain("is-missing-from-bars");
barState.wowBars = false;
markup = render();
expect(markup).toContain("Known One, unlocked, missing from action bars");
expect(markup).not.toContain("Known Two, unlocked, missing from action bars");
expect(markup.match(/is-missing-from-bars/g)).toHaveLength(1);
} finally { barState.pcIds = []; barState.controllerIds = []; barState.wowBars = true; }
});
it("renders future abilities as inspectable locked buttons with level requirements", () => {
const markup = renderToStaticMarkup(createElement(SpellbookPanel, {
abilities,