Release Healer Man 0.1.28

This commit is contained in:
phenom committed 2026-09-10 14:14:02 -04:00
1 parent ed8b9e3719
commit 8c9809d3e0
23 files changed
+975 -55

No files matched your search

+27
View File
@@ -66,6 +66,33 @@ afterEach(() => {
});
describe("gameplay input bindings", () => {
it("consumes custom action keys before movement, menus and legacy action bindings", () => {
stubInputEventTargets();
const nextActions = inputActionSpies();
nextActions.handleCustomKey = vi.fn(() => true);
const dispose = installInput(nextActions);
for (const code of ["KeyW", "KeyI", "Digit1"]) {
const event = new Event("keydown", { cancelable: true });
Object.defineProperties(event, { code: { value: code }, target: { value: null } });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
}
expect(nextActions.toggleInventory).not.toHaveBeenCalled();
expect(nextActions.castActionBinding).not.toHaveBeenCalled();
expect(nextActions.handleCustomKey).toHaveBeenCalledTimes(3);
dispose();
});
it("does not fire the old numbered bindings when desktop bars are enabled", () => {
stubInputEventTargets();
const nextActions = inputActionSpies();
nextActions.desktopActionBarsActive = () => true;
const dispose = installInput(nextActions);
const event = new Event("keydown", { cancelable: true });
Object.defineProperties(event, { code: { value: "Digit1" }, target: { value: null } });
window.dispatchEvent(event);
expect(nextActions.castActionBinding).not.toHaveBeenCalled();
dispose();
});
it("reserves I, P, N, and E for equipment and loot interactions", () => {
expect(keyboardCommandForCode("KeyI")).toBe("inventory");
expect(keyboardCommandForCode("KeyE")).toBe("loot");
+10 -1
View File
@@ -22,6 +22,9 @@ export interface InputSnapshot {
}
export interface InputActions {
/** Runs before built-in shortcuts. True consumes the key, including empty custom slots. */
handleCustomKey?: (event: KeyboardEvent) => boolean;
desktopActionBarsActive?: () => boolean;
togglePause: () => void;
toggleMap: () => void;
toggleInventory: () => void;
@@ -292,6 +295,12 @@ export function installInput(nextActions: InputActions): () => void {
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.matches("input, textarea, select, [contenteditable='true']")) return;
if (nextActions.handleCustomKey?.(event)) {
event.preventDefault();
event.stopImmediatePropagation();
keys.delete(event.code);
return;
}
if (["ShiftLeft", "ShiftRight", "AltLeft", "AltRight"].includes(event.code)) {
keyboardModifiers.add(event.code);
nextActions.setInputMode("keyboard");
@@ -312,7 +321,7 @@ export function installInput(nextActions: InputActions): () => void {
else nextActions.cancelGroundTarget();
return;
}
const actionControl = actionControlForKeyboardCode(event.code);
const actionControl = nextActions.desktopActionBarsActive?.() ? null : actionControlForKeyboardCode(event.code);
if (actionControl) {
if (nextActions.gameplayActionsBlocked()) return;
event.preventDefault();
+1 -1
View File
@@ -8,7 +8,7 @@ import type { GmReviewTarget } from "./gmContentReview";
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
export type InputMode = "keyboard" | "gamepad";
export type GameMode = "dungeon" | "manastorm";
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "gm" | null;
export type GameOverlay = "pause" | "party" | "inventory" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "gm" | null;
interface PlayerSnapshot {
position: Vector3Tuple;
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
actionKeyForEvent, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences,
} from "./uiLayout";
describe("personal UI layouts", () => {
it("migrates old accounts to independent Mobile and PC layouts with exactly six empty slots", () => {
const ui = normalizeUiPreferences(undefined);
expect(ui.slots.mobile).toEqual([null, null, null]);
expect(ui.slots.pc).toEqual([null, null, null]);
ui.current.pc.elements.party.visible = false;
expect(ui.current.mobile.elements.party.visible).toBe(true);
expect(ui.current.pc.bars).toHaveLength(5);
expect(ui.current.pc.bars.every((bar) => bar.slots.length === 12)).toBe(true);
});
it("round trips named slots, positions, hidden elements, geometry, assignments, and key chords", () => {
const ui = normalizeUiPreferences(null);
const layout = ui.current.pc;
layout.wowBars = true; layout.barCount = 5;
layout.elements.party = { visible: false, position: { x: .32, y: .45 }, width: 200 };
layout.bars[4].columns = 3;
layout.bars[4].slots[11] = { abilityId: "test-heal", key: "Ctrl+Shift+KeyQ" };
ui.slots.pc[2] = { name: "Raid night", layout };
ui.slots.mobile[0] = { name: "Travel", layout: defaultUiLayout() };
expect(importUiPreferences(exportUiPreferences(ui))).toEqual(ui);
});
it("reassigns conflicts even from hidden bars, accepts modifiers and clears a binding", () => {
let layout = defaultUiLayout(); layout.wowBars = true; layout.barCount = 5;
layout = bindDesktopSlot(layout, 4, 11, "Digit1");
expect(layout.bars[0].slots[0].key).toBeNull();
expect(desktopSlotForKey(layout, "Digit1")).toBe(layout.bars[4].slots[11]);
layout.elements["bar-5"].visible = false;
expect(desktopSlotForKey(layout, "Digit1")).not.toBeNull();
layout.barCount = 4;
expect(desktopSlotForKey(layout, "Digit1")).toBeNull();
layout = bindDesktopSlot(layout, 4, 11, null);
expect(layout.bars[4].slots[11].key).toBeNull();
expect(actionKeyForEvent({ code: "KeyQ", ctrlKey: true, shiftKey: true, altKey: false, metaKey: false })).toBe("Ctrl+Shift+KeyQ");
expect(normalizeActionKey("Escape")).toBeNull();
expect(normalizeActionKey("ShiftLeft")).toBeNull();
});
it.each([
["unsupported versions", (data: any) => { data.version = 99; }],
["extra slots", (data: any) => { data.slots.mobile.push(null); }],
["invalid coordinates", (data: any) => { data.current.pc.elements.party.position = { x: "bad", y: 0 }; }],
["invalid bar geometry", (data: any) => { data.current.pc.bars[0].columns = 7; }],
["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "Escape"; }],
["duplicate chords", (data: any) => { data.current.pc.bars[0].slots[1].key = "Digit1"; }],
["missing buttons", (data: any) => { data.current.pc.bars[0].slots.pop(); }],
])("rejects %s without mutating the original preferences", (_, mutate) => {
const ui = normalizeUiPreferences(null);
const original = exportUiPreferences(ui);
const data = JSON.parse(original); mutate(data);
expect(() => importUiPreferences(JSON.stringify(data))).toThrow();
expect(exportUiPreferences(ui)).toBe(original);
});
it("rejects malformed and oversized imports", () => {
expect(() => importUiPreferences("{")).toThrow("valid JSON");
expect(() => importUiPreferences(" ".repeat(256001))).toThrow("256 KB");
});
});
+178
View File
@@ -0,0 +1,178 @@
import { normalizeHudPosition, type NormalizedHudPosition } from "./hudLayout";
export type UiDevice = "mobile" | "pc";
export const UI_DEVICES: readonly UiDevice[] = ["mobile", "pc"];
export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const;
export const HUD_ELEMENTS = {
party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap",
player: "Player status", timeline: "Encounter timeline", target: "Enemy target",
friendly: "Friendly target", threat: "Threat meter", cast: "Cast bar",
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
manastorm: "Manastorm status", completion: "Dungeon completion", assets: "Asset notice",
objective: "Manastorm objective", messages: "Manastorm messages", cache: "Reward cache", feedback: "Action feedback",
"bar-1": "Action bar 1", "bar-2": "Action bar 2", "bar-3": "Action bar 3",
"bar-4": "Action bar 4", "bar-5": "Action bar 5",
} as const;
export type HudElementId = keyof typeof HUD_ELEMENTS;
export interface HudElementLayout {
visible: boolean;
position: NormalizedHudPosition | null;
width: number | null;
}
export interface DesktopActionSlot { abilityId: string | null; key: string | null }
export interface DesktopActionBar { columns: number; slots: DesktopActionSlot[] }
export interface UiLayout {
elements: Record<HudElementId, HudElementLayout>;
wowBars: boolean;
barCount: number;
bars: DesktopActionBar[];
}
export interface UiTemplate { name: string; layout: UiLayout }
export interface UiPreferences {
version: 1;
current: Record<UiDevice, UiLayout>;
slots: Record<UiDevice, (UiTemplate | null)[]>;
}
export function defaultUiLayout(): UiLayout {
return {
elements: Object.fromEntries(Object.keys(HUD_ELEMENTS).map((id) => [id, {
visible: true, position: null, width: null,
}])) as UiLayout["elements"],
wowBars: false,
barCount: 1,
bars: Array.from({ length: 5 }, (_, bar) => ({
columns: 12,
slots: Array.from({ length: 12 }, (_, slot) => ({
abilityId: null,
key: bar === 0 ? (slot < 9 ? `Digit${slot + 1}` : ["Digit0", "Minus", "Equal"][slot - 9]) : null,
})),
})),
};
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function bounded(value: unknown, fallback: number, min: number, max: number): number {
return typeof value === "number" && Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback;
}
const KEY_CODE = /^(?:Key[A-Z]|Digit[0-9]|F(?:[1-9]|1[0-2])|Numpad(?:[0-9]|Add|Subtract|Multiply|Divide|Decimal|Enter)|Arrow(?:Up|Down|Left|Right)|Space|Tab|Enter|Backquote|Minus|Equal|BracketLeft|BracketRight|Backslash|Semicolon|Quote|Comma|Period|Slash|Insert|Home|End|PageUp|PageDown)$/;
export function normalizeActionKey(value: unknown): string | null {
if (typeof value !== "string") return null;
const parts = value.split("+");
const code = parts.pop()!;
if (!KEY_CODE.test(code) || parts.some((part) => !["Ctrl", "Alt", "Shift", "Meta"].includes(part))) return null;
return [...["Ctrl", "Alt", "Shift", "Meta"].filter((part) => parts.includes(part)), code].join("+");
}
export function actionKeyForEvent(event: Pick<KeyboardEvent, "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey">): string | null {
return normalizeActionKey([
...(event.ctrlKey ? ["Ctrl"] : []), ...(event.altKey ? ["Alt"] : []),
...(event.shiftKey ? ["Shift"] : []), ...(event.metaKey ? ["Meta"] : []), event.code,
].join("+"));
}
export function actionKeyLabel(key: string | null): string {
return key?.replace(/Key|Digit/g, "").replace("Minus", "−").replace("Equal", "=").replace("Numpad", "Num ") ?? "";
}
export function normalizeUiLayout(value: unknown): UiLayout {
const source = record(value);
const defaults = defaultUiLayout();
const elements = record(source.elements);
for (const id of Object.keys(HUD_ELEMENTS) as HudElementId[]) {
const element = record(elements[id]);
defaults.elements[id] = {
visible: element.visible !== false,
position: element.position ? normalizeHudPosition(element.position, { x: 0.5, y: 0.5 }) : null,
width: typeof element.width === "number" ? bounded(element.width, 200, 20, 2000) : null,
};
}
const used = new Set<string>();
defaults.bars = defaults.bars.map((fallback, index) => {
const bar = record(Array.isArray(source.bars) ? source.bars[index] : null);
return {
columns: BAR_COLUMNS.includes(bar.columns as 12) ? bar.columns as number : 12,
slots: fallback.slots.map((slot, slotIndex) => {
const raw = Array.isArray(bar.slots) ? record(bar.slots[slotIndex]) : slot;
const key = normalizeActionKey(raw.key);
const uniqueKey = key && !used.has(key) ? key : null;
if (uniqueKey) used.add(uniqueKey);
return {
key: uniqueKey,
abilityId: typeof raw.abilityId === "string" && raw.abilityId.length <= 200 ? raw.abilityId : null,
};
}),
};
});
return { ...defaults, wowBars: source.wowBars === true, barCount: Math.floor(bounded(source.barCount, 1, 1, 5)) };
}
export function normalizeUiPreferences(value: unknown): UiPreferences {
const source = record(value);
const current = record(source.current);
const slots = record(source.slots);
return {
version: 1,
current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) },
slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => {
const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null);
return typeof slot.name === "string" && slot.layout
? { name: slot.name.trim().slice(0, 40) || `Template ${index + 1}`, layout: normalizeUiLayout(slot.layout) }
: null;
})])) as UiPreferences["slots"],
};
}
/** A key has one owner, including hidden bars, so enabling a bar cannot add conflicts. */
export function bindDesktopSlot(layout: UiLayout, barIndex: number, slotIndex: number, key: string | null): UiLayout {
return { ...layout, bars: layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((slot, s) => ({
...slot, key: b === barIndex && s === slotIndex ? key : key && slot.key === key ? null : slot.key,
})) })) };
}
export function desktopSlotForKey(layout: UiLayout, key: string | null): DesktopActionSlot | null {
if (!layout.wowBars || !key) return null;
// Hidden bars retain their bindings, like WoW; reducing the bar count deactivates them.
return layout.bars.slice(0, layout.barCount).flatMap((bar) => bar.slots).find((slot) => slot.key === key) ?? null;
}
export function exportUiPreferences(preferences: UiPreferences): string {
return JSON.stringify({ format: "healer-man-ui", ...normalizeUiPreferences(preferences) }, null, 2);
}
export function importUiPreferences(text: string): UiPreferences {
if (text.length > 256_000) throw new Error("UI files must be smaller than 256 KB.");
let source: Record<string, unknown>;
try { source = record(JSON.parse(text)); } catch { throw new Error("This file is not valid JSON."); }
if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version.");
const validateLayout = (value: unknown) => {
const layout = record(value);
if (typeof layout.wowBars !== "boolean" || !Number.isInteger(layout.barCount) || Number(layout.barCount) < 1 || Number(layout.barCount) > 5
|| !Array.isArray(layout.bars) || layout.bars.length !== 5) throw new Error("Invalid action bar configuration.");
for (const id of Object.keys(HUD_ELEMENTS)) {
const element = record(record(layout.elements)[id]);
const position = record(element.position);
if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
|| (element.width !== null && (typeof element.width !== "number" || !Number.isFinite(element.width) || element.width < 20 || element.width > 2000))) throw new Error("Invalid HUD position or visibility.");
}
const keys = new Set<string>();
for (const raw of layout.bars) {
const bar = record(raw);
if (!BAR_COLUMNS.includes(bar.columns as 12) || !Array.isArray(bar.slots) || bar.slots.length !== 12) throw new Error("Each bar needs exactly 12 buttons and a valid row/column layout.");
for (const rawSlot of bar.slots) {
const slot = record(rawSlot);
if (slot.abilityId !== null && (typeof slot.abilityId !== "string" || slot.abilityId.length > 200)) throw new Error("Invalid ability assignment.");
if (slot.key !== null && (!normalizeActionKey(slot.key) || keys.has(normalizeActionKey(slot.key)!))) throw new Error("Invalid or duplicate keybind.");
if (slot.key) keys.add(normalizeActionKey(slot.key)!);
}
}
};
for (const device of UI_DEVICES) {
validateLayout(record(source.current)[device]);
const slots = record(source.slots)[device];
if (!Array.isArray(slots) || slots.length !== 3) throw new Error("UI files must contain three Mobile and three PC slots.");
for (const slot of slots) {
if (slot === null) continue;
const template = record(slot);
if (typeof template.name !== "string" || !template.name.trim() || template.name.length > 40) throw new Error("Template names must be 1–40 characters.");
validateLayout(template.layout);
}
}
return normalizeUiPreferences(source);
}
+59
View File
@@ -0,0 +1,59 @@
import { create } from "zustand";
import { Capacitor } from "@capacitor/core";
import { loadUiPreferences, saveUiPreferences } from "../app/accountRepository";
import { useShellStore } from "../app/shellStore";
import { normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout";
export function currentUiDevice(): UiDevice {
return Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent))
|| (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches && window.innerWidth < 1100)
? "mobile" : "pc";
}
interface UiLayoutState {
ownerId: string | null;
preferences: UiPreferences;
draft: UiLayout | null;
device: UiDevice;
mode: "layout" | "bindings";
selectedButton: { bar: number; slot: number } | null;
selectedElement: string | null;
begin: () => void;
updateDraft: (update: (layout: UiLayout) => UiLayout) => void;
persist: (preferences: UiPreferences) => void;
finish: (save: boolean) => void;
}
export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
ownerId: null, preferences: normalizeUiPreferences(null), draft: null, device: currentUiDevice(),
mode: "layout", selectedButton: null, selectedElement: null,
begin: () => {
const device = currentUiDevice();
set({ draft: get().preferences.current[device], device, mode: "layout", selectedButton: null });
},
updateDraft: (update) => set((state) => state.draft ? { draft: update(state.draft) } : state),
persist: (preferences) => {
const ownerId = get().ownerId;
if (!ownerId) throw new Error("Sign in or choose an offline profile before saving your UI.");
// Write first: storage errors leave the editor open with its draft intact.
const saved = saveUiPreferences(ownerId, preferences);
set({ preferences: saved });
},
finish: (save) => {
const { draft, device, preferences } = get();
if (save && draft) get().persist({ ...preferences, current: { ...preferences.current, [device]: draft } });
set({ draft: null, selectedButton: null, mode: "layout" });
},
}));
function activateOwner() {
const ownerId = useShellStore.getState().session?.ownerId ?? null;
useUiLayoutStore.setState({ ownerId, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null });
}
activateOwner();
useShellStore.subscribe((state, previous) => {
if (state.session !== previous.session) activateOwner();
});
export function activeUiLayout(): UiLayout {
const state = useUiLayoutStore.getState();
return state.draft ?? state.preferences.current[currentUiDevice()];
}