Release Healer Man 0.1.28
This commit is contained in:
1 parent
ed8b9e3719
commit
8c9809d3e0
23 files changed
+975
-55
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
Reference in new issue
Block a user