Release Healer Man 0.1.43

This commit is contained in:
phenom committed 2026-09-14 13:20:20 -04:00
1 parent c7e8163790
commit ccc01396cd
86 files changed
+2951 -198

No files matched your search

+11 -5
View File
@@ -1,4 +1,5 @@
import { useEffect } from "react";
import { useShellStore } from "./app/shellStore";
import "./ui/uiLayout.css";
import { DualDisplayFrame } from "./components/DualDisplayFrame";
import { installInput, releasePointerLock } from "./game/inputManager";
@@ -7,7 +8,8 @@ import { connectDpsMeter } from "./game/dpsMeterStore";
import { useCombatStore } from "./game/combatStore";
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
import { requireDungeonDefinition } from "./game/dungeonRegistry";
import { bossTestingSourceDungeonId, clearBossTestingDungeons, requireDungeonDefinition } from "./game/dungeonRegistry";
import { BossTestingBridge } from "./game/BossTestingBridge";
import { PartyBridge } from "./game/PartyBridge";
import { usePartyStore } from "./game/partyStore";
import { useGameStore } from "./game/store";
@@ -24,9 +26,7 @@ import { DungeonRunBridge } from "./game/DungeonRunBridge";
import { DungeonRunResults } from "./ui/DungeonRunResults";
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
import { UiLayoutEditor } from "./ui/UiLayoutEditor";
import { EditableHudElement } from "./ui/EditableHudElement";
import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore";
import { useUiLayoutStore } from "./game/uiLayoutStore";
import { profileInputBindings } from "./game/keybindPreferences";
@@ -39,7 +39,7 @@ function InputBridge() {
useEffect(() => installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results"].includes(useGameStore.getState().overlay ?? ""),
inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
getBindings: (device) => {
const preferences = useUiLayoutStore.getState().preferences;
const bindings = profileInputBindings(preferences, device);
@@ -103,6 +103,12 @@ export default function GameRuntime() {
clearDungeonRuntimeCaches();
releaseDungeonGltfCache(activeDungeon);
releaseShelvedGameRuntime();
const sourceDungeonId = bossTestingSourceDungeonId(activeDungeon.id);
if (sourceDungeonId) {
useGameStore.setState({ activeDungeonId: sourceDungeonId, bossTest: null, gmTestMode: false });
useShellStore.setState({ activeDungeonId: sourceDungeonId });
}
clearBossTestingDungeons();
}, []);
return (
<DualDisplayFrame
@@ -118,10 +124,10 @@ export default function GameRuntime() {
<CombatBridge />
<PartyBridge />
<DungeonRunBridge />
<BossTestingBridge />
<ShelvedGameBridges />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
<Hud />
<EditableHudElement id="interaction"><DungeonInteractionPrompt /></EditableHudElement>
<MapPanel />
<GameplayPanels />
<PauseMenu />
+49 -6
View File
@@ -5,6 +5,10 @@ import {
createLocalAccount,
createOfflineSession,
listCharacters,
loadAccountBossCards,
saveAccountBossCards,
replaceCharactersForOwner,
deleteCharacter,
loadCombatPresentationSettings,
loginLocalAccount,
restoreSession,
@@ -12,7 +16,6 @@ import {
updateCharacterEquipment,
updateCharacterDungeonRunCheckpoint,
updateCharacterInventory,
updateCharacterBossCards,
updateCharacterManastormProgress,
updateCharacterProgression,
updateCharacterSettings,
@@ -213,7 +216,7 @@ describe("local profile repository", () => {
}));
expect(loadCombatPresentationSettings("offline-roster", storage))
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
}
const legacyV3Storage = new MemoryStorage();
@@ -382,18 +385,58 @@ describe("local profile repository", () => {
});
});
it("persists card copies and claim receipts per character across roster reloads", () => {
it("persists account card copies independently of character creation and deletion", () => {
const storage = new MemoryStorage();
const offline = createOfflineSession();
const draft = { ownerId: offline.ownerId, name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } };
const character = createCharacter(draft, storage).character!;
const other = createCharacter({ ...draft, name: "Other" }, storage).character!;
const bossCards = { counts: { "wow:lady anacondra": { normal: 7, epic: 1 } }, lastClaims: { spawn: "kill:7" } };
expect(updateCharacterBossCards(offline.ownerId, character.id, bossCards, storage)?.bossCards).toEqual(bossCards);
expect(saveAccountBossCards(offline.ownerId, bossCards, storage)).toEqual(bossCards);
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
expect(loadAccountBossCards("different-account", storage)).toEqual({ counts: {}, lastClaims: {} });
const roster = listCharacters(offline.ownerId, storage);
expect(roster.find((entry) => entry.id === character.id)?.bossCards).toEqual(bossCards);
expect(roster.find((entry) => entry.id === other.id)?.bossCards).toEqual({ counts: {}, lastClaims: {} });
expect(roster.find((entry) => entry.id === character.id)?.bossCards).toBeUndefined();
expect(roster.find((entry) => entry.id === other.id)?.bossCards).toBeUndefined();
expect(roster.find((entry) => entry.id === character.id)?.inventory).toEqual([]);
deleteCharacter(offline.ownerId, character.id, storage);
deleteCharacter(offline.ownerId, other.id, storage);
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
createCharacter({ ...draft, name: "Newcomer" }, storage);
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
});
it("migrates and sums all legacy character logs once while retaining the newest receipts", () => {
const storage = new MemoryStorage();
const draft = { ownerId: "one", name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } };
const first = createCharacter(draft, storage).character!;
const second = createCharacter({ ...draft, name: "Other" }, storage).character!;
const separate = createCharacter({ ...draft, ownerId: "two" }, storage).character!;
const legacyCharacters = [
{ ...first, bossCards: { counts: { boss: { normal: 7, epic: 1 } }, lastClaims: { spawn: "100:1:spawn" } } },
{ ...second, bossCards: { counts: { boss: { normal: 3, rare: 2 } }, lastClaims: { spawn: "200:2:spawn" } } },
];
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
version: 6, accounts: [], rosters: { one: legacyCharacters, two: [{ ...separate, bossCards: { counts: { boss: { normal: 4 } }, lastClaims: {} } }] },
}));
const expected = { counts: { boss: { normal: 10, rare: 2, epic: 1 } }, lastClaims: { spawn: "200:2:spawn" } };
expect(loadAccountBossCards("one", storage)).toEqual(expected);
expect(loadAccountBossCards("one", storage)).toEqual(expected);
expect(loadAccountBossCards("two", storage).counts.boss?.normal).toBe(4);
const database = JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!);
expect(database.version).toBe(7);
expect(database.rosters.one.every((entry: { bossCards?: unknown }) => entry.bossCards === undefined)).toBe(true);
// An old cloud roster can be downloaded again without repeating the migration.
replaceCharactersForOwner("one", legacyCharacters, storage);
replaceCharactersForOwner("one", legacyCharacters, storage);
expect(loadAccountBossCards("one", storage)).toEqual(expected);
});
it("restores account logs from cloud without overwriting higher cached counts", () => {
const storage = new MemoryStorage();
saveAccountBossCards("one", { counts: { boss: { normal: 8 } }, lastClaims: { spawn: "200:2:spawn" } }, storage);
replaceCharactersForOwner("one", [], storage, { counts: { boss: { normal: 7, rare: 1 } }, lastClaims: { spawn: "100:1:spawn" } });
expect(loadAccountBossCards("one", storage)).toEqual({ counts: { boss: { normal: 8, rare: 1 } }, lastClaims: { spawn: "200:2:spawn" } });
});
it("persists threat-meter visibility and position per character", () => {
+50 -33
View File
@@ -1,5 +1,5 @@
import type { CharacterProfile, PlayerSession } from "./types";
import { normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
import { mergeBossCardCollections, normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
import {
contentCategoryForClass,
@@ -62,9 +62,10 @@ interface LocalAccount {
}
interface ProfileDatabase {
version: 6;
version: 7;
accounts: LocalAccount[];
rosters: Record<string, CharacterProfile[]>;
bossCardsByOwner: Record<string, BossCardCollection>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
uiPreferencesByOwner?: Record<string, UiPreferences>;
}
@@ -105,7 +106,7 @@ function browserSessionStorage(): StorageLike | null {
}
function emptyDatabase(): ProfileDatabase {
return { version: 6, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
return { version: 7, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} };
}
function isCharacterProfile(value: unknown): value is CharacterProfile {
@@ -121,6 +122,9 @@ function isCharacterProfile(value: unknown): value is CharacterProfile {
}
function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
const profile = { ...value };
// Cards now belong to the owner; legacy fields are consumed before normalization.
delete profile.bossCards;
const legacy = value as CharacterProfile & {
actionBindings?: unknown;
experience?: unknown;
@@ -180,7 +184,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
: undefined;
const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint);
return {
...value,
...profile,
level,
experience,
talentRanks,
@@ -191,7 +195,6 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
actionLoadouts,
inventory,
equipment,
bossCards: normalizeBossCardCollection(value.bossCards),
manastormProgress,
dungeonRunCheckpoint,
settings,
@@ -205,10 +208,11 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
version?: number;
accounts?: unknown;
rosters?: unknown;
bossCardsByOwner?: unknown;
presentationSettingsByOwner?: unknown;
uiPreferencesByOwner?: unknown;
}) | null;
if (!parsed || ![1, 2, 3, 4, 5, 6].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
if (!parsed || ![1, 2, 3, 4, 5, 6, 7].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
return emptyDatabase();
}
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
@@ -220,12 +224,19 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
&& typeof account.verifier.salt === "string"
&& typeof account.verifier.digest === "string",
));
const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record<string, unknown>).map(([ownerId, roster]) => [
ownerId,
Array.isArray(roster)
? roster.filter(isCharacterProfile).map(normalizeCharacterProfile).slice(0, MAX_CHARACTERS)
: [],
]));
const rawCards = parsed.bossCardsByOwner && typeof parsed.bossCardsByOwner === "object" && !Array.isArray(parsed.bossCardsByOwner)
? parsed.bossCardsByOwner : {};
const bossCardsByOwner = Object.fromEntries(Object.entries(rawCards).map(([owner, cards]) => [owner, normalizeBossCardCollection(cards)]));
let cardsMigrated = false;
const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record<string, unknown>).map(([ownerId, roster]) => {
const characters = Array.isArray(roster) ? roster.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : [];
if (characters.some((character) => character.bossCards !== undefined)) {
const legacyCards = mergeBossCardCollections(characters.map((character) => character.bossCards), "sum");
bossCardsByOwner[ownerId] = mergeBossCardCollections([bossCardsByOwner[ownerId], legacyCards]);
cardsMigrated = true;
}
return [ownerId, characters.map(normalizeCharacterProfile)];
}));
const rawPresentationSettings = parsed.presentationSettingsByOwner
&& typeof parsed.presentationSettingsByOwner === "object"
&& !Array.isArray(parsed.presentationSettingsByOwner)
@@ -259,8 +270,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
);
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
const database: ProfileDatabase = { version: 6, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner };
if (parsed.version !== 6 || presentationSettingsMigrated) writeDatabase(database, storage);
const database: ProfileDatabase = { version: 7, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated) writeDatabase(database, storage);
return database;
} catch {
return emptyDatabase();
@@ -270,7 +281,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void {
storage?.setItem(DATABASE_KEY, JSON.stringify(database));
if (!storage || !rosterChangeListener) return;
for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.bossCardsByOwner), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
rosterChangeListener(ownerId, database.rosters[ownerId] ?? []);
}
}
@@ -452,15 +463,19 @@ export function replaceCharactersForOwner(
ownerId: string,
characters: unknown,
storage: StorageLike | null = browserLocalStorage(),
accountBossCards?: unknown,
): CharacterProfile[] {
if (!storage) return [];
const database = readDatabase(storage);
database.rosters[ownerId] = Array.isArray(characters)
? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS).map((character) => normalizeCharacterProfile({
...character,
ownerId,
}))
: [];
const roster = Array.isArray(characters) ? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : [];
const importedCards = accountBossCards === undefined
? mergeBossCardCollections(roster.map((character) => character.bossCards), "sum")
: normalizeBossCardCollection(accountBossCards);
database.bossCardsByOwner[ownerId] = mergeBossCardCollections([database.bossCardsByOwner[ownerId], importedCards]);
database.rosters[ownerId] = roster.map((character) => normalizeCharacterProfile({
...character,
ownerId,
}));
writeDatabase(database, storage);
return listCharacters(ownerId, storage);
}
@@ -730,22 +745,24 @@ export function updateCharacterEquipment(
return updated;
}
export function updateCharacterBossCards(
export function loadAccountBossCards(
ownerId: string,
storage: StorageLike | null = browserLocalStorage(),
): BossCardCollection {
return normalizeBossCardCollection(readDatabase(storage).bossCardsByOwner[ownerId]);
}
export function saveAccountBossCards(
ownerId: string,
characterId: string,
bossCards: BossCardCollection,
storage: StorageLike | null = browserLocalStorage(),
): CharacterProfile | null {
if (!storage) return null;
): BossCardCollection {
const normalized = normalizeBossCardCollection(bossCards);
if (!storage) return normalized;
const database = readDatabase(storage);
let updated: CharacterProfile | null = null;
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
if (character.id !== characterId) return character;
updated = normalizeCharacterProfile({ ...character, bossCards });
return updated;
});
if (updated) writeDatabase(database, storage);
return updated;
database.bossCardsByOwner[ownerId] = normalized;
writeDatabase(database, storage);
return normalized;
}
export function updateCharacterSettings(
+104
View File
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PlayerSession } from "./types";
function storage() {
const values = new Map<string, string>();
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value); },
removeItem: (key: string) => { values.delete(key); },
};
}
const draft = {
ownerId: "account-one", name: "Collector", raceId: "human" as const,
classId: "priest" as const, specializationId: "priest:holy", gender: "female" as const,
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
};
const session: PlayerSession = { kind: "account", ownerId: draft.ownerId, displayName: "Collector", accessToken: "test-token" };
const cards = { counts: { "wow:lady anacondra": { normal: 7, rare: 1 } }, lastClaims: { spawn: "200:1:spawn" } };
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
describe("account boss card integration", () => {
it("keeps earned cards when switching characters and isolates account sign-in", async () => {
vi.stubGlobal("window", { localStorage: storage(), sessionStorage: storage() });
const repository = await import("./accountRepository");
const { useShellStore } = await import("./shellStore");
const { useCombatStore } = await import("../game/combatStore");
const first = repository.createCharacter(draft).character!;
const second = repository.createCharacter({ ...draft, name: "Other" }).character!;
const offlineSession: PlayerSession = { ...session, accessToken: undefined };
const shell = useShellStore.getState();
shell.beginSession(offlineSession);
useShellStore.setState({ phase: "game", activeCharacter: first });
shell.saveAccountBossCards(cards);
shell.returnToCharacters();
shell.selectCharacter(second.id);
expect(useShellStore.getState().bossCards).toEqual(cards);
useCombatStore.getState().initializeCharacter({ ...second, bossCards: useShellStore.getState().bossCards });
useCombatStore.getState().collectSharedBossCards({ bossKey: "wow:lady anacondra", spawnId: "spawn", claimId: "300:1:spawn", rarities: ["normal"] }, "Lady Anacondra", 300);
shell.saveAccountBossCards(useCombatStore.getState().bossCards);
shell.selectCharacter(first.id);
expect(useShellStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(8);
expect(repository.listCharacters(session.ownerId).every((character) => character.bossCards === undefined)).toBe(true);
shell.signOut();
expect(useShellStore.getState().bossCards.counts).toEqual({});
shell.beginSession({ ...offlineSession, ownerId: "account-two" });
expect(useShellStore.getState().bossCards.counts).toEqual({});
shell.beginSession(offlineSession);
expect(useShellStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(8);
});
it("uploads the account collection without characters and restores it on another device", async () => {
const firstDevice = storage();
const secondDevice = storage();
vi.stubGlobal("window", { localStorage: firstDevice });
let cloudData: unknown = { version: 1, characters: [] };
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const repository = await import("./accountRepository");
const client = await import("./onlineAccountClient");
client.activateOnlineSession(session);
repository.saveAccountBossCards(session.ownerId, cards);
await vi.advanceTimersByTimeAsync(501);
expect(cloudData).toMatchObject({ version: 2, characters: [], bossCards: cards });
expect(client.useCloudSaveStatus.getState().state).toBe("synced");
vi.stubGlobal("window", { localStorage: secondDevice });
await client.hydrateOnlineSession(session);
expect(repository.loadAccountBossCards(session.ownerId)).toEqual(cards);
expect(repository.loadAccountBossCards("different-account").counts).toEqual({});
const { collectBossCardDrop } = await import("../game/bossCards");
const restored = repository.loadAccountBossCards(session.ownerId);
expect(collectBossCardDrop(restored, { bossKey: "wow:lady anacondra", spawnId: "spawn", claimId: "200:1:spawn", rarities: ["normal", "rare"] })).toBe(restored);
await client.logoutOnlineSession(session);
});
it("publishes legacy cloud migration once and preserves it through repeated downloads", async () => {
vi.stubGlobal("window", { localStorage: storage() });
const repository = await import("./accountRepository");
const first = repository.createCharacter(draft).character!;
const second = repository.createCharacter({ ...draft, name: "Other" }).character!;
let cloudData: unknown = { version: 1, characters: [
{ ...first, bossCards: cards },
{ ...second, bossCards: { counts: { "wow:lady anacondra": { normal: 3 } }, lastClaims: {} } },
] };
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200 });
}));
const client = await import("./onlineAccountClient");
await client.hydrateOnlineSession(session);
expect(repository.loadAccountBossCards(session.ownerId).counts["wow:lady anacondra"]?.normal).toBe(10);
await vi.advanceTimersByTimeAsync(501);
expect(cloudData).toMatchObject({ version: 2, bossCards: { counts: { "wow:lady anacondra": { normal: 10, rare: 1 } } } });
expect((cloudData as { characters: { bossCards?: unknown }[] }).characters.every((character) => character.bossCards === undefined)).toBe(true);
await client.hydrateOnlineSession(session);
expect(repository.loadAccountBossCards(session.ownerId).counts["wow:lady anacondra"]?.normal).toBe(10);
await client.logoutOnlineSession(session);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { useBossTestingReviews } from "./bossTestingReviews";
import { submitDungeonReport } from "./dungeonReports";
afterEach(() => { vi.unstubAllGlobals(); useBossTestingReviews.setState({ reviews: {} }); });
describe("boss feedback", () => {
const boss = { key: "wailing-caverns/lady-anacondra", sourceDungeonId: "wailing-caverns", name: "Lady Anacondra" };
it("retains offline readiness and syncs it before refreshing server labels", async () => {
const storage = { setItem: vi.fn() };
vi.stubGlobal("window", { localStorage: storage });
const fetch = vi.fn().mockRejectedValueOnce(new Error("offline"));
vi.stubGlobal("fetch", fetch);
expect(await useBossTestingReviews.getState().save(boss, "not-ready", null)).toBe(false);
expect(useBossTestingReviews.getState().reviews[boss.key]).toMatchObject({ status: "not-ready", pending: true });
expect(storage.setItem).toHaveBeenCalled();
const review = { bossKey: boss.key, dungeonId: boss.sourceDungeonId, bossName: boss.name, status: "not-ready", reviewedAt: 1, reviewedBy: "Guest player" };
fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ review }) }).mockResolvedValueOnce({ ok: true, json: async () => ({ reviews: [review] }) });
await useBossTestingReviews.getState().refresh(null);
expect(useBossTestingReviews.getState().reviews[boss.key]).toEqual(review);
expect(fetch.mock.calls[1][1].method).toBe("POST");
expect(fetch.mock.calls[2][0]).toBe("/api/boss-testing/reviews");
});
it("submits boss identity as structured metadata without consuming report text", async () => {
const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ report: {} }) });
vi.stubGlobal("fetch", fetch);
await submitDungeonReport(null, boss.sourceDungeonId, "x".repeat(500), { bossKey: boss.key, bossName: boss.name });
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ dungeonId: boss.sourceDungeonId, comment: "x".repeat(500), bossKey: boss.key, bossName: boss.name });
});
});
+76
View File
@@ -0,0 +1,76 @@
import { create } from "zustand";
import type { PlayerSession } from "./types";
import { requestOnlineJson } from "./onlineAccountClient";
export const BOSS_TESTING_STATUSES = ["ready", "needs-testing", "not-ready"] as const;
export type BossTestingStatus = (typeof BOSS_TESTING_STATUSES)[number];
export const BOSS_TESTING_STATUS_LABELS: Record<BossTestingStatus, string> = {
ready: "Ready", "needs-testing": "Needs testing", "not-ready": "Not ready",
};
export interface BossTestingReview {
readonly bossKey: string;
readonly dungeonId: string;
readonly bossName: string;
readonly status: BossTestingStatus;
readonly reviewedBy: string;
readonly reviewedAt: number;
readonly pending?: boolean;
}
const CACHE_KEY = "healer-man.boss-testing-reviews.v1";
function readReviews(): Record<string, BossTestingReview> {
try {
const reviews = JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? "{}");
if (!reviews || typeof reviews !== "object" || Array.isArray(reviews)) return {};
return Object.fromEntries(Object.entries(reviews).filter(([key, value]) => {
const review = value as BossTestingReview;
return review?.bossKey === key && typeof review.dungeonId === "string" && typeof review.bossName === "string"
&& BOSS_TESTING_STATUSES.includes(review.status) && Number.isFinite(review.reviewedAt);
})) as Record<string, BossTestingReview>;
} catch { return {}; }
}
function persist(reviews: Record<string, BossTestingReview>) {
try { window.localStorage.setItem(CACHE_KEY, JSON.stringify(reviews)); } catch { /* Storage may be disabled. */ }
}
function sendReview(review: BossTestingReview, session: PlayerSession | null) {
return requestOnlineJson<{ review: BossTestingReview }>("/api/boss-testing/reviews", {
method: "POST", body: JSON.stringify({ bossKey: review.bossKey, dungeonId: review.dungeonId, bossName: review.bossName, status: review.status }),
}, session?.kind === "account" ? session.accessToken : undefined);
}
interface ReviewState {
reviews: Record<string, BossTestingReview>;
refresh: (session: PlayerSession | null) => Promise<void>;
save: (boss: { readonly key: string; readonly sourceDungeonId: string; readonly name: string }, status: BossTestingStatus,
session: PlayerSession | null) => Promise<boolean>;
}
export const useBossTestingReviews = create<ReviewState>((set, get) => ({
reviews: readReviews(),
refresh: async (session) => {
try {
for (const pending of Object.values(get().reviews).filter((review) => review.pending)) {
const { review } = await sendReview(pending, session);
if (get().reviews[pending.bossKey] !== pending) continue;
const reviews = { ...get().reviews, [review.bossKey]: review };
persist(reviews); set({ reviews });
}
const payload = await requestOnlineJson<{ reviews: BossTestingReview[] }>("/api/boss-testing/reviews");
const reviews = { ...get().reviews, ...Object.fromEntries(payload.reviews
.filter((review) => !get().reviews[review.bossKey]?.pending
&& review.reviewedAt >= (get().reviews[review.bossKey]?.reviewedAt ?? 0)).map((review) => [review.bossKey, review])) };
persist(reviews); set({ reviews });
} catch { /* Keep cached labels and sync pending reviews when the connection returns. */ }
},
save: async (boss, status, session) => {
const pending: BossTestingReview = { bossKey: boss.key, dungeonId: boss.sourceDungeonId, bossName: boss.name, status,
reviewedAt: Date.now(), reviewedBy: session?.displayName ?? "Guest player", pending: true };
let reviews = { ...get().reviews, [boss.key]: pending };
persist(reviews); set({ reviews });
try {
const { review } = await sendReview(pending, session);
if (get().reviews[boss.key] === pending) {
reviews = { ...get().reviews, [boss.key]: review };
persist(reviews); set({ reviews });
}
return true;
} catch { return false; }
},
}));
+27
View File
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { prepareBossTesting } from "./bossTestingRuntime";
import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
import * as content from "../content/contentManager";
afterEach(() => vi.restoreAllMocks());
describe("boss asset preparation", () => {
const boss = BOSS_TESTING_CATALOG[0];
it("automatically downloads and verifies only the selected boss's dungeon pack", async () => {
const usable = vi.spyOn(content, "dungeonContentIsUsable").mockReturnValueOnce(false).mockReturnValueOnce(false).mockReturnValue(true);
vi.spyOn(content, "initializeContentSystem").mockResolvedValue();
const install = vi.spyOn(content, "installContentPack").mockResolvedValue();
await prepareBossTesting(boss);
expect(install).toHaveBeenCalledExactlyOnceWith(`dungeon:${boss.dungeonId}`);
expect(usable).toHaveBeenCalledWith(boss.dungeonId);
});
it("reuses installed content and stops entry after failed verification", async () => {
const usable = vi.spyOn(content, "dungeonContentIsUsable").mockReturnValue(true);
vi.spyOn(content, "initializeContentSystem").mockResolvedValue();
const install = vi.spyOn(content, "installContentPack").mockResolvedValue();
await prepareBossTesting(boss);
expect(install).not.toHaveBeenCalled();
usable.mockReturnValue(false);
await expect(prepareBossTesting(boss)).rejects.toThrow(/verified/);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { useShellStore } from "./shellStore";
import { specializationById } from "../game/specializationCatalog";
import { defaultPartyRoleForClass, type PartyRole } from "../game/partyRoles";
import { createBossTestingRoom, type TestableBoss } from "../game/bossTesting";
import { clearBossTestingDungeons, registerBossTestingDungeon, requireDungeonDefinition } from "../game/dungeonRegistry";
import { activateDungeonSession } from "../game/dungeonSession";
import { dungeonContentIsUsable, initializeContentSystem, installContentPack } from "../content/contentManager";
let preparationQueue: Promise<void> = Promise.resolve();
export async function prepareBossTesting(boss: TestableBoss): Promise<void> {
if (dungeonContentIsUsable(boss.dungeonId)) return;
const preparation = preparationQueue.then(async () => {
await initializeContentSystem();
if (!dungeonContentIsUsable(boss.dungeonId)) await installContentPack(`dungeon:${boss.dungeonId}`);
if (!dungeonContentIsUsable(boss.dungeonId)) throw new Error("The boss assets could not be verified. Please retry the download.");
});
preparationQueue = preparation.catch(() => undefined);
await preparation;
}
export function enterBossTesting(boss: TestableBoss, requestedRole?: PartyRole): boolean {
const shell = useShellStore.getState();
const character = shell.characters.find((entry) => entry.id === shell.selectedCharacterId);
if (!shell.session || !character) throw new Error("Select a character first.");
const specialization = specializationById(character.specializationId);
if (character.categoryId !== "rom" && !specialization) throw new Error("Choose a specialization in the Class Hall first.");
const { definition, session } = createBossTestingRoom(requireDungeonDefinition(boss.dungeonId), boss);
registerBossTestingDungeon(definition, boss.dungeonId);
if (!activateDungeonSession(definition.id, undefined, { gmTestMode: true, bossTest: session })) {
clearBossTestingDungeons();
throw new Error("The boss testing room could not be started. Check the downloaded assets and retry.");
}
useShellStore.setState({ phase: "game", activeCharacter: character, activeDungeonId: definition.id,
activeDungeonRole: specialization?.role ?? requestedRole ?? defaultPartyRoleForClass(character.classId), notice: "" });
return true;
}
+5 -2
View File
@@ -16,6 +16,8 @@ export interface DungeonReport {
readonly updatedAt: number;
readonly updatedBy: string | null;
readonly revision: number;
readonly bossKey?: string;
readonly bossName?: string;
}
export interface DungeonReportSnapshot {
@@ -40,10 +42,11 @@ export function fetchDungeonReports(): Promise<DungeonReportSnapshot> {
return requestOnlineJson<DungeonReportSnapshot>("/api/dungeon-reports");
}
export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string): Promise<DungeonReport> {
export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string,
boss?: { readonly bossKey: string; readonly bossName: string }): Promise<DungeonReport> {
const response = await requestOnlineJson<{ report: DungeonReport }>("/api/dungeon-reports", {
method: "POST",
body: JSON.stringify({ dungeonId, comment }),
body: JSON.stringify({ dungeonId, comment, ...boss }),
}, session?.kind === "account" ? session.accessToken : undefined);
return response.report;
}
+113
View File
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createDefaultAppearance } from "./characterCatalog";
import {
createCharacter,
listCharacters,
updateCharacterProgression,
type StorageLike,
} from "./accountRepository";
import { beginMainMenuCharacterEditing } from "./mainMenuCharacterEditing";
import { selectedCharacter, useShellStore } from "./shellStore";
import { useCombatStore } from "../game/combatStore";
import { talentNodesForTree } from "../game/talentCatalog";
class MemoryStorage implements StorageLike {
values = new Map<string, string>();
getItem(key: string) { return this.values.get(key) ?? null; }
setItem(key: string, value: string) { this.values.set(key, value); }
removeItem(key: string) { this.values.delete(key); }
}
describe("main-menu character editing", () => {
let storage: MemoryStorage;
let stop: (() => void) | null;
const holyTalent = talentNodesForTree("priest-holy")[0];
beforeEach(() => {
storage = new MemoryStorage();
stop = null;
const created = createCharacter({
ownerId: "offline-roster", name: "Menuhealer", classId: "priest",
raceId: "human", gender: "female", appearance: createDefaultAppearance(),
specializationId: "priest:holy",
}, storage).character!;
const character = updateCharacterProgression(created.ownerId, created.id, {
level: 17, experience: 123, talentRanks: {},
}, storage)!;
useShellStore.setState({
phase: "main-menu", activeCharacter: null, selectedCharacterId: character.id,
session: { kind: "offline", ownerId: character.ownerId, displayName: "Offline" },
characters: [character], notice: "",
});
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
});
afterEach(() => { stop?.(); });
it("loads the selected character without rewriting their saved profile or starting a dungeon", () => {
const before = [...storage.values];
stop = beginMainMenuCharacterEditing(storage);
expect(stop).not.toBeNull();
expect(useCombatStore.getState()).toMatchObject({ classId: "priest", level: 17, xp: 123 });
expect([...storage.values]).toEqual(before);
expect(useShellStore.getState()).toMatchObject({ phase: "main-menu", activeCharacter: null });
});
it("persists talent allocation and reset, and reloads the saved build when reopened", () => {
stop = beginMainMenuCharacterEditing(storage);
expect(useCombatStore.getState().allocateTalent(holyTalent.id)).toBe(true);
expect(listCharacters("offline-roster", storage)[0].talentRanks[holyTalent.id]).toBe(1);
expect(selectedCharacter(useShellStore.getState())?.talentRanks[holyTalent.id]).toBe(1);
stop!();
stop = beginMainMenuCharacterEditing(storage);
expect(useCombatStore.getState().talentRanks[holyTalent.id]).toBe(1);
useCombatStore.getState().resetTalents();
expect(listCharacters("offline-roster", storage)[0].talentRanks).toEqual({});
expect(listCharacters("offline-roster", storage)[0]).toMatchObject({ level: 17, experience: 123 });
});
it("persists controller assignments and reloads them without touching progression or equipment", () => {
const profile = selectedCharacter(useShellStore.getState())!;
stop = beginMainMenuCharacterEditing(storage);
expect(useCombatStore.getState().setActionBinding("primary", "face-bottom", "priest-flash-heal")).toBe(true);
const saved = listCharacters("offline-roster", storage)[0];
expect(saved.actionBindings["primary:face-bottom"]).toBe("priest-flash-heal");
expect(saved).toMatchObject({ level: profile.level, experience: profile.experience,
talentRanks: profile.talentRanks, inventory: profile.inventory, equipment: profile.equipment });
stop!();
stop = beginMainMenuCharacterEditing(storage);
expect(useCombatStore.getState().actionBindings["primary:face-bottom"]).toBe("priest-flash-heal");
});
it.each(["phase", "owner", "selection", "closed"])("stops saving after the editing context changes: %s", (change) => {
stop = beginMainMenuCharacterEditing(storage);
const before = [...storage.values];
if (change === "phase") useShellStore.setState({ phase: "game" });
if (change === "owner") useShellStore.setState({ session: { kind: "offline", ownerId: "another-owner", displayName: "Other" } });
if (change === "selection") useShellStore.setState({ selectedCharacterId: null });
if (change === "closed") stop!();
useCombatStore.getState().allocateTalent(holyTalent.id);
useCombatStore.getState().setActionBinding("primary", "face-bottom", "priest-flash-heal");
expect([...storage.values]).toEqual(before);
});
it("never saves combat XP as a side effect of a talent edit", () => {
stop = beginMainMenuCharacterEditing(storage);
useCombatStore.setState({ xp: 999 });
useCombatStore.getState().allocateTalent(holyTalent.id);
expect(listCharacters("offline-roster", storage)[0].experience).toBe(123);
});
it("rejects editing without a selected, owned main-menu character", () => {
useShellStore.setState({ selectedCharacterId: null });
expect(beginMainMenuCharacterEditing(storage)).toBeNull();
expect(useCombatStore.getState().classId).toBe("mage");
});
it("reports storage failures instead of updating the roster as though the save succeeded", () => {
stop = beginMainMenuCharacterEditing(storage);
storage.setItem = () => { throw new Error("Storage full"); };
useCombatStore.getState().allocateTalent(holyTalent.id);
expect(useShellStore.getState().notice).toMatch(/could not save/i);
expect(selectedCharacter(useShellStore.getState())?.talentRanks).toEqual({});
});
});
+52
View File
@@ -0,0 +1,52 @@
import {
updateCharacterActionBindings,
updateCharacterProgression,
type StorageLike,
} from "./accountRepository";
import { selectedCharacter, useShellStore } from "./shellStore";
import { useCombatStore } from "../game/combatStore";
// Reuse the character editors without mounting the dungeon's simulation bridge.
export function beginMainMenuCharacterEditing(storage?: StorageLike): (() => void) | null {
const shell = useShellStore.getState();
const character = selectedCharacter(shell);
if (shell.phase !== "main-menu" || !shell.session || !character
|| character.ownerId !== shell.session.ownerId) return null;
const ownerId = shell.session.ownerId;
const characterId = character.id;
useCombatStore.getState().initializeCharacter(character);
return useCombatStore.subscribe((combat, previous) => {
const current = useShellStore.getState();
const profile = selectedCharacter(current);
if (current.phase !== "main-menu" || current.session?.ownerId !== ownerId
|| profile?.id !== characterId || profile.ownerId !== ownerId) return;
if (combat.talentRanks === previous.talentRanks && combat.actionBindings === previous.actionBindings) return;
try {
let updated = profile;
if (combat.talentRanks !== previous.talentRanks) {
const saved = updateCharacterProgression(ownerId, characterId, {
// Menu edits cannot award experience or alter the character's level.
level: profile.level,
experience: profile.experience,
talentRanks: combat.talentRanks,
}, storage);
if (!saved) throw new Error("Character storage is unavailable.");
updated = saved;
}
if (combat.actionBindings !== previous.actionBindings) {
const saved = updateCharacterActionBindings(ownerId, characterId, combat.actionBindings, storage);
if (!saved) throw new Error("Character storage is unavailable.");
updated = saved;
}
useShellStore.setState((state) => ({
characters: state.characters.map((entry) => entry.id === characterId ? updated : entry),
notice: "",
}));
} catch {
useShellStore.getState().setNotice("Could not save character changes. Check that profile storage is available and try again.");
}
});
}
+11 -3
View File
@@ -5,6 +5,7 @@ import {
replaceCharactersForOwner,
setRosterChangeListener,
loadUiPreferences,
loadAccountBossCards,
saveUiPreferences,
type AccountResult,
} from "./accountRepository";
@@ -23,7 +24,7 @@ interface AuthResponse {
interface CloudSaveResponse {
revision: number;
updatedAt: number | null;
data: { version?: number; characters?: unknown; uiPreferences?: unknown };
data: { version?: number; characters?: unknown; uiPreferences?: unknown; bossCards?: unknown };
}
interface MeResponse {
@@ -106,11 +107,12 @@ function flushCloudSave(): void {
pendingCharacters = null;
if (!session?.accessToken || !characters) return;
const uiPreferences = loadUiPreferences(session.ownerId);
const bossCards = loadAccountBossCards(session.ownerId);
const sequence = saveSequence;
saveQueue = saveQueue.then(async () => {
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
method: "PUT",
body: JSON.stringify({ data: { version: 1, characters, uiPreferences } }),
body: JSON.stringify({ data: { version: 2, characters, uiPreferences, bossCards } }),
}, session.accessToken);
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." });
}).catch((error) => {
@@ -149,12 +151,18 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise<Char
activateOnlineSession(session);
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
suppressUpload = true;
let characters: CharacterProfile[];
try {
saveUiPreferences(session.ownerId, cloud.data?.uiPreferences);
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
characters = replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? [], undefined, cloud.data?.bossCards);
} finally {
suppressUpload = false;
}
// Publish migrated logs even if the player has not earned another card yet.
if (JSON.stringify(cloud.data?.bossCards) !== JSON.stringify(loadAccountBossCards(session.ownerId))) {
queueCloudSave(session.ownerId, characters);
}
return characters;
}
export async function refreshOnlineSession(session: PlayerSession): Promise<PlayerSession> {
+1 -1
View File
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
},
});
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
});
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
+19 -15
View File
@@ -5,13 +5,14 @@ import {
createCharacter as persistCharacter,
deleteCharacter as removeCharacter,
listCharacters,
loadAccountBossCards,
loadCombatPresentationSettings,
restoreSession as restoreStoredSession,
saveSession,
updateCharacterActionBindings,
updateCharacterEquipment,
updateCharacterInventory,
updateCharacterBossCards,
saveAccountBossCards as persistAccountBossCards,
updateCharacterManastormProgress,
updateCharacterDungeonRunCheckpoint,
updateCharacterProgression,
@@ -39,7 +40,7 @@ import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from
import type { DungeonId } from "../game/dungeonRegistry";
import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults";
import type { InventoryItem } from "../game/lootTypes";
import type { BossCardCollection } from "../game/bossCards";
import { emptyBossCardCollection, type BossCardCollection } from "../game/bossCards";
import type { EquipmentAssignments } from "../game/equipment";
import type { GameplaySettings } from "../game/combatStore";
import {
@@ -70,6 +71,7 @@ export interface ShellState {
phase: ShellPhase;
session: PlayerSession | null;
characters: CharacterProfile[];
bossCards: BossCardCollection;
selectedCharacterId: string | null;
activeCharacter: CharacterProfile | null;
draft: CharacterDraft;
@@ -111,7 +113,7 @@ export interface ShellState {
saveActiveProgression: (level: number, experience: number, talentRanks: Record<string, number>) => void;
saveActiveActionBindings: (actionBindings: Readonly<Record<string, string | null>>) => void;
saveActiveInventory: (inventory: readonly InventoryItem[]) => void;
saveActiveBossCards: (bossCards: BossCardCollection) => void;
saveAccountBossCards: (bossCards: BossCardCollection) => void;
saveActiveEquipment: (equipment: EquipmentAssignments) => void;
saveActiveSettings: (settings: GameplaySettings) => void;
updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => void;
@@ -138,12 +140,13 @@ function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
};
}
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "selectedCharacterId" | "combatPresentationSettings"> {
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "bossCards" | "selectedCharacterId" | "combatPresentationSettings"> {
const session = restoreStoredSession();
if (!session) return {
phase: "login",
session: null,
characters: [],
bossCards: emptyBossCardCollection(),
selectedCharacterId: null,
combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS,
};
@@ -152,6 +155,7 @@ function restoredState(): Pick<ShellState, "phase" | "session" | "characters" |
phase: "characters",
session,
characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters[0]?.id ?? null,
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
};
@@ -174,6 +178,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
phase: "characters",
session,
characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters[0]?.id ?? null,
activeCharacter: null,
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
@@ -189,6 +194,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
phase: "login",
session: null,
characters: [],
bossCards: emptyBossCardCollection(),
selectedCharacterId: null,
activeCharacter: null,
draft: defaultDraft(),
@@ -276,6 +282,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({
phase: "characters",
characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: result.character.id,
draft: defaultDraft(),
notice: `${result.character.name} is ready for the expedition.`,
@@ -439,7 +446,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
return true;
},
returnToMainMenu: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame();
const { session, characters, activeCharacter, selectedCharacterId } = get();
if (!session) {
@@ -477,7 +484,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
},
enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID),
returnToCharacters: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
const session = get().session;
if (!session) {
set({ phase: "login", activeCharacter: null });
@@ -488,6 +495,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({
phase: "characters",
characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters.some((character) => character.id === currentId)
? currentId
: characters[0]?.id ?? null,
@@ -538,15 +546,10 @@ export const useShellStore = create<ShellState>((set, get) => ({
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveActiveBossCards: (bossCards) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
const updated = updateCharacterBossCards(session.ownerId, activeCharacter.id, bossCards);
if (!updated) return;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
saveAccountBossCards: (bossCards) => {
const { session } = get();
if (!session) return;
set({ bossCards: persistAccountBossCards(session.ownerId, bossCards) });
},
saveActiveEquipment: (equipment) => {
const { session, activeCharacter } = get();
@@ -651,6 +654,7 @@ if (restored.session?.accessToken) {
useShellStore.setState({
session: refreshedSession,
characters,
bossCards: loadAccountBossCards(refreshedSession.ownerId),
selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId)
? useShellStore.getState().selectedCharacterId
: characters[0]?.id ?? null,
+1
View File
@@ -42,6 +42,7 @@ export interface CharacterProfile {
actionLoadouts?: Record<string, Record<string, string | null>>;
inventory: InventoryItem[];
equipment: EquipmentAssignments;
/** Legacy character log, read only during migration to the account collection. */
bossCards?: BossCardCollection;
/** Present on normalized and newly created profiles; optional for legacy callers. */
manastormProgress?: ManastormProgress;
+6 -5
View File
@@ -5,6 +5,7 @@ import { controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { resolveDisplayLayout } from "../platform/displayLayout";
import { THOR_DISPLAYS } from "../platform/thorDisplayProfile";
import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
interface DualDisplayFrameProps {
@@ -136,12 +137,12 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
}
return (
<div className="thor-preview-frame">
<div className="thor-preview-label"><b>Main display</b><span>960 x 540 CSS / 1920 x 1080</span></div>
<div className="thor-preview-top">{top}</div>
<div className="thor-preview-frame" style={{ width: THOR_DISPLAYS.top.cssWidth + 80 }}>
<div className="thor-preview-label"><b>Top · 6 inches</b><span>1920 × 1080 · {THOR_DISPLAYS.top.widthMm.toFixed(1)} × {THOR_DISPLAYS.top.heightMm.toFixed(1)} mm</span></div>
<div className="thor-preview-top" style={{ width: THOR_DISPLAYS.top.cssWidth, height: THOR_DISPLAYS.top.cssHeight }}>{top}</div>
<div className="thor-preview-hinge"><i /><b>AYN THOR</b><i /></div>
<div className="thor-preview-label"><b>Companion display</b><span>620 x 540 CSS / 1240 x 1080</span></div>
<div className="thor-preview-bottom">{bottom}</div>
<div className="thor-preview-label"><b>Bottom · 3.92 inches</b><span>1240 × 1080 · {THOR_DISPLAYS.bottom.widthMm.toFixed(1)} × {THOR_DISPLAYS.bottom.heightMm.toFixed(1)} mm</span></div>
<div className="thor-preview-bottom" style={{ width: THOR_DISPLAYS.bottom.cssWidth, height: THOR_DISPLAYS.bottom.cssHeight }}>{bottom}</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from "react";
import { useCombatStore } from "./combatStore";
import { useGameStore } from "./store";
export function bossTestIsDefeated(): boolean {
const test = useGameStore.getState().bossTest;
const mobs = useCombatStore.getState().mobs;
return Boolean(test?.runtimeIds.length && test.runtimeIds.every((id) => mobs[id]?.dead));
}
export function BossTestingBridge() {
useEffect(() => {
let defeated = false;
const observe = () => {
const next = bossTestIsDefeated();
if (next && !defeated) useGameStore.getState().openOverlay("boss-test-review");
defeated = next;
};
const unsubscribeCombat = useCombatStore.subscribe(observe);
const unsubscribeGame = useGameStore.subscribe((state, previous) => {
if (state.encounterResetRevision !== previous.encounterResetRevision || state.bossTest !== previous.bossTest) defeated = false;
});
observe();
return () => { unsubscribeCombat(); unsubscribeGame(); };
}, []);
return null;
}
+5 -3
View File
@@ -40,7 +40,7 @@ export function CombatBridge() {
useEffect(() => {
if (!character) return;
useCombatStore.getState().initializeCharacter({ ...character, role: activeRole });
useCombatStore.getState().initializeCharacter({ ...character, bossCards: useShellStore.getState().bossCards, role: activeRole });
useCombatStore.getState().synchronizeActors();
if (gameMode === "manastorm") {
activateManastormActiveSpellLoadout(
@@ -66,12 +66,14 @@ export function CombatBridge() {
useEffect(() => {
if (!character) return;
const characterId = character.id;
const ownerId = useShellStore.getState().session?.ownerId;
const unsubscribe = useCombatStore.subscribe((state, previousState) => {
// Save APIs intentionally target the active character. Avoid persisting a
// final combat notification during the render-to-effect-cleanup window
// after Character Select has already replaced the active character.
const shell = useShellStore.getState();
if (shell.activeCharacter?.id !== characterId) return;
if (shell.activeCharacter?.id !== characterId || shell.session?.ownerId !== ownerId) return;
if (useGameStore.getState().bossTest) return;
// Persisted collections are replaced immutably by combat actions. A
// reference/primitive comparison therefore detects the same changes as
@@ -101,7 +103,7 @@ export function CombatBridge() {
shell.saveActiveInventory(state.inventory);
}
if (state.bossCards !== previousState.bossCards) {
shell.saveActiveBossCards(state.bossCards);
shell.saveAccountBossCards(state.bossCards);
}
if (state.equipment !== previousState.equipment) {
shell.saveActiveEquipment(state.equipment);
+3 -2
View File
@@ -246,6 +246,7 @@ function applyRelayEvent(
}
export function OnlineSessionBridge() {
const bossTest = useGameStore((state) => state.bossTest);
const session = useShellStore((state) => state.session);
const activeActivity = useOnlineGroupStore((state) => state.activeActivity);
const group = useOnlineGroupStore((state) => state.snapshot.group);
@@ -256,7 +257,7 @@ export function OnlineSessionBridge() {
const applyingRef = useRef(false);
useEffect(() => {
if (!session?.accessToken || !activeActivity || !group) {
if (bossTest || !session?.accessToken || !activeActivity || !group) {
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
reset();
return undefined;
@@ -362,7 +363,7 @@ export function OnlineSessionBridge() {
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
reset();
};
}, [accept, activeActivity?.id, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]);
}, [accept, activeActivity?.id, bossTest, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]);
return null;
}
+29
View File
@@ -49,6 +49,35 @@ export function normalizeBossCardCollection(value: unknown): BossCardCollection
return { counts, lastClaims };
}
/** Sum independent legacy character logs; max reconciles copies of the same account log. */
export function mergeBossCardCollections(collections: readonly unknown[], mode: "sum" | "max" = "max"): BossCardCollection {
const counts: Record<string, BossCardCounts> = Object.create(null);
const lastClaims: Record<string, string> = Object.create(null);
for (const value of collections) {
const collection = normalizeBossCardCollection(value);
for (const [bossKey, entry] of Object.entries(collection.counts)) {
const merged = counts[bossKey] ??= {};
for (const rarity of BOSS_CARD_RARITIES) {
const count = entry[rarity];
if (count === undefined) continue;
merged[rarity] = mode === "sum"
? Math.min(Number.MAX_SAFE_INTEGER, (merged[rarity] ?? 0) + count)
: Math.max(merged[rarity] ?? 0, count);
}
}
for (const [spawnId, claim] of Object.entries(collection.lastClaims)) {
const previous = lastClaims[spawnId];
if (!previous) { lastClaims[spawnId] = claim; continue; }
if (/^\d+:/.test(previous) && /^\d+:/.test(claim)) {
const [previousTime, previousRevision] = previous.split(":").map(Number);
const [time, revision] = claim.split(":").map(Number);
if (time > previousTime || (time === previousTime && revision > previousRevision)) lastClaims[spawnId] = claim;
} else if (claim > previous) lastClaims[spawnId] = claim;
}
}
return { counts, lastClaims };
}
/** Independent bonus rolls; Normal is always awarded, including with all three bonuses. */
export function rollBossCards(random: () => number = Math.random): readonly BossCardRarity[] {
return BOSS_CARD_RARITIES.filter((rarity) => {
+82
View File
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it } from "vitest";
import { buildBossTestingCatalog, BOSS_TESTING_CATALOG, createBossTestingRoom, randomTestingBoss } from "./bossTesting";
import { clearBossTestingDungeons, DUNGEON_DEFINITIONS, registerBossTestingDungeon, requireDungeonDefinition } from "./dungeonRegistry";
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
import { useGameStore } from "./store";
import { useCombatStore } from "./combatStore";
import { bossTestIsDefeated } from "./BossTestingBridge";
import type { DungeonDefinition } from "./dungeonTypes";
afterEach(() => {
useGameStore.getState().activateDungeon("wailing-caverns");
clearBossTestingDungeons();
});
describe("Boss Testing", () => {
const boss = BOSS_TESTING_CATALOG.find((entry) => entry.dungeonId === "wailing-caverns" && entry.name === "Lady Anacondra")!;
const source = requireDungeonDefinition(boss.dungeonId);
it("includes unready and event-only bosses and isolates catalog changes from shipped content", () => {
const blocked: DungeonDefinition = { ...source, availability: "blocked", staticSpawns: [], roamingPacks: [],
encounterSpawnGroups: [{ id: "event", spawns: [boss.spawn] }], entities: { [boss.entityId]: source.entities[boss.entityId] } };
expect(buildBossTestingCatalog([blocked])).toHaveLength(1);
const count = DUNGEON_DEFINITIONS.length;
const room = createBossTestingRoom(source, boss);
registerBossTestingDungeon(room.definition, source.id);
expect(requireDungeonDefinition(source.id)).toBe(source);
expect(DUNGEON_DEFINITIONS).toHaveLength(count);
expect(room.definition.staticSpawns).toHaveLength(1);
expect(room.definition.roamingPacks).toEqual([]);
expect(room.definition.entities[boss.entityId].combat).toEqual(source.entities[boss.entityId].combat);
expect(room.definition.staticSpawns[0].position).toEqual(boss.spawn.position);
expect(room.definition.assets).toEqual({ visual: [], collision: [] });
});
it("preserves boss phases and scripted summons without spawning unrelated dungeon packs", () => {
const fixture: DungeonDefinition = { ...source, encounters: [
{ id: "boss-mechanics", dungeonId: source.id, initialPhase: "idle", rules: [{ id: "adds", trigger: { kind: "entity-health", entityId: boss.spawn.id, atOrBelowPercent: 50 },
actions: [{ id: "summon", kind: "activate-spawn-group", spawnGroupId: "boss-adds" }, { id: "loot", kind: "grant-loot", lootTableId: "test" }] }] },
{ id: "other-boss", dungeonId: source.id, initialPhase: "idle", rules: [{ id: "other", trigger: { kind: "entity-engaged", entityId: "unrelated" }, actions: [] }] },
], encounterSpawnGroups: [{ id: "boss-adds", spawns: [{ ...boss.spawn, id: "add", entityId: boss.entityId }] }, { id: "trash", spawns: [] }] };
const room = createBossTestingRoom(fixture, boss);
expect(room.definition.encounters?.map((entry) => entry.id)).toEqual([`${room.definition.id}:boss-mechanics`]);
expect(room.definition.encounters?.[0].dungeonId).toBe(room.definition.id);
expect(room.definition.encounters?.[0].rules[0].actions).toEqual([{ id: "summon", kind: "activate-spawn-group", spawnGroupId: "boss-adds" }]);
expect(room.definition.encounterSpawnGroups?.map((entry) => entry.id)).toEqual(["boss-adds"]);
expect(room.definition.staticSpawns).toHaveLength(1);
});
it("starts an unready boss room, survives scene retries, and retains testing mode when restarted", () => {
const room = createBossTestingRoom(source, boss);
registerBossTestingDungeon(room.definition, source.id);
expect(activateDungeonSession(room.definition.id)).toBe(false);
expect(activateDungeonSession(room.definition.id, undefined, { gmTestMode: true, bossTest: room.session })).toBe(true);
expect(restartActiveDungeonSession()).toBe(true);
useGameStore.getState().resetAtEntrance();
expect(useGameStore.getState()).toMatchObject({ gmTestMode: true, bossTest: room.session, overlay: null });
});
it("recognizes actual boss deaths and awards no progression or collectible rewards", () => {
const room = createBossTestingRoom(source, boss);
registerBossTestingDungeon(room.definition, source.id);
useGameStore.getState().activateDungeon(room.definition.id, undefined, { bossTest: room.session, gmTestMode: true });
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 20 });
expect(bossTestIsDefeated()).toBe(false);
useCombatStore.getState().registerMob(boss.spawn.id, { name: boss.name, boss: true, maxHealth: 10, xpReward: 500, hasLoot: true, lootSourceId: boss.entityId });
const xp = useCombatStore.getState().xp;
useCombatStore.getState().damageMob(boss.spawn.id, 100000, Date.now());
expect(bossTestIsDefeated()).toBe(true);
const combat = useCombatStore.getState();
expect(combat.xp).toBe(xp);
expect(combat.mobs[boss.spawn.id]).toMatchObject({ hasLoot: false, pendingLoot: [] });
expect(combat.mobs[boss.spawn.id].bossCardDrop).toBeFalsy();
});
it("draws a boss only from the selected filters and handles empty queues", () => {
expect(randomTestingBoss([], () => 0.5)).toBeNull();
const candidates = BOSS_TESTING_CATALOG.slice(0, 2);
expect(randomTestingBoss(candidates, () => 0)).toBe(candidates[0]);
expect(randomTestingBoss(candidates, () => 1)).toBe(candidates[1]);
expect(randomTestingBoss(candidates, () => Number.NaN)).toBe(candidates[0]);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
import type { DungeonDefinition, Vector3Tuple } from "./dungeonTypes";
import type { StaticMobSpawnDefinition } from "./mobPopulation";
import type { BossTestSession } from "./store";
export interface TestableBoss {
readonly key: string;
readonly name: string;
readonly dungeonId: string;
readonly dungeonTitle: string;
readonly entityId: string;
readonly category: "wow" | "rom";
readonly spawn: StaticMobSpawnDefinition;
}
export function buildBossTestingCatalog(definitions: readonly DungeonDefinition[]): readonly TestableBoss[] {
return definitions.flatMap((dungeon) => Object.values(dungeon.entities).flatMap((entity): TestableBoss[] => {
if (entity.kind !== "boss") return [];
const spawn = dungeon.staticSpawns.find((candidate) => candidate.entityId === entity.id)
?? dungeon.encounterSpawnGroups?.flatMap((group) => group.spawns).find((candidate) => candidate.entityId === entity.id);
const objective = dungeon.bosses.find((boss) => boss.id === spawn?.id || boss.name === entity.name);
const pack = dungeon.roamingPacks.find((candidate) => candidate.members.some((member) => member.entityId === entity.id));
const member = pack?.members.find((candidate) => candidate.entityId === entity.id);
const position = spawn?.position ?? objective?.position ?? pack?.waypoints[0];
if (!position) return [];
return [{
key: `${dungeon.id}/${entity.id}`, name: entity.name, dungeonId: dungeon.id,
dungeonTitle: dungeon.title, entityId: entity.id,
category: dungeon.expansion === "runewaker" ? "rom" : "wow",
spawn: { ...spawn, id: spawn?.id ?? (pack && member ? `${pack.id}:${member.id}` : objective?.id ?? entity.id),
entityId: entity.id, position, spawnChance: 1 },
}];
})).sort((a, b) => a.name.localeCompare(b.name) || a.dungeonTitle.localeCompare(b.dungeonTitle));
}
export const BOSS_TESTING_CATALOG = buildBossTestingCatalog(DUNGEON_DEFINITIONS);
export function randomTestingBoss(bosses: readonly TestableBoss[], random: () => number = Math.random): TestableBoss | null {
if (!bosses.length) return null;
const draw = random();
const index = Math.floor((Number.isFinite(draw) ? Math.max(0, Math.min(0.999999999, draw)) : 0) * bosses.length);
return bosses[index];
}
function references(value: unknown, ids: ReadonlySet<string>): boolean {
if (typeof value === "string") return ids.has(value);
if (Array.isArray(value)) return value.some((entry) => references(entry, ids));
return Boolean(value && typeof value === "object" && Object.values(value).some((entry) => references(entry, ids)));
}
function remapEncounterReferences<T>(value: T, ids: ReadonlyMap<string, string>): T {
if (typeof value === "string") return (ids.get(value) ?? value) as T;
if (Array.isArray(value)) return value.map((entry) => remapEncounterReferences(entry, ids)) as T;
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value)
.map(([key, entry]) => [key, remapEncounterReferences(entry, ids)])) as T;
return value;
}
/** Keep original mechanic coordinates and spawn IDs while replacing the environment with a clear floor. */
export function createBossTestingRoom(source: DungeonDefinition, boss: TestableBoss): {
readonly definition: DungeonDefinition;
readonly session: BossTestSession;
} {
const entity = source.entities[boss.entityId];
if (source.id !== boss.dungeonId || entity?.kind !== "boss") throw new Error("That boss is unavailable.");
const id = `boss-testing:${source.id}:${boss.entityId}`;
const ids = new Set([boss.entityId, boss.spawn.id]);
const sourceEncounters = (source.encounters ?? []).filter((encounter) =>
references(encounter.rules.map((rule) => rule.trigger), ids)
|| references(entity.combat, new Set([encounter.id]))
);
const encounterIds = new Map(sourceEncounters.map((encounter) => [encounter.id, `${id}:${encounter.id}`]));
const encounters = sourceEncounters.map((encounter) => {
const isolated = remapEncounterReferences(encounter, encounterIds);
return { ...isolated, id: encounterIds.get(encounter.id)!, dungeonId: id,
rules: isolated.rules.map((rule) => ({ ...rule, actions: rule.actions.filter((action) =>
action.kind !== "grant-loot" && action.kind !== "set-checkpoint"
) })),
};
});
const groups = (source.encounterSpawnGroups ?? []).filter((group) => references(encounters, new Set([group.id])));
const objects = source.serverGameObjects.filter((object) => references(encounters, new Set([object.id, object.interaction?.signalId ?? ""])));
const entityIds = new Set([boss.entityId, ...groups.flatMap((group) => group.spawns.map((spawn) => spawn.entityId))]);
// Summoned creature definitions are needed even though they are absent from the initial room.
for (const entityId of entityIds) {
for (const attack of source.entities[entityId]?.combat?.attacks ?? []) {
for (const effect of attack.effect ? [attack.effect] : []) {
if (effect.kind !== "summon") continue;
for (const candidate of Object.values(source.entities)) {
if (candidate.id === effect.entityId || candidate.combat?.serverEntry === effect.creatureEntry) entityIds.add(candidate.id);
}
}
}
}
const center = boss.spawn.position;
const radius = Math.max(40, ...groups.flatMap((group) => group.spawns.map((spawn) =>
Math.hypot(spawn.position[0] - center[0], spawn.position[2] - center[2]) + 8
)), ...objects.map((object) => Math.hypot(object.position[0] - center[0], object.position[2] - center[2]) + 8));
const spawnDistance = Math.max(14, (entity.combat?.aggroRange ?? 12) + 5);
const entrance: Vector3Tuple = [center[0], center[1], center[2] + Math.min(radius - 5, spawnDistance)];
const spawn = { ...boss.spawn, spawnMask: undefined, spawnChance: 1 };
const objective = { id: spawn.id, name: boss.name, position: center };
return {
session: { key: boss.key, name: boss.name, sourceDungeonId: source.id, entityId: boss.entityId, runtimeIds: [spawn.id] },
definition: {
...source, id, title: `${boss.name} — Boss Testing`, availability: "available",
assets: { visual: [], collision: [] },
bounds: { min: [center[0] - radius, center[1] - 2, center[2] - radius], max: [center[0] + radius, center[1] + 24, center[2] + radius] },
entrance: { name: "Testing room", footPosition: entrance, yaw: Math.PI, forward: [0, 0, -1] },
areas: [{ id: "testing-room", name: "Boss Testing", center, radius: radius * 2 }],
entities: Object.fromEntries([...entityIds].filter((entityId) => source.entities[entityId])
.map((entityId) => [entityId, remapEncounterReferences(source.entities[entityId], encounterIds)])),
staticSpawns: [spawn], roamingPacks: [], bosses: [objective], objectiveOrder: [objective],
navigationLinks: [], traversalRamps: [], encounters, encounterSpawnGroups: groups, serverGameObjects: objects,
presentation: { ...source.presentation, location: "Boss Testing", summary: "An isolated room for testing boss mechanics.",
loadingMessage: `Preparing ${boss.name}…`, background: "#111923",
fog: { color: "#111923", near: radius, far: radius * 3 },
ambientLight: { color: "#ffffff", intensity: 1.2 },
hemisphereLight: { skyColor: "#c8ddf0", groundColor: "#444c57", intensity: 1.4 },
map: { width: 600, height: 600, padding: 20, contours: [] } },
},
};
}
+5 -4
View File
@@ -2631,15 +2631,16 @@ function damageMobInWork(
const health = mob.health - applied;
const killed = health <= 0;
const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt;
const canAward = killed && !mob.experienceGranted;
const testing = Boolean(useGameStore.getState().bossTest);
const canAward = killed && !mob.experienceGranted && !testing;
const beforeLevel = work.progression.level;
const pendingLoot = killed && mob.lootSourceId
const pendingLoot = testing ? [] : killed && mob.lootSourceId
? rollBossLoot(mob.lootSourceId, beforeLevel, {
now,
bonusItemChance: mob.bonusLootChance,
})
: mob.pendingLoot;
const card = killed && mob.boss
const card = killed && mob.boss && !testing
? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name)
: null;
const bossCardDrop = card ? {
@@ -2648,7 +2649,7 @@ function damageMobInWork(
claimId: `${now}:${mob.deathRevision + 1}:${mob.id}`,
rarities: rollBossCards(),
} satisfies BossCardDrop : mob.bossCardDrop;
const hasLoot = killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot;
const hasLoot = testing ? false : killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot;
const xpAwarded = canAward ? grantMobExperience(classId, work, mob.xpReward) : 0;
engageMobGroupInWork(work, id, now, source.actorId);
const engagedMob = work.mobs[id] ?? mob;
+20 -1
View File
@@ -199,8 +199,27 @@ export type DungeonId = string;
export const DUNGEON_DEFINITIONS = Object.freeze(Object.values(DUNGEON_REGISTRY));
// Test rooms are session-only definitions; the shipped dungeon catalog stays immutable.
const bossTestingDefinitions = new Map<string, { definition: DungeonDefinition; sourceDungeonId: string }>();
export function registerBossTestingDungeon(definition: DungeonDefinition, sourceDungeonId: string): void {
if (!definition.id.startsWith("boss-testing:") || !DUNGEON_REGISTRY[sourceDungeonId]) {
throw new Error("Invalid boss testing dungeon.");
}
bossTestingDefinitions.clear();
bossTestingDefinitions.set(definition.id, { definition, sourceDungeonId });
}
export function bossTestingSourceDungeonId(id: string): string | null {
return bossTestingDefinitions.get(id)?.sourceDungeonId ?? null;
}
export function clearBossTestingDungeons(): void {
bossTestingDefinitions.clear();
}
export function dungeonDefinitionById(id: string): DungeonDefinition | null {
return DUNGEON_REGISTRY[id] ?? null;
return bossTestingDefinitions.get(id)?.definition ?? DUNGEON_REGISTRY[id] ?? null;
}
export function requireDungeonDefinition(id: string): DungeonDefinition {
+9 -3
View File
@@ -5,8 +5,8 @@ import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { resetPartyRuntimeClock } from "./partyRuntime";
import { usePartyStore } from "./partyStore";
import { useGameStore, type GameMode } from "./store";
import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
import { useGameStore, type BossTestSession, type GameMode } from "./store";
import { bossTestingSourceDungeonId, dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
import type { WorldSpawn } from "./dungeonTypes";
import type { GmReviewTarget } from "./gmContentReview";
import { shelvedDungeonSpawn } from "@shelvedDungeonSession";
@@ -27,6 +27,7 @@ export interface DungeonSessionOptions {
readonly gameMode?: GameMode;
readonly spawn?: WorldSpawn;
readonly gmTestMode?: boolean;
readonly bossTest?: BossTestSession | null;
readonly gmReviewTarget?: GmReviewTarget | null;
/** Used by in-game encounter transitions so character progression and inventory survive. */
readonly preserveCharacter?: boolean;
@@ -47,7 +48,10 @@ export function activateDungeonSession(
options: DungeonSessionOptions = {},
): boolean {
const definition = dungeonDefinitionById(dungeonId);
if (!definition || !dungeonCanEnter(dungeonId) || !dungeonContentIsUsable(dungeonId)) {
const testingSource = bossTestingSourceDungeonId(dungeonId);
const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource);
if (!definition || (!testing && !dungeonCanEnter(dungeonId))
|| !dungeonContentIsUsable(testing ? testingSource! : dungeonId)) {
return false;
}
if ((difficultyId !== undefined && !definition.difficultyVariants.some((variant) => variant.id === difficultyId))
@@ -107,6 +111,7 @@ export function activateDungeonSession(
gameMode: options.gameMode,
spawn: shelvedDungeonSpawn(dungeonId, options.spawn),
gmTestMode: options.gmTestMode,
bossTest: options.bossTest,
gmReviewTarget: options.gmReviewTarget,
bossDifficulty,
});
@@ -128,6 +133,7 @@ export function restartActiveDungeonSession(): boolean {
gameMode: game.gameMode,
spawn: game.activeSpawn,
gmTestMode: game.gmTestMode,
bossTest: game.bossTest,
gmReviewTarget: game.activeGmReviewTarget,
preserveCharacter: true,
bossDifficulty: game.activeBossDifficulty,
+14 -2
View File
@@ -9,7 +9,15 @@ import { validBossDifficulty } from "./dungeonDifficulty";
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
export type InputMode = "keyboard" | "gamepad";
export type GameMode = "dungeon" | "manastorm";
export type GameOverlay = "pause" | "report-issue" | "party" | "inventory" | "boss-cards" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | "dungeon-results" | null;
export type GameOverlay = "pause" | "boss-test-review" | "report-issue" | "party" | "inventory" | "boss-cards" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | "dungeon-results" | null;
export interface BossTestSession {
readonly key: string;
readonly name: string;
readonly sourceDungeonId: string;
readonly entityId: string;
readonly runtimeIds: readonly string[];
}
interface PlayerSnapshot {
position: Vector3Tuple;
@@ -24,6 +32,7 @@ export interface GameState {
activeBossDifficulty: number;
gameMode: GameMode;
gmTestMode: boolean;
bossTest: BossTestSession | null;
activeGmReviewTarget: GmReviewTarget | null;
activeSpawn: WorldSpawn;
sessionRevision: number;
@@ -55,6 +64,7 @@ export interface GameState {
readonly gameMode?: GameMode;
readonly spawn?: WorldSpawn;
readonly gmTestMode?: boolean;
readonly bossTest?: BossTestSession | null;
readonly gmReviewTarget?: GmReviewTarget | null;
readonly bossDifficulty?: number;
},
@@ -88,6 +98,7 @@ export const useGameStore = create<GameState>((set) => ({
activeBossDifficulty: 1,
gameMode: "dungeon",
gmTestMode: false,
bossTest: null,
activeGmReviewTarget: null,
activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw },
sessionRevision: 0,
@@ -125,6 +136,7 @@ export const useGameStore = create<GameState>((set) => ({
activeBossDifficulty: validBossDifficulty(options?.bossDifficulty) ? options.bossDifficulty : 1,
gameMode: options?.gameMode ?? "dungeon",
gmTestMode: options?.gmTestMode ?? false,
bossTest: options?.bossTest ?? null,
activeGmReviewTarget: options?.gmReviewTarget ?? null,
activeSpawn,
sessionRevision: state.sessionRevision + 1,
@@ -279,7 +291,7 @@ export const useGameStore = create<GameState>((set) => ({
const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw };
return {
gameMode: "dungeon",
gmTestMode: false,
gmTestMode: Boolean(state.bossTest),
activeGmReviewTarget: null,
activeSpawn,
overlay: null,
+21
View File
@@ -2,9 +2,30 @@ import { describe, expect, it } from "vitest";
import {
actionKeyForEvent, assignDesktopAbility, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resetDesktopAbilities, resolveDpsMeterDisplay,
HUD_ELEMENTS, resolveHudScreen,
} from "./uiLayout";
describe("personal UI layouts", () => {
it("assigns every element one display, saves routing, and keeps single-display widgets available", () => {
const preferences = normalizeUiPreferences(null);
const layout = preferences.current.mobile;
expect(resolveHudScreen("party", layout, true)).toBe("top");
for (const id of ["actions", "player", "cast", "experience"] as const) expect(resolveHudScreen(id, layout, true)).toBe("bottom");
for (const id of Object.keys(HUD_ELEMENTS) as (keyof typeof HUD_ELEMENTS)[]) {
layout.elements[id].screen = "bottom";
expect(resolveHudScreen(id, layout, true)).toBe("bottom");
expect(resolveHudScreen(id, layout, false)).toBe("top");
if (id === "dps") {
expect(resolveDpsMeterDisplay(layout, false)).toBe("main");
expect(resolveDpsMeterDisplay(layout, true)).toBe("companion");
}
layout.elements[id].screen = "top";
expect(resolveHudScreen(id, layout, true)).toBe("top");
layout.elements[id].screen = "hidden";
expect(resolveHudScreen(id, layout, true)).toBe("hidden");
}
expect(importUiPreferences(exportUiPreferences(preferences))).toEqual(preferences);
});
it("saves and clears abilities on inactive WoW bars without changing keys, templates, or mobile controls", () => {
const ui = normalizeUiPreferences(null);
ui.slots.pc[0] = { name: "Original", layout: ui.current.pc };
+18 -2
View File
@@ -6,7 +6,7 @@ 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",
player: "Buffs and debuffs", timeline: "Encounter timeline", target: "Enemy target",
friendly: "Friendly target", threat: "Threat meter", dps: "DPS meter", cast: "Cast bar",
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
@@ -16,8 +16,10 @@ export const HUD_ELEMENTS = {
"bar-4": "Action bar 4", "bar-5": "Action bar 5",
} as const;
export type HudElementId = keyof typeof HUD_ELEMENTS;
export type HudScreen = "auto" | "top" | "bottom" | "hidden";
export interface HudElementLayout {
visible: boolean;
screen?: HudScreen;
position: NormalizedHudPosition | null;
width: number | null;
}
@@ -87,6 +89,7 @@ export function normalizeUiLayout(value: unknown): UiLayout {
const element = record(elements[id]);
defaults.elements[id] = {
visible: element.visible !== false,
...(["auto", "top", "bottom", "hidden"].includes(String(element.screen)) ? { screen: element.screen as HudScreen } : {}),
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,
};
@@ -114,8 +117,21 @@ export function normalizeUiLayout(value: unknown): UiLayout {
}
export function resolveDpsMeterDisplay(layout: UiLayout, dualScreen: boolean): "main" | "companion" {
const screen = layout.elements.dps.screen;
if (screen === "top" || screen === "bottom") return !dualScreen || screen === "top" ? "main" : "companion";
return layout.dpsMeterDisplay === "auto" ? dualScreen ? "companion" : "main" : layout.dpsMeterDisplay;
}
/** One owner per widget; single-display layouts keep all visible widgets available. */
export function resolveHudScreen(id: HudElementId, layout: UiLayout, dualScreen: boolean): "top" | "bottom" | "hidden" {
const element = layout.elements[id];
if (!element.visible || element.screen === "hidden") return "hidden";
if (!dualScreen) return "top";
if (element.screen === "top" || element.screen === "bottom") return element.screen;
if (id === "dps") return resolveDpsMeterDisplay(layout, true) === "main" ? "top" : "bottom";
if (["player", "actions", "cast", "experience", "bar-1", "bar-2", "bar-3", "bar-4", "bar-5"].includes(id)) return "bottom";
if (id === "hints") return "hidden";
return "top";
}
export function normalizeUiPreferences(value: unknown): UiPreferences {
const source = record(value);
const current = record(source.current);
@@ -174,7 +190,7 @@ export function importUiPreferences(text: string): UiPreferences {
if (id === "dps" && record(layout.elements)[id] === undefined) continue;
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))
if ((element.screen !== undefined && !["auto", "top", "bottom", "hidden"].includes(String(element.screen))) || 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>();
+2 -1
View File
@@ -6,7 +6,8 @@ import { normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreference
import { publishInputPreferences } from "../input/keybindRuntime";
export function currentUiDevice(): UiDevice {
return Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent))
return (typeof window !== "undefined" && /(?:[?&]layout=(?:thor-preview|dual)|[?&]display=(?:top|bottom))\b/.test(window.location.search))
|| 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";
}
+6
View File
@@ -4,15 +4,21 @@ import { Capacitor } from "@capacitor/core";
import App from "./App";
import { startControllerInput } from "./input/controller";
import { currentDisplayLayout } from "./platform/displayLayout";
import { THOR_DISPLAYS } from "./platform/thorDisplayProfile";
import { initializeContentSystem } from "./content/contentManager";
import "./styles.css";
import "./ui/dungeonReports.css";
import "./ui/thorUi.css";
const BottomDisplayApp = lazy(() => import("./platform/BottomDisplayApp").then((module) => ({ default: module.BottomDisplayApp })));
const params = new URLSearchParams(window.location.search);
const displayMode = params.get("display");
const nativeLayoutRequested = params.has("nativeLayout");
if (Capacitor.isNativePlatform() && (displayMode === "top" || displayMode === "bottom")) {
// Let each WebView fit the same physical UI density, independently of Android's shared display density.
document.querySelector('meta[name="viewport"]')?.setAttribute("content", `width=${THOR_DISPLAYS[displayMode].cssWidth}, viewport-fit=cover`);
}
if (Capacitor.isNativePlatform() || nativeLayoutRequested) document.documentElement.classList.add("native-platform");
if (displayMode === "top" || displayMode === "bottom") document.documentElement.dataset.displaySurface = displayMode;
document.documentElement.dataset.displayLayout = currentDisplayLayout();
+4 -1
View File
@@ -15,6 +15,7 @@ import {
import { normalizeInputPreferences } from "../input/keybinds";
import { publishInputPreferences } from "../input/keybindRuntime";
import { useForcedThorDisplays } from "./useThorDualScreen";
import { RemoteDungeonHud } from "./RemoteDungeonHud";
export function BottomDisplayApp() {
useForcedThorDisplays();
@@ -78,5 +79,7 @@ export function BottomDisplayApp() {
};
}, []);
return <main className="bottom-display-root"><CompanionPanel snapshot={snapshot} send={send} preferControllerBinds /></main>;
return <main className="bottom-display-root">{snapshot.phase === "game"
? <RemoteDungeonHud snapshot={snapshot} send={send} />
: <CompanionPanel snapshot={snapshot} send={send} preferControllerBinds />}</main>;
}
+53
View File
@@ -0,0 +1,53 @@
import { useLayoutEffect } from "react";
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store";
import { clearBossTestingDungeons, registerBossTestingDungeon } from "../game/dungeonRegistry";
import { usePartyStore } from "../game/partyStore";
import { useManastormStore } from "../game/manastormStore";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useShellStore } from "../app/shellStore";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore";
import { DungeonCompanionHud } from "../ui/DungeonCompanionHud";
import type { CompanionCommand, CompanionSnapshot } from "./dualScreenSync";
export function RemoteDungeonHud({ snapshot, send }: {
snapshot: CompanionSnapshot;
send: (command: CompanionCommand) => void;
}) {
const hud = snapshot.game.hud;
useLayoutEffect(() => {
if (hud.bossTestingDungeon) registerBossTestingDungeon(hud.bossTestingDungeon.definition, hud.bossTestingDungeon.sourceDungeonId);
const session = useShellStore.getState().session;
useShellStore.setState({ ...hud.shell,
session: session?.ownerId === snapshot.session?.ownerId ? session : snapshot.session,
});
useUiLayoutStore.setState(hud.ui);
useGameStore.setState({ ...hud.game,
toggleMap: () => send({ name: "toggleMap" }),
openOverlay: (overlay) => send({ name: "openOverlay", overlay }),
});
if (!hud.bossTestingDungeon) clearBossTestingDungeons();
useCombatStore.setState({ ...hud.combat,
castActionBinding: (layer, control) => { send({ name: "castActionBinding", layer, control }); return { ok: false, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 }; },
selectFriendlyActor: (id) => { send({ name: "selectFriendlyActor", id }); return true; },
clearTarget: () => send({ name: "clearTarget" }),
updateSettings: (patch) => { if (patch.threatMeterPosition) send({ name: "moveThreatMeter", position: patch.threatMeterPosition }); },
});
usePartyStore.setState({ ...hud.party,
selectMember: (memberId) => { if (memberId) send({ name: "selectPartyMember", memberId }); else send({ name: "clearPartySelection" }); return true; },
selectSelf: () => send({ name: "selectPlayer" }),
clearSelection: () => send({ name: "clearPartySelection" }),
setCommand: (command) => send({ name: "setPartyCommand", command }),
recallParty: () => send({ name: "recallParty" }),
});
useManastormStore.setState(hud.manastorm);
useDungeonRunStore.setState({ progress: hud.run });
useOnlineSessionStore.setState({ snapshot: hud.onlineSession });
useOnlineGroupStore.setState(hud.onlineGroup);
useDpsMeterStore.setState(hud.dps);
}, [hud, send, snapshot.session]);
return <DungeonCompanionHud send={send} remote completion={hud.completion} interaction={hud.interaction} />;
}
@@ -7,6 +7,10 @@ import { currentCompanionSnapshot, executeCompanionCommand, type DualScreenMessa
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import { subscribeDungeonEncounterSnapshots } from "../game/dungeonEncounterRuntime";
export function connectAuthoritativeDualScreen(
channel: BroadcastChannel,
@@ -38,6 +42,11 @@ export function connectAuthoritativeDualScreen(
const unsubscribeDps = useDpsMeterStore.subscribe(() => {
if (companionReady) gamePublisher.request();
});
const request = () => { if (companionReady) gamePublisher.request(); };
const unsubscribeOnline = useOnlineSessionStore.subscribe(request);
const unsubscribeGroup = useOnlineGroupStore.subscribe(request);
const unsubscribeRun = useDungeonRunStore.subscribe(request);
const unsubscribeEncounters = subscribeDungeonEncounterSnapshots(request);
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "companion-ready") {
companionReady = true;
@@ -62,6 +71,10 @@ export function connectAuthoritativeDualScreen(
unsubscribeParty();
unsubscribeUi();
unsubscribeDps();
unsubscribeOnline();
unsubscribeGroup();
unsubscribeRun();
unsubscribeEncounters();
gamePublisher.dispose();
};
}
+33 -1
View File
@@ -32,6 +32,10 @@ import { activeUiLayout, useUiLayoutStore } from "../game/uiLayoutStore";
import { normalizeHudPosition } from "../game/hudLayout";
import { resolveDpsMeterDisplay, type HudElementLayout } from "../game/uiLayout";
import { currentDisplayLayout } from "./displayLayout";
import { currentHudSnapshot, type HudSnapshot } from "./hudSnapshot";
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
import { interactWithActiveDungeonObject } from "../game/dungeonWorldInteraction";
import { resurrectDeadPartyMembers } from "../game/partyResurrection";
export { createDualScreenChannel } from "./dualScreenChannel";
@@ -95,6 +99,7 @@ export interface CompanionPartyObjectiveSnapshot {
}
export interface CompanionGameSnapshot {
hud: HudSnapshot;
dpsMeter: {
values: DpsMeterSnapshot;
layout: HudElementLayout;
@@ -172,7 +177,15 @@ export type CompanionCommand =
| { name: "setPartyCommand"; command: PartyCommand }
| { name: "recallParty" }
| { name: "resetAtEntrance" }
| { name: "editDpsMeter"; update: Partial<HudElementLayout> };
| { name: "editDpsMeter"; update: Partial<HudElementLayout> }
| { name: "editHudElement"; id: HudElementId; update: Partial<HudElementLayout> }
| { name: "selectFriendlyActor"; id: string | null }
| { name: "selectPlayer" }
| { name: "clearPartySelection" }
| { name: "resurrectParty"; checkpointId: string }
| { name: "interactDungeonObject" }
| { name: "finishDungeon" }
| { name: "moveThreatMeter"; position: { x: number; y: number } };
export type DualScreenMessage =
| { type: "companion-ready" }
@@ -265,6 +278,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
draft: shell.draft,
notice: shell.notice,
game: {
hud: currentHudSnapshot(),
dpsMeter: {
values: currentDpsMeterSnapshot(),
layout: uiLayout.elements.dps,
@@ -342,6 +356,24 @@ export function executeCompanionCommand(command: CompanionCommand): void {
const game = useGameStore.getState();
const combat = useCombatStore.getState();
switch (command.name) {
case "editHudElement": {
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft || !(command.id in HUD_ELEMENTS)) break;
const update: Partial<HudElementLayout> = {};
if (command.update.position) update.position = normalizeHudPosition(command.update.position, { x: .5, y: .5 });
if (typeof command.update.width === "number" && Number.isFinite(command.update.width)) update.width = Math.max(20, Math.min(2000, command.update.width));
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
[command.id]: { ...layout.elements[command.id], ...update },
} }));
useUiLayoutStore.setState({ selectedElement: command.id });
break;
}
case "selectFriendlyActor": combat.selectFriendlyActor(command.id); break;
case "selectPlayer": combat.clearTarget(); usePartyStore.getState().selectSelf(); break;
case "clearPartySelection": usePartyStore.getState().clearSelection(); break;
case "resurrectParty": resurrectDeadPartyMembers(command.checkpointId); break;
case "interactDungeonObject": if (!game.overlay) interactWithActiveDungeonObject(); break;
case "finishDungeon": if (currentHudSnapshot().completion) { shell.saveActiveDungeonRunCheckpoint(null); shell.returnToMainMenu(); } break;
case "moveThreatMeter": combat.updateSettings({ threatMeterPosition: normalizeHudPosition(command.position, { x: .8, y: .3 }) }); break;
case "editDpsMeter": {
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft) break;
const update: Partial<HudElementLayout> = {};
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { currentHudSnapshot } from "./hudSnapshot";
import { executeCompanionCommand } from "./dualScreenSync";
import { useGameStore } from "../game/store";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { normalizeUiPreferences } from "../game/uiLayout";
import { BOSS_TESTING_CATALOG, createBossTestingRoom } from "../game/bossTesting";
import { clearBossTestingDungeons, registerBossTestingDungeon, requireDungeonDefinition } from "../game/dungeonRegistry";
describe("remote HUD presentation", () => {
it("carries the isolated boss room to the lower display without simulation methods", () => {
const original = useGameStore.getState();
const boss = BOSS_TESTING_CATALOG.find((entry) => entry.dungeonId === "wailing-caverns")!;
const room = createBossTestingRoom(requireDungeonDefinition(boss.dungeonId), boss);
try {
registerBossTestingDungeon(room.definition, boss.dungeonId);
useGameStore.getState().activateDungeon(room.definition.id, undefined, { bossTest: room.session, gmTestMode: true });
const snapshot = structuredClone(currentHudSnapshot());
expect(snapshot.bossTestingDungeon?.definition).toEqual(room.definition);
expect(snapshot.game.bossTest).toEqual(room.session);
clearBossTestingDungeons();
registerBossTestingDungeon(snapshot.bossTestingDungeon!.definition, snapshot.bossTestingDungeon!.sourceDungeonId);
expect(requireDungeonDefinition(snapshot.game.activeDungeonId).staticSpawns).toHaveLength(1);
} finally {
useGameStore.setState(original);
clearBossTestingDungeons();
}
});
it("contains cloneable presentation data without simulation methods", () => {
const snapshot = currentHudSnapshot();
expect(() => structuredClone(snapshot)).not.toThrow();
expect(snapshot.combat).not.toHaveProperty("tick");
expect(snapshot.game).not.toHaveProperty("activateDungeon");
expect(snapshot.party).not.toHaveProperty("recallParty");
});
it("accepts bounded moves only while the authoritative editor is open", () => {
const originalUi = useUiLayoutStore.getState();
const originalGame = useGameStore.getState();
try {
const draft = normalizeUiPreferences(null).current.mobile;
useUiLayoutStore.setState({ draft });
useGameStore.setState({ overlay: null });
executeCompanionCommand({ name: "editHudElement", id: "party", update: { position: { x: .8, y: .7 } } });
expect(useUiLayoutStore.getState().draft).toBe(draft);
useGameStore.setState({ overlay: "ui-layout" });
executeCompanionCommand({ name: "editHudElement", id: "party", update: { position: { x: 5, y: -1 }, width: 99999, screen: "hidden" } });
expect(useUiLayoutStore.getState().draft?.elements.party).toMatchObject({ position: { x: 1, y: 0 }, width: 2000, visible: true });
expect(useUiLayoutStore.getState().draft?.elements.party.screen).toBeUndefined();
} finally {
useUiLayoutStore.setState(originalUi);
useGameStore.setState(originalGame);
}
});
});
+40
View File
@@ -0,0 +1,40 @@
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store";
import { usePartyStore } from "../game/partyStore";
import { useManastormStore } from "../game/manastormStore";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useShellStore } from "../app/shellStore";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore";
import { activeDungeonRunIsComplete } from "../game/dungeonEncounterRuntime";
import { activeDungeonInteractionCandidate } from "../game/dungeonWorldInteraction";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
function pick<T, K extends keyof T>(state: T, keys: readonly K[]): Pick<T, K> {
return Object.fromEntries(keys.map((key) => [key, state[key]])) as Pick<T, K>;
}
/** Presentation data only. The lower WebView never starts another simulation. */
export function currentHudSnapshot() {
const game = useGameStore.getState();
const run = useDungeonRunStore.getState().progress;
const candidate = game.overlay ? null : activeDungeonInteractionCandidate();
return {
game: pick(game, ["activeDungeonId", "gameMode", "gmTestMode", "bossTest", "areaName", "distanceTravelled", "playerPosition", "cameraYaw", "hasMoved", "cameraLookActive", "inputMode", "actionLayer", "assetStatus", "assetMessage", "overlay", "paused", "companionOpen"]),
bossTestingDungeon: game.bossTest ? { definition: requireDungeonDefinition(game.activeDungeonId), sourceDungeonId: game.bossTest.sourceDungeonId } : null,
combat: pick(useCombatStore.getState(), ["classId", "secondaryClassId", "specializationId", "level", "xp", "xpToNext", "health", "maxHealth", "shield", "shieldExpiresAt", "resource", "maxResource", "resourcePools", "maxResourcePools", "resourceName", "abilities", "talentRanks", "talentModifiers", "auras", "effects", "summons", "actors", "actionBindings", "cooldowns", "globalCooldownEndsAt", "activeCast", "pendingGroundTarget", "feedback", "selectedTargetId", "selectedFriendlyActorId", "mobs", "settings"]),
party: pick(usePartyStore.getState(), ["members", "selectedMemberId", "selfSelected", "playerRole", "command", "routeBlocked"]),
manastorm: pick(useManastormStore.getState(), ["currentEncounter", "resurrectionCharges"]),
shell: pick(useShellStore.getState(), ["activeCharacter", "activeDungeonRole"]),
onlineSession: useOnlineSessionStore.getState().snapshot,
onlineGroup: pick(useOnlineGroupStore.getState(), ["snapshot", "activeActivity"]),
ui: pick(useUiLayoutStore.getState(), ["preferences", "draft", "device", "selectedElement"]),
run,
dps: useDpsMeterStore.getState(),
completion: game.gameMode === "dungeon" && (activeDungeonRunIsComplete(game.activeDungeonId) || (run?.dungeonId === game.activeDungeonId && run.finishedAt !== null)),
interaction: candidate ? { prompt: candidate.object.interaction!.prompt, name: candidate.object.name, label: candidate.state.label } : null,
};
}
export type HudSnapshot = ReturnType<typeof currentHudSnapshot>;
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { THOR_DISPLAYS, THOR_UI_PIXELS_PER_INCH } from "./thorDisplayProfile";
describe("Thor physical display sizing", () => {
it("uses millimetres for relative panel size, with equal physical touch targets", () => {
const { top, bottom } = THOR_DISPLAYS;
expect(top.widthMm).toBeCloseTo(132.83, 1);
expect(bottom.widthMm).toBeCloseTo(75.12, 1);
expect(bottom.cssWidth / top.cssWidth).toBeCloseTo(bottom.widthMm / top.widthMm, 2);
expect(bottom.deviceScaleFactor).toBeGreaterThan(top.deviceScaleFactor);
for (const panel of [top, bottom]) {
expect(44 * panel.deviceScaleFactor / panel.pixelsPerInch * 25.4).toBeCloseTo(44 / THOR_UI_PIXELS_PER_INCH * 25.4);
}
});
});
+17
View File
@@ -0,0 +1,17 @@
/** AYN Thor panel dimensions, at a common physical UI density (160 CSS px/in). */
export const THOR_UI_PIXELS_PER_INCH = 160;
export function physicalDisplayProfile(width: number, height: number, diagonalInches: number) {
const pixelsPerInch = Math.hypot(width, height) / diagonalInches;
return {
width, height, diagonalInches, pixelsPerInch,
widthMm: width / pixelsPerInch * 25.4,
heightMm: height / pixelsPerInch * 25.4,
cssWidth: Math.round(width / pixelsPerInch * THOR_UI_PIXELS_PER_INCH),
cssHeight: Math.round(height / pixelsPerInch * THOR_UI_PIXELS_PER_INCH),
deviceScaleFactor: pixelsPerInch / THOR_UI_PIXELS_PER_INCH,
};
}
export const THOR_DISPLAYS = {
top: physicalDisplayProfile(1920, 1080, 6),
bottom: physicalDisplayProfile(1240, 1080, 3.92),
};
+30
View File
@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { RigidBody } from "@react-three/rapier";
import type { DungeonDefinition } from "../game/dungeonTypes";
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_WALL_COLLISION_GROUPS } from "./dungeonLineOfSight";
export function BossTestingRoom({ definition, onReady }: { readonly definition: DungeonDefinition; readonly onReady?: () => void }) {
const { min, max } = definition.bounds;
const x = (min[0] + max[0]) / 2;
const z = (min[2] + max[2]) / 2;
const y = definition.bosses[0].position[1];
const width = max[0] - min[0];
const depth = max[2] - min[2];
useEffect(() => {
onReady?.();
}, [onReady]);
return <group name="boss-testing-room">
<RigidBody type="fixed" colliders="cuboid" collisionGroups={DUNGEON_PHYSICAL_COLLISION_GROUPS} position={[x, y - 0.5, z]}>
<mesh receiveShadow><boxGeometry args={[width, 1, depth]} /><meshStandardMaterial color="#535c68" roughness={0.9} /></mesh>
</RigidBody>
{([
[min[0] - 0.5, y + 4, z, 1, 8, depth + 2], [max[0] + 0.5, y + 4, z, 1, 8, depth + 2],
[x, y + 4, min[2] - 0.5, width, 8, 1], [x, y + 4, max[2] + 0.5, width, 8, 1],
] as const).map(([px, py, pz, w, h, d], index) =>
<RigidBody key={index} type="fixed" colliders="cuboid" collisionGroups={DUNGEON_WALL_COLLISION_GROUPS} position={[px, py, pz]}>
<mesh receiveShadow><boxGeometry args={[w, h, d]} /><meshStandardMaterial color="#293441" roughness={0.95} /></mesh>
</RigidBody>
)}
<gridHelper args={[Math.max(width, depth), Math.ceil(Math.max(width, depth) / 5), "#8794a5", "#626d7b"]} position={[x, y + 0.015, z]} />
</group>;
}
+5 -1
View File
@@ -5,10 +5,12 @@ import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { useGameStore } from "../game/store";
import { ProductionDungeon } from "./ProductionDungeon";
import { PrototypeDungeon } from "./PrototypeDungeon";
import { BossTestingRoom } from "./BossTestingRoom";
export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void }) {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const gameMode = useGameStore((state) => state.gameMode);
const bossTest = useGameStore((state) => state.bossTest);
const sessionRevision = useGameStore((state) => state.sessionRevision);
const definition = requireDungeonDefinition(activeDungeonId);
const manastormPackage = gameMode === "manastorm"
@@ -31,6 +33,7 @@ export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void
&& assets.collision.length > 0;
useEffect(() => {
if (bossTest) return;
if (hasProductionAssets) return;
const missing = [
...definition.assets.visual.filter((_, index) => !assets.visual[index]).map((asset) => asset.fileName),
@@ -38,8 +41,9 @@ export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void
];
useGameStore.getState().setAssetStatus("proxy", `Waiting for ${missing.join(" and ") || "generated dungeon assets"}.`);
onReady?.();
}, [assets.collision, assets.visual, definition, hasProductionAssets, onReady]);
}, [assets.collision, assets.visual, bossTest, definition, hasProductionAssets, onReady]);
if (bossTest) return <BossTestingRoom definition={definition} onReady={onReady} />;
if (!hasProductionAssets) return <PrototypeDungeon definition={definition} />;
return (
<Suspense fallback={null}>
+26 -2
View File
@@ -28,6 +28,30 @@ html, body, #root {
body { color: var(--ink); }
button { font: inherit; }
.ability-tooltip {
position: fixed;
z-index: 10000;
width: min(340px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
overflow: hidden;
padding: 12px;
border: 1px solid var(--gold);
border-radius: 8px 3px;
background: var(--panel-solid);
box-shadow: var(--shadow);
color: var(--ink);
font-size: 12px;
line-height: 1.5;
text-align: left;
pointer-events: none;
}
.ability-tooltip header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.ability-tooltip header strong { color: var(--gold); font: 600 16px Georgia, serif; }
.ability-tooltip kbd { color: var(--muted); font-size: 10px; }
.ability-tooltip p { margin: 8px 0; white-space: pre-line; overflow-wrap: anywhere; }
.ability-tooltip footer { display: flex; flex-wrap: wrap; gap: 4px 12px; padding-top: 8px; border-top: 1px solid var(--edge); color: var(--moss-bright); font-size: 11px; }
.ability-tooltip .ability-tooltip__lock { margin-bottom: 0; color: var(--gold); }
.game-shell, .scene-shell {
position: relative;
width: 100%;
@@ -2233,7 +2257,7 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
.manastorm-spell-option b { overflow: hidden; font: 9px Georgia, serif; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }
.manastorm-spell-option small { color: var(--front-dim); font-size: 6px; }
.manastorm-spell-option.is-active { border-color: rgba(142,231,239,.72); background: rgba(81,126,143,.28); color: #efffff; box-shadow: inset 0 0 12px rgba(100,210,225,.11); }
.manastorm-spell-option:disabled { opacity: .42; }
.manastorm-spell-option:is(:disabled, [aria-disabled="true"]) { opacity: .42; }
.manastorm-role-label { display: block; margin-top: 11px; }
.manastorm-role-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin-top: 5px; }
.manastorm-role-grid .dungeon-role-option { min-height: 47px; padding: 6px; }
@@ -3772,7 +3796,7 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
display: grid; justify-items: center; gap: .1rem; padding: .2rem;
border: 1px solid #9dad8277; background: #08130feb; color: #fff;
}
.arena-personal-bars button:disabled { opacity: .55; }
.arena-personal-bars button:is(:disabled, [aria-disabled="true"]) { opacity: .55; }
.arena-personal-bars button kbd { position: absolute; top: .1rem; right: .2rem; z-index: 1; color: #fff5aa; font-size: .7rem; text-shadow: 1px 1px #000; }
.arena-personal-bars button .action-slot__icon { width: 1.9rem; height: 1.9rem; }
.arena-personal-bars button > span:last-of-type { font-size: .58rem; line-height: 1.15; overflow: hidden; }
+29 -10
View File
@@ -10,7 +10,7 @@ import {
type ActionBindingLayer,
} from "../game/actionBindings";
import { useCombatStore } from "../game/combatStore";
import { defaultActionBarForCharacter } from "../game/abilityCatalog";
import { abilityAtLevel, defaultActionBarForCharacter, type AbilityDefinition } from "../game/abilityCatalog";
import { useGameStore } from "../game/store";
import { actionKeyLabel, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
@@ -20,6 +20,7 @@ import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import { AbilityTooltipTarget, type AbilityTooltipData } from "./AbilityTooltip";
function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: string; color: string }) {
const [failed, setFailed] = useState(false);
@@ -97,7 +98,7 @@ const MAIN_ACTION_NEIGHBORS: Readonly<Record<string, NonNullable<MenuAction["nei
},
};
function AbilityBindingsDialog() {
function AbilityBindingsDialog({ onClose }: { onClose?: () => void }) {
const panel = useRef<HTMLElement>(null);
const keyLabel = useInputBindingLabels();
const bindingChord = (layer: ActionBindingLayer, control: ActionBindingControl) => `${keyLabel("controller", `ability:${layer}:${control}`)} / ${keyLabel("pc", `ability:${layer}:${control}`)}`;
@@ -106,6 +107,19 @@ function AbilityBindingsDialog() {
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
const level = useCombatStore((state) => state.level);
const abilities = useCombatStore((state) => state.abilities);
const talentRanks = useCombatStore((state) => state.talentRanks);
const tooltipData = (definition: AbilityDefinition | null | undefined): AbilityTooltipData | null => {
if (!definition) return null;
const talentRank = definition.talentEntryId === undefined ? undefined : talentRanks[`coa-entry-${definition.talentEntryId}`] ?? 0;
const ability = abilityAtLevel(definition, level, talentRank);
return {
name: ability.name, description: ability.description, target: ability.target,
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000,
range: ability.range.max, lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
talentLocked: ability.talentEntryId !== undefined && !talentRank,
};
};
const bindings = useCombatStore((state) => state.actionBindings);
const pcLayout = useUiLayoutStore((state) => state.preferences.current.pc);
const [layout, setLayout] = useState<BindingLayout>(() => currentUiDevice() === "pc" && pcLayout.wowBars ? "wow" : "controller");
@@ -133,7 +147,7 @@ function AbilityBindingsDialog() {
]
: ordered;
}, [abilities, editingAbilityId]);
const close = () => openOverlay("pause");
const close = onClose ?? (() => openOverlay("pause"));
const saveWowLayout = (update: (current: UiLayout) => UiLayout) => {
try {
const ui = useUiLayoutStore.getState();
@@ -335,6 +349,7 @@ function AbilityBindingsDialog() {
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
const locked = Boolean(ability && ability.unlockLevel > level);
return (
<AbilityTooltipTarget key={control} ability={tooltipData(ability)} bindingLabel={bindingChord(layer, control)}>
<ControllerButton
key={control}
controlId={slotControlId(control)}
@@ -352,6 +367,7 @@ function AbilityBindingsDialog() {
: <span className="ability-bindings__empty" aria-hidden="true">+</span>}
<span><strong>{definition.label}</strong><small>{locked ? `${ability!.name} · Level ${ability!.unlockLevel}` : ability?.name ?? "Empty"}</small></span>
</ControllerButton>
</AbilityTooltipTarget>
);
};
@@ -363,7 +379,7 @@ function AbilityBindingsDialog() {
<p className="eyebrow">{isWow ? "WoW loadout · 5 bars · 60 customizable slots" : "Controller loadout · 24 customizable slots"}</p>
<h2 id="ability-bindings-title">Ability controls</h2>
</div>
<button type="button" className="icon-button" onClick={close} aria-label="Return to pause menu">×</button>
<button type="button" className="icon-button" onClick={close} aria-label="Close button assignments">×</button>
</header>
<nav className="ability-bindings__layouts" aria-label="Action bar style">
@@ -407,14 +423,15 @@ function AbilityBindingsDialog() {
const ability = abilities.find((candidate) => candidate.id === slot.abilityId);
const locked = Boolean(ability && ability.unlockLevel > level);
const name = ability?.name ?? (slot.abilityId ? "Unavailable for this character" : "Empty");
return <ControllerButton key={index} controlId={slotControlId(index)} selectedId={controller.selectedId} select={controller.select}
return <AbilityTooltipTarget key={index} ability={tooltipData(ability)} bindingLabel={controlLabel(index)}>
<ControllerButton controlId={slotControlId(index)} selectedId={controller.selectedId} select={controller.select}
type="button" className={`ability-binding-slot ${locked ? "is-locked" : ""} ${editingControl === index ? "is-editing" : ""}`}
aria-label={`${controlLabel(index)}: ${name}${locked ? `, unlocks at level ${ability!.unlockLevel}` : ""}`}
aria-pressed={editingControl === index} title={`${controlLabel(index)}: ${name}`} onClick={() => beginEditing(index)}>
<kbd>{actionKeyLabel(slot.key) || "—"}</kbd>
{ability ? <AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} /> : <span className="ability-bindings__empty" aria-hidden="true">+</span>}
<span><strong>Button {index + 1}</strong><small>{locked ? `${name} · Level ${ability!.unlockLevel}` : name}</small></span>
</ControllerButton>;
</ControllerButton></AbilityTooltipTarget>;
})}
</div>
<p className="ability-bindings__wow-note">{!pcLayout.wowBars ? "Enable WoW action bars in UI layout to use these assignments on PC." : bar >= pcLayout.barCount ? "This bar is inactive. Increase the visible bar count in UI layout to use it." : "Choose a button to change its ability. Changes save immediately."}</p>
@@ -475,10 +492,12 @@ function AbilityBindingsDialog() {
<h3>Choose an ability</h3>
<div className="ability-bindings__choices">
{pickerAbilities.map((ability) => (
<ControllerButton key={ability.id} 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)}>
<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)}>
<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>
</AbilityTooltipTarget>
))}
<ControllerButton controlId="binding-choice-empty" selectedId={controller.selectedId} select={controller.select} type="button" className={editingAbilityId === null ? "is-current" : ""} onClick={() => assign(null)}>
<span className="ability-bindings__empty" aria-hidden="true">×</span>
@@ -507,7 +526,7 @@ function AbilityBindingsDialog() {
);
}
export function AbilityBindingsPanel() {
const open = useGameStore((state) => state.overlay === "bindings");
return open ? <AbilityBindingsDialog /> : null;
export function AbilityBindingsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
const storeOpen = useGameStore((state) => state.overlay === "bindings");
return (open ?? storeOpen) ? <AbilityBindingsDialog onClose={onClose} /> : null;
}
+74
View File
@@ -0,0 +1,74 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import {
AbilityTooltipContent, AbilityTooltipTarget, abilityTooltipFacts, abilityTooltipPosition,
type AbilityTooltipData,
} from "./AbilityTooltip";
const ability: AbilityTooltipData = {
name: "Healing Wave", description: "Heals a friendly target for 120 health.",
resourceCost: 15, resourceLabel: "mana", cooldownSeconds: 6,
castSeconds: 1.5, range: 40, target: "friendly",
};
describe("ability tooltips", () => {
it("shows the ability's description, binding, and gameplay facts", () => {
const markup = renderToStaticMarkup(createElement(AbilityTooltipContent, { ability, bindingLabel: "Shift+1" }));
for (const text of [ability.name, ability.description!, "Shift+1", "15 mana", "1.5 sec cast", "6 sec cooldown", "40 m range", "Selected ally or self"]) {
expect(markup).toContain(text);
}
});
it("explains free, instant abilities and distinguishes no cooldown from missing data", () => {
expect(abilityTooltipFacts({ name: "Free", resourceCost: 0, castSeconds: 0, cooldownSeconds: 0 }))
.toEqual(["No cost", "Instant", "No cooldown"]);
expect(abilityTooltipFacts({ name: "Unknown" })).toEqual([]);
});
it("shows unlock and talent requirements without hiding the description", () => {
const markup = renderToStaticMarkup(createElement(AbilityTooltipContent, {
ability: { ...ability, lockedUntilLevel: 20, talentLocked: true },
}));
expect(markup).toContain(ability.description);
expect(markup).toContain("Unlocks at level 20");
expect(markup).toContain("Learn in talents");
});
it("does not present active cast facts for passive abilities", () => {
expect(abilityTooltipFacts({ ...ability, passive: true, rank: 2 })).toEqual(["Passive", "Rank 2"]);
});
it("preserves inspectable unavailable controls and creates no layout wrapper", () => {
const markup = renderToStaticMarkup(createElement(AbilityTooltipTarget, {
ability,
children: createElement("button", { "aria-disabled": true, title: "Old browser tooltip" }, "Healing Wave"),
}));
expect(markup).toBe('<button aria-disabled="true">Healing Wave</button>');
});
it("keeps the empty-slot browser hint without creating an ability tooltip", () => {
const markup = renderToStaticMarkup(createElement(AbilityTooltipTarget, {
ability: null,
children: createElement("button", { title: "Empty binding" }, "Empty"),
}));
expect(markup).toBe('<button title="Empty binding">Empty</button>');
});
it("places a bottom-bar tooltip above its control", () => {
expect(abilityTooltipPosition({ left: 400, right: 440, top: 700, bottom: 740 }, { width: 300, height: 180 }, { width: 1000, height: 800 }))
.toEqual({ left: 270, top: 512 });
});
it("flips below top-edge controls and clamps to either horizontal edge", () => {
expect(abilityTooltipPosition({ left: 0, right: 40, top: 20, bottom: 60 }, { width: 300, height: 180 }, { width: 1000, height: 800 }))
.toEqual({ left: 12, top: 68 });
expect(abilityTooltipPosition({ left: 960, right: 1000, top: 20, bottom: 60 }, { width: 300, height: 180 }, { width: 1000, height: 800 }))
.toEqual({ left: 688, top: 68 });
});
it("keeps a tall tooltip inside a narrow viewport", () => {
expect(abilityTooltipPosition({ left: 200, right: 240, top: 120, bottom: 160 }, { width: 296, height: 400 }, { width: 320, height: 480 }))
.toEqual({ left: 12, top: 68 });
});
});
+146
View File
@@ -0,0 +1,146 @@
import {
cloneElement, useEffect, useId, useLayoutEffect, useRef, useState,
type ButtonHTMLAttributes, type ReactElement,
} from "react";
import { createPortal } from "react-dom";
import type { AbilityTarget } from "../game/abilityCatalog";
const INSPECT_EVENT = "healer-man:ability-tooltip-inspect";
export interface AbilityTooltipData {
name: string;
description?: string;
resourceCost?: number;
resourceLabel?: string;
cooldownSeconds?: number;
castSeconds?: number;
range?: number;
target?: AbilityTarget;
passive?: boolean;
rank?: number;
lockedUntilLevel?: number;
talentLocked?: boolean;
}
export function abilityTooltipFacts(ability: AbilityTooltipData): string[] {
const facts: string[] = [];
if (ability.passive) facts.push("Passive");
if (ability.rank !== undefined) facts.push(`Rank ${ability.rank}`);
if (!ability.passive) {
if (ability.resourceCost !== undefined) facts.push(ability.resourceCost > 0
? `${Number(ability.resourceCost.toFixed(2))} ${ability.resourceLabel ?? "resource"}` : "No cost");
if (ability.castSeconds !== undefined) facts.push(ability.castSeconds > 0 ? `${ability.castSeconds} sec cast` : "Instant");
if (ability.cooldownSeconds !== undefined) facts.push(ability.cooldownSeconds > 0 ? `${ability.cooldownSeconds} sec cooldown` : "No cooldown");
if (ability.range !== undefined && ability.range > 0) facts.push(`${ability.range} m range`);
if (ability.target) facts.push({ friendly: "Selected ally or self", hostile: "Enemy target", any: "Enemy or ally", self: "Self" }[ability.target]);
}
return facts;
}
export function abilityTooltipPosition(
anchor: { left: number; right: number; top: number; bottom: number },
size: { width: number; height: number },
viewport: { width: number; height: number },
): { left: number; top: number } {
const margin = 12;
const gap = 8;
const clamp = (value: number, maximum: number) => Math.max(margin, Math.min(value, maximum - margin));
const above = anchor.top - size.height - gap;
return {
left: clamp((anchor.left + anchor.right - size.width) / 2, viewport.width - size.width),
top: clamp(above >= margin ? above : anchor.bottom + gap, viewport.height - size.height),
};
}
export function AbilityTooltipContent({ ability, bindingLabel }: { ability: AbilityTooltipData; bindingLabel?: string }) {
return <>
<header><strong>{ability.name}</strong>{bindingLabel && <kbd>{bindingLabel}</kbd>}</header>
<p>{ability.description || "No description available for this ability."}</p>
<footer>{abilityTooltipFacts(ability).map((fact) => <span key={fact}>{fact}</span>)}</footer>
{ability.lockedUntilLevel !== undefined && <p className="ability-tooltip__lock">Unlocks at level {ability.lockedUntilLevel}</p>}
{ability.talentLocked && <p className="ability-tooltip__lock">Learn in talents</p>}
</>;
}
/** Adds inspection without a layout wrapper or changing the control's activation behavior. */
export function AbilityTooltipTarget({ ability, bindingLabel, children }: {
ability: AbilityTooltipData | null | undefined;
bindingLabel?: string;
children: ReactElement<ButtonHTMLAttributes<HTMLButtonElement>>;
}) {
const id = useId();
const [anchor, setAnchor] = useState<HTMLButtonElement | null>(null);
const [position, setPosition] = useState({ left: 12, top: 12 });
const tooltip = useRef<HTMLDivElement>(null);
const hovering = useRef(false);
const keyboardFocused = useRef(false);
const visible = Boolean(anchor && ability);
const inspect = (element: HTMLButtonElement) => {
window.dispatchEvent(new CustomEvent(INSPECT_EVENT, { detail: id }));
setAnchor(element);
};
useLayoutEffect(() => {
if (!anchor || !tooltip.current || !ability) return;
setPosition(abilityTooltipPosition(anchor.getBoundingClientRect(), tooltip.current.getBoundingClientRect(), {
width: window.innerWidth, height: window.innerHeight,
}));
}, [anchor, ability, bindingLabel]);
useEffect(() => {
if (!visible) return;
const dismiss = () => {
hovering.current = false;
keyboardFocused.current = false;
setAnchor(null);
};
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") dismiss(); };
const onInspect = (event: Event) => { if ((event as CustomEvent<string>).detail !== id) dismiss(); };
window.addEventListener(INSPECT_EVENT, onInspect);
window.addEventListener("keydown", onKey);
window.addEventListener("blur", dismiss);
window.addEventListener("resize", dismiss);
window.addEventListener("scroll", dismiss, true);
return () => {
window.removeEventListener(INSPECT_EVENT, onInspect);
window.removeEventListener("keydown", onKey);
window.removeEventListener("blur", dismiss);
window.removeEventListener("resize", dismiss);
window.removeEventListener("scroll", dismiss, true);
};
}, [visible, id]);
const original = children.props;
return <>
{cloneElement(children, {
title: ability ? undefined : original.title,
"aria-describedby": [original["aria-describedby"], visible ? id : undefined].filter(Boolean).join(" ") || undefined,
onPointerEnter: (event) => {
original.onPointerEnter?.(event);
if (event.pointerType === "touch" || !ability) return;
hovering.current = true;
inspect(event.currentTarget);
},
onPointerLeave: (event) => {
original.onPointerLeave?.(event);
hovering.current = false;
if (!keyboardFocused.current) setAnchor(null);
},
onFocus: (event) => {
original.onFocus?.(event);
keyboardFocused.current = event.currentTarget.matches(":focus-visible");
if (keyboardFocused.current && ability) inspect(event.currentTarget);
},
onBlur: (event) => {
original.onBlur?.(event);
keyboardFocused.current = false;
if (!hovering.current) setAnchor(null);
},
})}
{visible && createPortal(
<div ref={tooltip} id={id} role="tooltip" className="ability-tooltip" style={position}>
<AbilityTooltipContent ability={ability!} bindingLabel={bindingLabel} />
</div>, document.body,
)}
</>;
}
+7
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, type CSSProperties } from "react";
import type { AbilityTarget } from "../game/abilityCatalog";
import { AbilityTooltipTarget } from "./AbilityTooltip";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import {
ACTION_LAYER_LABELS,
@@ -21,6 +22,10 @@ export interface ActionBarItem {
disabled?: boolean;
lockedUntilLevel?: number;
target?: AbilityTarget;
cooldownSeconds?: number;
castSeconds?: number;
range?: number;
talentLocked?: boolean;
}
export type ActionBarItems = Readonly<Record<ActionBindingControl, ActionBarItem | null>>;
@@ -94,6 +99,7 @@ function ActionSlot({
: "";
return (
<AbilityTooltipTarget ability={item} bindingLabel={chord}>
<button
type="button"
className={`action-slot action-slot--${control} ${item ? "" : "action-slot--empty"} ${unavailable ? "action-slot--disabled" : ""} ${item?.target === "friendly" || item?.target === "any" ? "action-slot--friendly" : ""} ${cooldown > 0 ? "action-slot--cooldown" : ""}`}
@@ -124,6 +130,7 @@ function ActionSlot({
<em>{item!.cooldownRemaining! < 10 ? item!.cooldownRemaining!.toFixed(1) : Math.ceil(item!.cooldownRemaining!)}</em>
)}
</button>
</AbilityTooltipTarget>
);
}
+6 -3
View File
@@ -8,6 +8,7 @@ import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { ActionBar, ActionIcon, type ActionBarItem, type ActionBarItems } from "./ActionBar";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useArenaStore } from "../game/arenaStore";
import { AbilityTooltipTarget } from "./AbilityTooltip";
const PC_ACTION_CONTROLS = ["face-bottom", "face-left", "face-top", "face-right", "r1", "r2", "dpad-left", "dpad-right"] as const;
@@ -38,6 +39,7 @@ export function ArenaActionBar({ character, actor, layer, inputMode, blocked, on
resourceCost: ability.cost.amount, resourceLabel: kit.resource.name,
cooldownDuration: Math.max(ability.cooldownMs, ability.gcdMs) / 1000,
cooldownRemaining: remaining,
cooldownSeconds: ability.cooldownMs / 1000, castSeconds: ability.castTimeMs / 1000, range: ability.range.max,
disabled: blocked || !actor?.alive || !learned || remaining > 0 || actor.effects.some((effect) => effect.id.startsWith("casting:")),
lockedUntilLevel: learned ? undefined : ability.unlockLevel,
}];
@@ -65,12 +67,13 @@ export function ArenaActionBar({ character, actor, layer, inputMode, blocked, on
{desktopBars.map((bar, index) => <section key={index} aria-label={`Action bar ${index + 1}`} style={{ gridTemplateColumns: `repeat(${bar.columns}, minmax(0, 1fr))` }}>
{bar.slots.map((slot, slotIndex) => {
const item = slot.abilityId ? items[slot.abilityId] : null;
return <button type="button" key={slotIndex} disabled={!item || item.disabled}
return <AbilityTooltipTarget key={slotIndex} ability={item} bindingLabel={slot.label}>
<button type="button" aria-disabled={!item || item.disabled}
aria-label={`${item?.name ?? "Empty"}, ${slot.label}`} title={item?.description ?? "Empty binding"}
onClick={() => item && onCast(item.id)}>
onClick={() => item && !item.disabled && onCast(item.id)}>
<kbd>{slot.label}</kbd><ActionIcon item={item ?? null} /><span>{item?.name ?? "Empty"}</span>
{item && (item.cooldownRemaining ?? 0) > 0 && <em>{item.cooldownRemaining!.toFixed(1)}</em>}
</button>;
</button></AbilityTooltipTarget>;
})}
</section>)}
</nav>
+2 -1
View File
@@ -8,12 +8,13 @@ describe("boss card display", () => {
it("shows per-rarity copy totals and every dungeon for the visible boss", () => {
const boss = BOSS_CARD_CATALOG[0];
const markup = renderToStaticMarkup(createElement(BossCardCollection, {
onClose: () => {}, characterName: "Collector",
onClose: () => {},
collection: { counts: { [boss.key]: { normal: 7, rare: 1 } }, lastClaims: {} },
}));
expect(markup).toContain("7 copies");
expect(markup).toContain("1 copy");
expect(markup).toContain("8 total copies");
expect(markup).toContain("Account-wide collection log");
expect(markup).toContain("Group by dungeon");
expect(markup).toContain("Group by rarity");
for (const dungeon of boss.dungeons) expect(markup).toContain(dungeon.title);
+6 -13
View File
@@ -1,9 +1,8 @@
import { lazy, Suspense, useMemo, useState, type CSSProperties } from "react";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore";
import { useShellStore } from "../app/shellStore";
import { useGameStore } from "../game/store";
import { BOSS_CARD_CATALOG, groupBossCards, type BossCardGrouping } from "../game/bossCardCatalog";
import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, emptyBossCardCollection, type BossCardCollection as Collection } from "../game/bossCards";
import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, type BossCardCollection as Collection } from "../game/bossCards";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import { dungeonContentIsUsable } from "../content/contentManager";
@@ -11,13 +10,11 @@ import { useContentManagerSnapshot } from "../content/useContentManager";
import "./bossCards.css";
const BossCardPortrait = lazy(() => import("./BossCardPortrait"));
const EMPTY_COLLECTION = emptyBossCardCollection();
const PAGE_SIZE = 8;
export function BossCardCollection({ onClose, collection, characterName }: {
export function BossCardCollection({ onClose, collection }: {
readonly onClose: () => void;
readonly collection: Collection;
readonly characterName: string;
}) {
useContentManagerSnapshot();
const [earnedOnly, setEarnedOnly] = useState(false);
@@ -52,7 +49,7 @@ export function BossCardCollection({ onClose, collection, characterName }: {
return <div className="modal-backdrop boss-collection-backdrop" role="presentation" onPointerDown={(event) => event.stopPropagation()}>
<section className="boss-collection" role="dialog" aria-modal="true" aria-labelledby="boss-collection-title">
<header className="boss-collection__header">
<div><p className="eyebrow">{characterName}’s collection log</p><h2 id="boss-collection-title">Boss Cards</h2></div>
<div><p className="eyebrow">Account-wide collection log</p><h2 id="boss-collection-title">Boss Cards</h2></div>
<ControllerButton {...controlProps} controlId="cards-close" className="icon-button" onClick={onClose} aria-label="Close boss card collection">×</ControllerButton>
</header>
<div className="boss-collection__summary">
@@ -100,11 +97,7 @@ export function BossCardCollection({ onClose, collection, characterName }: {
}
export default function BossCardCollectionScreen({ onClose }: { readonly onClose?: () => void }) {
const savedCharacter = useShellStore(selectedCharacter);
const activeCharacter = useShellStore((state) => state.activeCharacter);
const phase = useShellStore((state) => state.phase);
const liveCollection = useCombatStore((state) => state.bossCards);
const character = activeCharacter ?? savedCharacter;
const collection = useShellStore((state) => state.bossCards);
return <BossCardCollection onClose={onClose ?? (() => useGameStore.getState().closeOverlay())}
collection={phase === "game" ? liveCollection : character?.bossCards ?? EMPTY_COLLECTION} characterName={character?.name ?? "Adventurer"} />;
collection={collection} />;
}
+3 -2
View File
@@ -10,11 +10,12 @@ const GUIDE_CATALOG = buildBossGuideCatalog(DUNGEON_DEFINITIONS, BOSS_GUIDE_OVER
function BossGuideDialog() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const bossTest = useGameStore((state) => state.bossTest);
const openOverlay = useGameStore((state) => state.openOverlay);
const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === activeDungeonId) ?? GUIDE_CATALOG[0];
const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === (bossTest?.sourceDungeonId ?? activeDungeonId)) ?? GUIDE_CATALOG[0];
const [selectedDungeonId, setSelectedDungeonId] = useState(initialDungeon?.id ?? "");
const selectedDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon;
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses[0]?.id ?? "");
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses.find((boss) => boss.entityId === bossTest?.entityId)?.id ?? selectedDungeon?.bosses[0]?.id ?? "");
const selectedBoss = selectedDungeon?.bosses.find((boss) => boss.id === selectedBossId)
?? selectedDungeon?.bosses[0];
const close = () => openOverlay("pause");
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from "react";
import { useShellStore } from "../app/shellStore";
import { BOSS_TESTING_STATUSES, BOSS_TESTING_STATUS_LABELS, useBossTestingReviews, type BossTestingStatus } from "../app/bossTestingReviews";
import { bossTestIsDefeated } from "../game/BossTestingBridge";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import "./bossTesting.css";
export function BossTestingReviewPanel() {
const boss = useGameStore((state) => state.bossTest);
const session = useShellStore((state) => state.session);
const review = useBossTestingReviews((state) => boss ? state.reviews[boss.key] : undefined);
const save = useBossTestingReviews((state) => state.save);
const refresh = useBossTestingReviews((state) => state.refresh);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const busy = useRef(false);
const mark = async (status: BossTestingStatus) => {
if (!boss || busy.current) return;
busy.current = true; setSaving(true); setMessage("");
const synced = await save(boss, status, session);
setMessage(synced ? `Marked ${BOSS_TESTING_STATUS_LABELS[status].toLowerCase()}.` : "Saved on this device. This label will sync when you reconnect or reopen Boss Testing.");
busy.current = false; setSaving(false);
};
useEffect(() => {
void refresh(session);
const reconnect = () => void refresh(session);
window.addEventListener("online", reconnect);
return () => window.removeEventListener("online", reconnect);
}, [refresh, session]);
const defeated = bossTestIsDefeated();
const back = () => { if (!busy.current) useGameStore.getState().openOverlay("pause"); };
const retry = () => useGameStore.getState().resetAtEntrance();
const leave = () => useShellStore.getState().returnToMainMenu();
const actions: MenuAction[] = [
...BOSS_TESTING_STATUSES.map((status) => ({ id: `boss-review-${status}`, run: () => void mark(status), enabled: !saving })),
{ id: "boss-review-report", run: () => useGameStore.getState().openOverlay("report-issue"), enabled: !saving },
{ id: "boss-review-retry", run: retry, enabled: !saving },
{ id: "boss-review-leave", run: leave, enabled: !saving },
{ id: "boss-review-back", run: back, enabled: !saving },
];
const controller = useMenuController(actions, { initialId: "boss-review-needs-testing", onBack: back });
if (!boss) return null;
const button = (controlId: string, label: string, run: () => void, pressed?: boolean) =>
<ControllerButton controlId={controlId} selectedId={controller.selectedId} select={controller.select}
type="button" className="button" onClick={run} disabled={saving} aria-pressed={pressed}>{label}</ControllerButton>;
return <div className="modal-backdrop" role="presentation">
<section className="pause-menu boss-testing-review" role="dialog" aria-modal="true" aria-labelledby="boss-review-title">
<p className="eyebrow">{defeated ? "Boss defeated" : "Boss Testing paused"}</p><h2 id="boss-review-title">{boss.name}</h2>
<p>Current readiness: <strong>{BOSS_TESTING_STATUS_LABELS[review?.status ?? "needs-testing"]}</strong></p>
<div className="boss-testing-actions" aria-label="Boss readiness">{BOSS_TESTING_STATUSES.map((status) =>
<span key={status}>{button(`boss-review-${status}`, `Mark ${BOSS_TESTING_STATUS_LABELS[status].toLowerCase()}`, () => void mark(status), review?.status === status)}</span>
)}</div>
{message && <p role="status">{message}</p>}
<div className="pause-menu__actions">
{button("boss-review-report", "Submit a boss report", () => useGameStore.getState().openOverlay("report-issue"))}
{button("boss-review-retry", "Fight this boss again", retry)}
{button("boss-review-leave", "Return to main menu", leave)}
{button("boss-review-back", "Back to pause menu", back)}
</div>
</section>
</div>;
}
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { enterBossTesting, prepareBossTesting } from "../app/bossTestingRuntime";
import { BOSS_TESTING_STATUS_LABELS, useBossTestingReviews } from "../app/bossTestingReviews";
import { BOSS_TESTING_CATALOG, randomTestingBoss, type TestableBoss } from "../game/bossTesting";
import { defaultPartyRoleForClass, partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyRoles";
import { specializationById } from "../game/specializationCatalog";
import { useContentManagerSnapshot } from "../content/useContentManager";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { FrontSurface, BrandMark, ControllerLegend } from "./FrontSurface";
import { ControllerButton } from "./ControllerButton";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import "./bossTesting.css";
export function BossTestingScreen({ onClose }: { readonly onClose: () => void }) {
const character = useShellStore(selectedCharacter);
const session = useShellStore((state) => state.session);
const reviews = useBossTestingReviews((state) => state.reviews);
const refresh = useBossTestingReviews((state) => state.refresh);
const content = useContentManagerSnapshot();
const [query, setQuery] = useState("");
const [category, setCategory] = useState("all");
const [status, setStatus] = useState("all");
const [selectedKey, setSelectedKey] = useState("");
const [preparing, setPreparing] = useState(false);
const [prepared, setPrepared] = useState(false);
const [error, setError] = useState("");
const [role, setRole] = useState<PartyRole>(character ? defaultPartyRoleForClass(character.classId) : "damage");
const busy = useRef(false);
const alive = useRef(true);
const bosses = useMemo(() => BOSS_TESTING_CATALOG.filter((boss) =>
(category === "all" || boss.category === category)
&& (status === "all" || (reviews[boss.key]?.status ?? "needs-testing") === status)
&& `${boss.name} ${boss.dungeonTitle}`.toLowerCase().includes(query.trim().toLowerCase())
), [category, query, reviews, status]);
const selected = BOSS_TESTING_CATALOG.find((boss) => boss.key === selectedKey);
const specialization = specializationById(character?.specializationId);
const roles = character ? specialization ? [specialization.role] : partyRolesForClass(character.classId) : [];
const effectiveRole = specialization?.role ?? role;
useEffect(() => {
alive.current = true;
void refresh(session);
const reconnect = () => void refresh(session);
window.addEventListener("online", reconnect);
return () => { alive.current = false; window.removeEventListener("online", reconnect); };
}, [refresh, session]);
const choose = async (boss: TestableBoss, start = false) => {
if (busy.current || !character) return;
busy.current = true;
setSelectedKey(boss.key); setPreparing(true); setPrepared(false); setError("");
try {
await prepareBossTesting(boss);
if (!alive.current) return;
setPrepared(true);
if (start) enterBossTesting(boss, effectiveRole);
} catch (failure) {
if (alive.current) setError(failure instanceof Error ? failure.message : "The boss could not be prepared. Please retry.");
} finally {
busy.current = false;
if (alive.current) setPreparing(false);
}
};
const testSelected = () => {
if (!selected || preparing) return;
if (!prepared) { void choose(selected, true); return; }
try { enterBossTesting(selected, effectiveRole); } catch (failure) {
setError(failure instanceof Error ? failure.message : "The test could not be started.");
}
};
const random = () => {
const boss = randomTestingBoss(bosses);
if (boss) void choose(boss, true);
};
const actions: MenuAction[] = [
{ id: "boss-testing-back", run: onClose },
{ id: "boss-testing-category", run: () => setCategory(category === "all" ? "wow" : category === "wow" ? "rom" : "all"), enabled: !preparing },
{ id: "boss-testing-status", run: () => { const values = ["all", "ready", "needs-testing", "not-ready"]; setStatus(values[(values.indexOf(status) + 1) % values.length]); }, enabled: !preparing },
{ id: "boss-testing-picker", run: () => { const next = bosses[(bosses.findIndex((boss) => boss.key === selectedKey) + 1) % bosses.length]; if (next) void choose(next); }, enabled: !preparing && bosses.length > 0 },
...roles.map((value) => ({ id: `boss-testing-role-${value}`, run: () => setRole(value), enabled: !preparing })),
{ id: "boss-testing-start", run: testSelected, enabled: Boolean(selected) && !preparing },
{ id: "boss-testing-random", run: random, enabled: bosses.length > 0 && !preparing },
];
const controller = useMenuController(actions, { initialId: "boss-testing-picker", onBack: onClose });
const selectProps = (controlId: string) => ({ className: controller.selectedId === controlId ? "is-controller-selected" : "",
onPointerEnter: () => controller.select(controlId), disabled: preparing });
const button = (controlId: string, label: string, run: () => void, disabled = false) =>
<ControllerButton controlId={controlId} selectedId={controller.selectedId} select={controller.select}
className="front-button" type="button" onClick={run} disabled={disabled}>{label}</ControllerButton>;
return <DualDisplayFrame contextLabel="Boss Testing" top={
<FrontSurface className="boss-testing-screen" ariaLabel="Boss Testing selection">
<header className="boss-testing-header"><BrandMark compact /><div><span>Main menu / Mechanics laboratory</span><h1>Boss Testing</h1></div>
{button("boss-testing-back", "Back to main menu", onClose)}</header>
<main className="boss-testing-main">
<p>Choose a boss to test with your party in an empty room. Missing content downloads automatically when you select a boss.</p>
<div className="boss-testing-filters">
<label>Search bosses<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Boss or dungeon name" disabled={preparing} /></label>
<label>Game<select {...selectProps("boss-testing-category")} value={category} onChange={(event) => setCategory(event.target.value)}>
<option value="all">All games</option><option value="wow">WoW</option><option value="rom">RoM</option></select></label>
<label>Readiness<select {...selectProps("boss-testing-status")} value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="all">All statuses</option>{Object.entries(BOSS_TESTING_STATUS_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
</div>
<label className="boss-testing-picker">Choose a boss <small>{bosses.length} bosses</small>
<select {...selectProps("boss-testing-picker")} value={bosses.some((boss) => boss.key === selectedKey) ? selectedKey : ""}
onChange={(event) => { const boss = bosses.find((entry) => entry.key === event.target.value); if (boss) void choose(boss); }}>
<option value="" disabled>Select a boss…</option>
{bosses.map((boss) => <option key={boss.key} value={boss.key}>{boss.name} · {boss.dungeonTitle} · {BOSS_TESTING_STATUS_LABELS[reviews[boss.key]?.status ?? "needs-testing"]}</option>)}
</select>
</label>
{selected && <article className="boss-testing-selected"><p className="front-kicker">Selected encounter</p><h2>{selected.name}</h2><p>{selected.dungeonTitle}</p>
<strong className={`boss-testing-status boss-testing-status--${reviews[selected.key]?.status ?? "needs-testing"}`}>{BOSS_TESTING_STATUS_LABELS[reviews[selected.key]?.status ?? "needs-testing"]}</strong>
{reviews[selected.key]?.pending && <small>Readiness saved on this device; waiting to sync.</small>}
</article>}
{roles.length > 1 && <div className="boss-testing-actions" aria-label="Your party role">{roles.map((value) =>
<ControllerButton key={value} controlId={`boss-testing-role-${value}`} selectedId={controller.selectedId} select={controller.select}
className="front-button" type="button" aria-pressed={effectiveRole === value} onClick={() => setRole(value)} disabled={preparing}>{PARTY_ROLE_LABELS[value]}</ControllerButton>
)}</div>}
{preparing && <div role="status" className="boss-testing-progress"><p>Preparing {selected?.name}… {content.installingPackId ? "Downloading content" : "Checking assets"}</p>
{content.installingPackId && <><progress max={Math.max(1, content.totalBytes)} value={content.downloadedBytes} /><small>{Math.round(content.downloadedBytes / 1024 / 1024)} / {Math.round(content.totalBytes / 1024 / 1024)} MB</small></>}
</div>}
{error && <p role="alert" className="dungeon-report-error">{error}</p>}
{!bosses.length && <p role="status">No bosses match these filters.</p>}
<div className="boss-testing-actions">{button("boss-testing-start", error ? "Retry and test selected boss" : "Test selected boss", testSelected, !selected || preparing)}
{button("boss-testing-random", "Fight a random boss", random, !bosses.length || preparing)}</div>
<p className="boss-testing-help">Mark readiness and submit a boss report from pause or after victory. Test fights do not award loot, XP, or boss cards.</p>
</main><ControllerLegend back />
</FrontSurface>
} bottom={<LazyLocalCompanionPanel />} />;
}
+19 -3
View File
@@ -34,6 +34,8 @@ import { EditableHudElement } from "./EditableHudElement";
import { DpsMeterWidget } from "./DpsMeterWidget";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore";
import { currentDisplayLayout } from "../platform/displayLayout";
import { DungeonCompanionHud } from "./DungeonCompanionHud";
function safePercent(current: number, maximum: number): number {
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
@@ -166,12 +168,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
<p className="companion-kicker">Ready on both screens</p>
<h2>One roster. Two ways to play.</h2>
<div className="companion-feature-grid">
<article><b>01</b><span><strong>Local account</strong><small>Salted password verifier on this device</small></span></article>
<article><b>01</b><span><strong>Your roster</strong><small>Choose your adventurer after signing in</small></span></article>
<article><b>02</b><span><strong>Offline play</strong><small>No sign-in or network required</small></span></article>
<article><b>03</b><span><strong>PC fallback</strong><small>This panel opens as an overlay</small></span></article>
</div>
</main>
<footer><span>Top screen owns account entry</span><b>THOR / 620 x 540</b></footer>
<footer><span>Sign in on the top screen</span><b>THOR / 3.92 INCHES</b></footer>
</FrontSurface>
);
}
@@ -297,6 +299,19 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
return <CompanionDungeonSelection selected={selected} send={send} />;
}
if (snapshot.phase === "class-hall") {
const definition = selected ? classById(selected.classId) : null;
return <FrontSurface bottom className="companion-panel companion-create" ariaLabel="Class Hall companion screen">
<header><BrandMark compact /><b>CLASS HALL</b></header>
<main><section><p className="companion-kicker">{selected?.name ?? "Your adventurer"}</p>
<h2>{definition?.name ?? "Choose your path"}</h2>
<p>Choose your specialization on the top screen.</p>
<p>Your specialization determines your role and starting equipment.</p>
</section></main>
<footer className="companion-actions"><button type="button" onClick={() => send({ name: "returnToMainMenu" })}>Back to main menu</button></footer>
</FrontSurface>;
}
if (snapshot.phase === "manastorms") {
return <ShelvedCompanionPanel selected={selected} send={send} />;
}
@@ -510,11 +525,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
}
export function LocalCompanionPanel() {
useShellStore();
const shell = useShellStore();
useGameStore();
useCombatStore();
usePartyStore();
useUiLayoutStore();
useDpsMeterStore();
if (shell.phase === "game" && currentDisplayLayout() !== "single") return <DungeonCompanionHud send={executeCompanionCommand} />;
return <CompanionPanel snapshot={currentCompanionSnapshot()} send={executeCompanionCommand} />;
}
+4 -2
View File
@@ -5,6 +5,7 @@ import { actionKeyLabel, type HudElementId } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { EditableHudElement } from "./EditableHudElement";
import { ActionIcon, type ActionBarItem } from "./ActionBar";
import { AbilityTooltipTarget } from "./AbilityTooltip";
export function DesktopActionBars({ items }: { items: Readonly<Record<string, ActionBarItem>> }) {
const ui = useUiLayoutStore();
@@ -19,7 +20,8 @@ export function DesktopActionBars({ items }: { items: Readonly<Record<string, Ac
{bar.slots.map((button, s) => {
const item = button.abilityId ? items[button.abilityId] : null;
const selected = editing && ui.selectedButton?.bar === b && ui.selectedButton.slot === s;
return <button type="button" key={s} className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`}
return <AbilityTooltipTarget key={s} ability={item} bindingLabel={actionKeyLabel(button.key)}>
<button type="button" className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`}
aria-label={`Bar ${b + 1} button ${s + 1}: ${item?.name ?? "Empty"}${button.key ? `, ${actionKeyLabel(button.key)}` : ""}`}
aria-disabled={!editing && (!item || item.disabled || blocked)} aria-pressed={editing ? selected : undefined}
title={item ? `${item.name}\n${item.description ?? ""}\n${actionKeyLabel(button.key)}${item.lockedUntilLevel ? ` · Level ${item.lockedUntilLevel}` : ""}` : `Empty · ${actionKeyLabel(button.key)}`}
@@ -30,7 +32,7 @@ export function DesktopActionBars({ items }: { items: Readonly<Record<string, Ac
<ActionIcon item={item ?? null} /><kbd>{actionKeyLabel(button.key)}</kbd>
{item && item.cooldownRemaining! > .05 && <span className="desktop-action-button__cooldown">{item.cooldownRemaining! < 10 ? item.cooldownRemaining!.toFixed(1) : Math.ceil(item.cooldownRemaining!)}</span>}
{editing && !item && <small>{s + 1}</small>}
</button>;
</button></AbilityTooltipTarget>;
})}
</section>
</EditableHudElement>; })}</>;
+3 -1
View File
@@ -4,11 +4,13 @@ import { resolveDpsMeterDisplay } from "../game/uiLayout";
import { currentDpsMeterSnapshot, useDpsMeterStore } from "../game/dpsMeterStore";
import { EditableHudElement } from "./EditableHudElement";
import { DpsMeterWidget } from "./DpsMeterWidget";
import { useHudSurface } from "./HudSurface";
export function DpsMeter() {
const { surface } = useHudSurface();
useDpsMeterStore();
const ui = useUiLayoutStore();
const layout = ui.draft ?? ui.preferences.current[currentUiDevice()];
if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== "main") return null;
if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== (surface === "top" ? "main" : "companion")) return null;
return <EditableHudElement id="dps"><DpsMeterWidget snapshot={currentDpsMeterSnapshot()} width={layout.elements.dps.width} /></EditableHudElement>;
}
+27
View File
@@ -0,0 +1,27 @@
import { Hud } from "./Hud";
import { useGameStore } from "../game/store";
import type { CompanionCommand } from "../platform/dualScreenSync";
export function DungeonCompanionHud({ send, remote = false, completion, interaction }: {
send: (command: CompanionCommand) => void;
remote?: boolean;
completion?: boolean;
interaction?: { prompt: string; name: string; label: string } | null;
}) {
const bossTest = useGameStore((state) => state.bossTest);
return <main className="game-shell companion-hud-shell" aria-label="Dungeon bottom screen">
<Hud surface="bottom" completion={completion} interaction={interaction}
onFinish={remote ? () => send({ name: "finishDungeon" }) : undefined}
onInteract={remote ? () => send({ name: "interactDungeonObject" }) : undefined}
onResurrect={remote ? (checkpointId) => send({ name: "resurrectParty", checkpointId }) : undefined}
onLayoutChange={remote ? (id, update) => send({ name: "editHudElement", id, update }) : undefined} />
<nav className="companion-hud-menus" aria-label="Dungeon menus">
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "map" })}>Map</button>
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "spellbook" })}>Spells</button>
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "inventory" })}>Gear</button>
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "ui-layout" })}>Edit UI</button>
<button type="button" onClick={() => send({ name: "togglePause" })}>Pause</button>
{bossTest && <button type="button" onClick={() => send({ name: "openOverlay", overlay: "boss-test-review" })}>Boss review</button>}
</nav>
</main>;
}
+10 -6
View File
@@ -10,7 +10,10 @@ import {
import { useGameStore } from "../game/store";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
export function DungeonInteractionPrompt() {
export function DungeonInteractionPrompt({ interaction, onInteract }: {
interaction?: { prompt: string; name: string; label: string } | null;
onInteract?: () => void;
} = {}) {
const label = useInputBindingLabels();
const playerPosition = useGameStore((state) => state.playerPosition);
const inputMode = useGameStore((state) => state.inputMode);
@@ -21,19 +24,20 @@ export function DungeonInteractionPrompt() {
activeDungeonEncounterRevision,
);
void playerPosition;
const candidate = overlay ? null : activeDungeonInteractionCandidate();
const local = interaction === undefined && !overlay ? activeDungeonInteractionCandidate() : null;
const candidate = interaction ?? (local ? { prompt: local.object.interaction!.prompt, name: local.object.name, label: local.state.label } : null);
if (!candidate) return null;
return (
<button
type="button"
className="dungeon-interaction-prompt"
onClick={() => interactWithActiveDungeonObject()}
aria-label={`${candidate.object.interaction!.prompt}: ${candidate.object.name}`}
onClick={onInteract ?? (() => interactWithActiveDungeonObject())}
aria-label={`${candidate.prompt}: ${candidate.name}`}
>
<span className="dungeon-interaction-prompt__key">{label(inputMode === "gamepad" ? "controller" : "pc", "loot")}</span>
<span>
<strong>{candidate.object.interaction!.prompt}</strong>
<small>{candidate.object.name} · {candidate.state.label}</small>
<strong>{candidate.prompt}</strong>
<small>{candidate.name} · {candidate.label}</small>
</span>
</button>
);
+9 -7
View File
@@ -8,6 +8,7 @@ import { ControllerButton } from "./ControllerButton";
export function DungeonIssueReportPanel() {
const dungeonId = useGameStore((state) => state.activeDungeonId);
const bossTest = useGameStore((state) => state.bossTest);
const openOverlay = useGameStore((state) => state.openOverlay);
const session = useShellStore((state) => state.session);
const [comment, setComment] = useState("");
@@ -16,12 +17,12 @@ export function DungeonIssueReportPanel() {
const [error, setError] = useState("");
const busy = useRef(false);
const form = useRef<HTMLFormElement>(null);
const back = () => { if (!busy.current) openOverlay("pause"); };
const back = () => { if (!busy.current) openOverlay(bossTest ? "boss-test-review" : "pause"); };
const canSubmit = Boolean(comment.trim()) && comment.length <= DUNGEON_REPORT_COMMENT_MAX_LENGTH && !submitting;
const actions = useMemo<MenuAction[]>(() => [
{ id: "report-back", run: back, enabled: !submitting },
{ id: "report-submit", run: () => form.current?.requestSubmit(), enabled: canSubmit && !submitted },
], [canSubmit, openOverlay, submitted, submitting]);
], [bossTest, canSubmit, openOverlay, submitted, submitting]);
const controller = useMenuController(actions, { initialId: "report-back", onBack: back });
const submit = async (event: FormEvent) => {
@@ -31,7 +32,8 @@ export function DungeonIssueReportPanel() {
setSubmitting(true);
setError("");
try {
await submitDungeonReport(session, dungeonId, comment);
await submitDungeonReport(session, bossTest?.sourceDungeonId ?? dungeonId, comment,
bossTest ? { bossKey: bossTest.key, bossName: bossTest.name } : undefined);
setSubmitted(true);
} catch (failure) {
setError(failure instanceof Error ? failure.message : "Your report could not be submitted. Please try again.");
@@ -44,9 +46,9 @@ export function DungeonIssueReportPanel() {
return (
<div className="modal-backdrop" role="presentation">
<section className="pause-menu dungeon-issue-report" role="dialog" aria-modal="true" aria-labelledby="issue-report-title">
<p className="eyebrow">Dungeon feedback</p>
<h2 id="issue-report-title">Report an issue</h2>
<p>{dungeonById(dungeonId)?.title ?? dungeonId}</p>
<p className="eyebrow">{bossTest ? "Boss feedback" : "Dungeon feedback"}</p>
<h2 id="issue-report-title">{bossTest ? "Submit a boss report" : "Report an issue"}</h2>
<p>{bossTest ? `${bossTest.name} · ${dungeonById(bossTest.sourceDungeonId)?.title ?? bossTest.sourceDungeonId}` : dungeonById(dungeonId)?.title ?? dungeonId}</p>
{submitted ? <p role="status">Report submitted. You can follow its status in View Reports on the main menu.</p> : (
<form ref={form} onSubmit={(event) => void submit(event)}>
<label htmlFor="dungeon-issue-comment">Describe the issue</label>
@@ -62,7 +64,7 @@ export function DungeonIssueReportPanel() {
</form>
)}
<ControllerButton controlId="report-back" selectedId={controller.selectedId} select={controller.select}
className="button" type="button" onClick={back} disabled={submitting}>Back to pause menu</ControllerButton>
className="button" type="button" onClick={back} disabled={submitting}>{bossTest ? "Back to boss review" : "Back to pause menu"}</ControllerButton>
</section>
</div>
);
+1
View File
@@ -23,6 +23,7 @@ function ReportCard({ report, canManage, saving, onUpdate, selectedId, select }:
return (
<article className={`dungeon-report-card ${report.status === "Closed" ? "is-closed" : ""}`}>
<header><h2>{dungeonById(report.dungeonId)?.title ?? report.dungeonId}</h2><span className="dungeon-report-status">{report.status}</span></header>
{report.bossName && <strong>Boss: {report.bossName}</strong>}
<p className="dungeon-report-comment">{report.comment}</p>
<small>Submitted by {report.submittedBy} · <time dateTime={new Date(report.createdAt).toISOString()}>{new Date(report.createdAt).toLocaleString()}</time></small>
{report.updatedBy && <small>Updated by {report.updatedBy} · {new Date(report.updatedAt).toLocaleString()}</small>}
+12 -5
View File
@@ -1,6 +1,8 @@
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { HUD_ELEMENTS, type HudElementId, type HudElementLayout } from "../game/uiLayout";
import { HUD_ELEMENTS, resolveHudScreen, type HudElementId, type HudElementLayout } from "../game/uiLayout";
import { currentDisplayLayout } from "../platform/displayLayout";
import { useHudSurface } from "./HudSurface";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { useGameStore } from "../game/store";
import "./uiLayout.css";
@@ -22,23 +24,26 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
onChange?: (update: Partial<HudElementLayout>) => void;
}) {
const overlay = useGameStore((state) => state.overlay);
const surface = useHudSurface();
const dualScreen = currentDisplayLayout() !== "single";
const editing = editingOverride ?? overlay === "ui-layout";
const state = useUiLayoutStore();
const layout = state.draft ?? state.preferences.current[currentUiDevice()];
const element = elementLayout ?? layout.elements[id];
const screen = resolveHudScreen(id, layout, dualScreen);
const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
const ref = useRef<HTMLDivElement>(null);
const [host, setHost] = useState<Element | null>(null);
const drag = useRef<{ pointerId: number; dx: number; dy: number } | null>(null);
const [bounds, setBounds] = useState({ width: 0, height: 0, panelWidth: 0, panelHeight: 0 });
const positioned = editing || element.position !== null;
const positioned = editing || element.position !== null || (dualScreen && surface.surface === "bottom" && !["player", "actions", "cast", "experience", "dps"].includes(id));
const fallback = FALLBACKS[id] ?? [.5, .35, 250];
const position = element.position ?? (bottomAnchor !== undefined
? { x: .5, y: 1 - bottomAnchor / Math.max(1, bounds.height) }
: origin ?? { x: fallback[0], y: fallback[1] });
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2];
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector]);
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector, screen]);
useLayoutEffect(() => {
if (positioned || !ref.current) return;
@@ -66,18 +71,19 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
const observer = new ResizeObserver(measure);
observer.observe(node); observer.observe(host); measure();
return () => observer.disconnect();
}, [positioned, host, hostSelector]);
}, [positioned, host, hostSelector, screen]);
const updatePosition = (x: number, y: number) => {
const update = { position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width };
if (onChange) onChange(update);
else if (surface.onChange) surface.onChange(id, update);
else state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, [id]: { ...draft.elements[id], ...update } } }));
};
const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2));
const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2));
const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined;
const content = <div ref={ref} data-hud-element={id} style={style}
className={`editable-hud ${positioned ? "editable-hud--positioned" : "editable-hud--default"} ${editing ? "editable-hud--editing" : ""} ${!element.visible ? editing ? "editable-hud--ghost" : "editable-hud--hidden" : ""}`}
className={`editable-hud ${positioned ? "editable-hud--positioned" : "editable-hud--default"} ${editing ? "editable-hud--editing" : ""} ${state.selectedElement === id ? "editable-hud--selected" : ""} ${!element.visible ? editing ? "editable-hud--ghost" : "editable-hud--hidden" : ""}`}
onClickCapture={(event) => { if (editing && !id.startsWith("bar-")) { event.preventDefault(); event.stopPropagation(); useUiLayoutStore.setState({ selectedElement: id }); } }}>
{children}
{editing && <button type="button" className="editable-hud__handle" aria-label={`Move ${HUD_ELEMENTS[id]}`} title="Drag to move; arrow keys nudge"
@@ -102,5 +108,6 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
updatePosition(position.x + delta[0] * .01, position.y + delta[1] * .01);
}}>{HUD_ELEMENTS[id]}{!element.visible ? " · Hidden" : ""}</button>}
</div>;
if (dualScreen && screen !== surface.surface) return null;
return positioned && host ? createPortal(content, host) : content;
}
+27 -9
View File
@@ -1,4 +1,7 @@
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { HudSurfaceContext } from "./HudSurface";
import type { HudElementId, HudElementLayout } from "../game/uiLayout";
import { DungeonInteractionPrompt } from "./DungeonInteractionPrompt";
import {
useCallback,
useEffect,
@@ -168,7 +171,15 @@ export function DungeonCompletePrompt({
);
}
export function Hud() {
export function Hud({ surface = "top", onLayoutChange, completion, onFinish, onResurrect, interaction, onInteract }: {
surface?: "top" | "bottom";
onLayoutChange?: (id: HudElementId, update: Partial<HudElementLayout>) => void;
completion?: boolean;
onFinish?: () => void;
onResurrect?: (checkpointId: string) => void;
interaction?: { prompt: string; name: string; label: string } | null;
onInteract?: () => void;
} = {}) {
const bindingLabel = useInputBindingLabels();
const ui = useUiLayoutStore();
const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars;
@@ -230,7 +241,7 @@ export function Hud() {
);
const runProgress = useDungeonRunStore((state) => state.progress);
const finalBossDefeated = runProgress?.dungeonId === activeDungeonId && runProgress.finishedAt !== null;
const dungeonComplete = gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId));
const dungeonComplete = completion ?? (gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId)));
useEffect(() => {
const timer = window.setInterval(() => setClock(Date.now()), 100);
@@ -282,6 +293,10 @@ export function Hud() {
disabled: talentLocked || level < ability.unlockLevel || health <= 0 || remaining > 0 || Boolean(activeCast) || cannotPay,
lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined,
target: ability.target,
cooldownSeconds: ability.cooldownMs / 1_000,
castSeconds: ability.castTimeMs / 1_000,
range: ability.range.max,
talentLocked,
};
return [catalogAbility.id, item];
})), [assignedAbilities, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
@@ -391,12 +406,14 @@ export function Hud() {
};
return (
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
<HudSurfaceContext.Provider value={{ surface, onChange: onLayoutChange, onResurrect }}>
<div className={`hud hud-surface-${surface}`} style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
<ShelvedHud />
<DpsMeter />
<BossLootWindow />
{surface === "top" && <BossLootWindow />}
<EditableHudElement id="interaction"><DungeonInteractionPrompt interaction={interaction} onInteract={onInteract} /></EditableHudElement>
<EditableHudElement id="completion">{dungeonComplete ? (
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon}
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={onFinish ?? finishDungeon}
finalBossOnly={!activeDungeonRunIsComplete(activeDungeonId)} durationMs={runProgress?.timed ? runProgress.durationMs : null}
onResults={finalBossDefeated ? () => useGameStore.getState().openOverlay("dungeon-results") : undefined} />
) : null}</EditableHudElement>
@@ -409,7 +426,7 @@ export function Hud() {
<EditableHudElement id="party"><PartyFrames /></EditableHudElement>
<EditableHudElement id="summons"><SummonFrames now={clock} /></EditableHudElement>
{pendingGroundTarget ? (
{surface === "top" && pendingGroundTarget ? (
<section className="ground-target-reticle" role="status" aria-live="polite">
<div aria-hidden="true"><i /><b /></div>
<strong>Place ground effect</strong>
@@ -507,7 +524,7 @@ export function Hud() {
items={actionItems}
activeLayer={actionLayer}
onActivate={castBinding}
inputMode={inputMode}
inputMode={surface === "bottom" ? "gamepad" : inputMode}
feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null}
/></EditableHudElement>}
</div>
@@ -527,8 +544,8 @@ export function Hud() {
].map((asset) => asset.fileName).join(" and ")}.`}</span>
</div>
)}</EditableHudElement>
<LoadingOverlay />
{health <= 0 ? (
{surface === "top" && <LoadingOverlay />}
{surface === "top" && health <= 0 ? (
<DefeatedPrompt
gameMode={gameMode}
resurrectionCharges={resurrectionCharges}
@@ -539,5 +556,6 @@ export function Hud() {
/>
) : null}
</div>
</HudSurfaceContext.Provider>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { createContext, useContext } from "react";
import type { HudElementId, HudElementLayout } from "../game/uiLayout";
export const HudSurfaceContext = createContext<{
surface: "top" | "bottom";
onChange?: (id: HudElementId, update: Partial<HudElementLayout>) => void;
onResurrect?: (checkpointId: string) => void;
}>({ surface: "top" });
export const useHudSurface = () => useContext(HudSurfaceContext);
+5 -5
View File
@@ -26,7 +26,7 @@ const PC_KEYS = [
"Insert", "Home", "End", "PageUp", "PageDown", ...Array.from({ length: 10 }, (_, i) => `Numpad${i}`),
"NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide", "NumpadDecimal", "NumpadEnter",
];
function KeybindsDialog() {
function KeybindsDialog({ onClose }: { onClose?: () => void }) {
const [device, setDevice] = useState<InputDevice>(() => defaultInputDevice(Capacitor.isNativePlatform(), currentUiDevice() === "mobile"));
const [draft, setDraft] = useState(() => useUiLayoutStore.getState().preferences);
const [search, setSearch] = useState("");
@@ -57,7 +57,7 @@ function KeybindsDialog() {
const shown = INPUT_ACTIONS.filter((entry) => (group === "All actions" || entry.group === group)
&& (!conflictsOnly || conflicts.some((conflict) => conflict.id === entry.id))
&& `${entry.label} ${detail(entry.id)} ${inputBindingLabel(device, bindings[entry.id])}`.toLowerCase().includes(search.toLowerCase()));
const close = () => useGameStore.getState().openOverlay("options");
const close = onClose ?? (() => useGameStore.getState().openOverlay("options"));
const endCapture = () => { setListening(false); setCapturingInput(false); };
const cancelEdit = () => { endCapture(); setEditing(null); setPending(null); setResetPending(false); };
const apply = (id: string, key: string | null) => {
@@ -211,7 +211,7 @@ function KeybindsDialog() {
</section>
</div>;
}
export function KeybindsPanel() {
const open = useGameStore((state) => state.overlay === "keybinds");
return open ? <KeybindsDialog /> : null;
export function KeybindsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
const storeOpen = useGameStore((state) => state.overlay === "keybinds");
return (open ?? storeOpen) ? <KeybindsDialog onClose={onClose} /> : null;
}
+53
View File
@@ -0,0 +1,53 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { classById } from "../app/characterCatalog";
import { beginMainMenuCharacterEditing } from "../app/mainMenuCharacterEditing";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { AbilityBindingsPanel } from "./AbilityBindingsPanel";
import { BrandMark, FrontSurface } from "./FrontSurface";
import { KeybindsPanel } from "./KeybindsPanel";
import { RomSkillsPanel } from "./RomSkillsPanel";
import { loadTalentGameplayPanel } from "./talentGameplayResources";
import "./mainMenuCharacterEditor.css";
const TalentGameplayPanel = lazy(loadTalentGameplayPanel);
export type MainMenuEditor = "talents" | "keybinds" | "bindings";
export function MainMenuCharacterEditor({ editor, onClose }: {
editor: MainMenuEditor;
onClose: () => void;
}) {
const character = useShellStore(selectedCharacter);
const ownerId = useShellStore((state) => state.session?.ownerId);
const notice = useShellStore((state) => state.notice);
const characterKey = `${ownerId ?? ""}:${character?.id ?? ""}`;
const [readyKey, setReadyKey] = useState<string | null>(null);
useEffect(() => {
const stop = beginMainMenuCharacterEditing();
setReadyKey(stop ? characterKey : null);
return () => stop?.();
}, [characterKey]);
const ready = readyKey === characterKey;
return <DualDisplayFrame companionDisabled contextLabel="Character setup" bottom={null} top={
<FrontSurface className="main-menu-character-editor" ariaLabel="Main menu character setup">
<header className="main-menu-character-editor__header">
<BrandMark compact />
<div><strong>{character?.name ?? "Character setup"}</strong>
<span>{character ? `Level ${character.level} ${classById(character.classId).name}` : "Select a character to edit."}</span></div>
<button className="front-button" type="button" onClick={onClose}>Back to main menu</button>
</header>
{notice && <p className="front-notice" role="status">{notice}</p>}
<div className="main-menu-character-editor__panel">
{!ready ? <p role="status">Preparing character…</p> : <Suspense fallback={<p role="status">Opening talents…</p>}>
{editor === "talents" && (character?.categoryId === "rom"
? <RomSkillsPanel open onClose={onClose} />
: <TalentGameplayPanel open onClose={onClose} />)}
{editor === "keybinds" && <KeybindsPanel open onClose={onClose} />}
{editor === "bindings" && <AbilityBindingsPanel open onClose={onClose} />}
</Suspense>}
</div>
</FrontSurface>
} />;
}
+37 -5
View File
@@ -11,7 +11,9 @@ import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { DungeonReportsScreen } from "./DungeonReportsScreen";
import { MainMenuCharacterEditor, type MainMenuEditor } from "./MainMenuCharacterEditor";
const BossCardCollectionScreen = lazy(() => import("./BossCardCollection"));
const BossTestingScreen = lazy(() => import("./BossTestingScreen").then((module) => ({ default: module.BossTestingScreen })));
const GAME_MODES = [
{
@@ -68,16 +70,22 @@ export function MainMenuScreen() {
const [contentManagerOpen, setContentManagerOpen] = useState(false);
const [reportsOpen, setReportsOpen] = useState(false);
const [cardsOpen, setCardsOpen] = useState(false);
const [bossTestingOpen, setBossTestingOpen] = 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 (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)} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenEditor={setEditor} />;
}
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenEditor }: {
readonly onOpenDownloads: () => void;
readonly onOpenReports: () => void;
readonly onOpenCards: () => void;
readonly onOpenBossTesting: () => void;
readonly onOpenEditor: (editor: MainMenuEditor) => void;
}) {
const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter);
@@ -118,11 +126,17 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
run: returnToCharacters,
neighbors: { up: "mode-dungeons", left: "mode-dungeons" },
},
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
...(character ? [
{ id: "mode-class-hall", run: openClassHall },
{ id: "mode-talents", run: () => onOpenEditor("talents") },
{ id: "mode-keybinds", run: () => onOpenEditor("keybinds") },
{ id: "mode-bindings", run: () => onOpenEditor("bindings") },
] : []),
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
{ id: "mode-reports", run: onOpenReports },
{ id: "mode-cards", run: onOpenCards },
], [character, isGm, onOpenCards, onOpenDownloads, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
{ id: "mode-boss-testing", run: onOpenBossTesting },
], [character, isGm, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
const controller = useMenuController(actions, {
initialId: "mode-dungeons",
onBack: returnToCharacters,
@@ -177,7 +191,25 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
<p>Select a destination. More adventures will unlock here as they are built.</p>
</header>
<OnlineGroupPanel />
<nav className="main-menu-mode-grid" aria-label="Game modes">
<nav className="main-menu-mode-grid" aria-label="Main menu destinations">
<ControllerButton controlId="mode-boss-testing" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenBossTesting}>
<b aria-hidden="true">BT</b><span><strong>Boss Testing</strong><small>Choose a boss or fight a random encounter in an empty testing room.</small></span><em>Open</em>
</ControllerButton>
{character && <>
<ControllerButton controlId="mode-talents" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("talents")}>
<b aria-hidden="true">T</b><span><strong>{isRom ? "Skills" : "Talents"}</strong><small>{isRom ? "Review your primary, secondary, and elite skills." : "View your talent trees, spend points, or reset your build."}</small></span><em>Open</em>
</ControllerButton>
<ControllerButton controlId="mode-keybinds" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("keybinds")}>
<b aria-hidden="true">K</b><span><strong>Keybinds</strong><small>View and change keyboard or controller bindings.</small></span><em>Open</em>
</ControllerButton>
<ControllerButton controlId="mode-bindings" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("bindings")}>
<b aria-hidden="true">A</b><span><strong>Button assignments</strong><small>Choose the abilities on your action bars and controller buttons.</small></span><em>Open</em>
</ControllerButton>
</>}
<ControllerButton controlId="mode-cards" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenCards}>
<b aria-hidden="true">◆</b><span><strong>Boss Cards</strong><small>Track your collection, rarities, and copies from every dungeon.</small></span><em>Open</em>
+5 -2
View File
@@ -37,6 +37,7 @@ import {
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton";
import { AbilityTooltipTarget } from "./AbilityTooltip";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
@@ -413,6 +414,7 @@ export function ManastormSelectScreen() {
const capped = !selected
&& selectedSpellIds.length >= spellLoadoutModel.maxSelections;
return (
<AbilityTooltipTarget key={spell.id} ability={spell}>
<ControllerButton
key={spell.id}
controlId={`manastorm-spell-${spell.id}`}
@@ -420,8 +422,8 @@ export function ManastormSelectScreen() {
select={controller.select}
className={`manastorm-spell-option ${selected ? "is-active" : ""}`}
type="button"
disabled={capped}
onClick={() => toggleSpell(spell.id)}
aria-disabled={capped}
onClick={() => { if (!capped) toggleSpell(spell.id); }}
title={`${spell.description} · Client Spell.dbc ID ${spell.dbcSpellId}`}
>
<img src={spell.icon} alt="" aria-hidden="true" />
@@ -430,6 +432,7 @@ export function ManastormSelectScreen() {
<small>Spell {spell.dbcSpellId}</small>
</span>
</ControllerButton>
</AbilityTooltipTarget>
);
})}
{!spellLoadoutModel?.options.length ? (
+3 -1
View File
@@ -20,6 +20,7 @@ import { auraDefinitionIsDispellableDebuff } from "./auraDisplay";
import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../game/partyResurrection";
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
import { useGameStore } from "../game/store";
import { useHudSurface } from "./HudSurface";
const PARTY_COMMANDS: readonly {
command: PartyCommand;
@@ -212,6 +213,7 @@ function OnlineMemberFrame({
/** Compact roster for the player plus the selected number of targetable AI allies. */
export function PartyFrames() {
const { onResurrect } = useHudSurface();
const character = useShellStore((state) => state.activeCharacter);
const playerRole = useShellStore((state) => state.activeDungeonRole);
const playerHealth = useCombatStore((state) => state.health);
@@ -342,7 +344,7 @@ export function PartyFrames() {
title={bossCombat ? "Unavailable during boss combat." : !onlineSessionOwnsWorldSimulation()
? "Only the party's simulation host can resurrect NPC teammates."
: "Resurrect fallen NPC teammates at the selected checkpoint. They will return to you."}
onClick={() => resurrectDeadPartyMembers(checkpointId)}
onClick={() => (onResurrect ?? resurrectDeadPartyMembers)(checkpointId)}
>
<strong>Rez dead members</strong>
<small>{bossCombat ? "Unavailable during boss combat" : !onlineSessionOwnsWorldSimulation()
+8 -3
View File
@@ -7,6 +7,7 @@ import { sessionCanUseShelvedGmTools } from "../app/releaseFeatures";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { INPUT_ACTION_BY_ID } from "../input/keybinds";
import { DungeonIssueReportPanel } from "./DungeonIssueReportPanel";
import { BossTestingReviewPanel } from "./BossTestingReviewPanel";
function PauseDialog() {
const bindingLabel = useInputBindingLabels();
@@ -14,6 +15,7 @@ function PauseDialog() {
const openOverlay = useGameStore((state) => state.openOverlay);
const reset = useGameStore((state) => state.resetAtEntrance);
const gameMode = useGameStore((state) => state.gameMode);
const bossTest = useGameStore((state) => state.bossTest);
const activeCharacter = useShellStore((state) => state.activeCharacter);
const session = useShellStore((state) => state.session);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
@@ -50,12 +52,13 @@ function PauseDialog() {
{ id: "pause-options", run: () => openOverlay("options") },
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
...(gameMode === "dungeon" ? [{ id: "pause-report-issue", run: () => openOverlay("report-issue") }] : []),
...(bossTest ? [{ id: "pause-boss-testing-review", run: () => openOverlay("boss-test-review") }] : []),
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
...(gameMode === "manastorm"
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
: [{ id: "pause-entrance", run: returnToEntrance }]),
{ id: "pause-main-menu", run: leaveDungeon },
], [abandonManastormRun, closeOverlay, gameMode, isGm, openOverlay, reset, returnToMainMenu]);
], [abandonManastormRun, bossTest, closeOverlay, gameMode, isGm, openOverlay, reset, returnToMainMenu]);
const controller = useMenuController(actions, { initialId: "pause-resume", onBack: resume });
const menuButton = (id: string, label: string, run: () => void, primary = false) => (
@@ -73,10 +76,11 @@ function PauseDialog() {
<div className="modal-backdrop" role="presentation">
<section className="pause-menu" role="dialog" aria-modal="true" aria-labelledby="pause-title">
<p className="eyebrow">Healer Man paused</p>
<h2 id="pause-title">The cavern waits.</h2>
<h2 id="pause-title">{bossTest ? `${bossTest.name} — Boss Testing` : "The cavern waits."}</h2>
<p>Resume play or open any character and system menu from this hub.</p>
<div className="pause-menu__actions">
{menuButton("pause-resume", "Resume expedition", resume, true)}
{bossTest && menuButton("pause-boss-testing-review", "Boss readiness & report", () => openOverlay("boss-test-review"))}
{menuButton("pause-party", "Party management", () => openOverlay("party"))}
{menuButton("pause-inventory", "Inventory & equipment", () => openOverlay("inventory"))}
{menuButton("pause-boss-cards", "Boss card collection", () => openOverlay("boss-cards"))}
@@ -92,7 +96,7 @@ function PauseDialog() {
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
{gameMode === "manastorm"
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
: menuButton("pause-entrance", "Return to entrance", returnToEntrance)}
: menuButton("pause-entrance", bossTest ? "Restart boss test" : "Return to entrance", returnToEntrance)}
{menuButton("pause-main-menu", "Return to main menu", leaveDungeon)}
</div>
<dl className="controls-list">
@@ -110,5 +114,6 @@ function PauseDialog() {
export function PauseMenu() {
const overlay = useGameStore((state) => state.overlay);
if (overlay === "report-issue") return <DungeonIssueReportPanel />;
if (overlay === "boss-test-review") return <BossTestingReviewPanel />;
return overlay === "pause" ? <PauseDialog /> : null;
}
+5 -4
View File
@@ -20,9 +20,10 @@ function visibleGroup(ability: AbilityDefinition, primaryClassId: string): (type
return "primary";
}
export function RomSkillsPanel() {
const open = useGameStore((state) => state.overlay === "talents");
const close = useGameStore((state) => state.closeOverlay);
export function RomSkillsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
const storeOpen = useGameStore((state) => state.overlay === "talents");
const closeOverlay = useGameStore((state) => state.closeOverlay);
const close = onClose ?? closeOverlay;
const classId = useCombatStore((state) => state.classId);
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
const level = useCombatStore((state) => state.level);
@@ -32,7 +33,7 @@ export function RomSkillsPanel() {
{ id: "rom-skills-close", run: close },
], [abilities, close]);
const controller = useMenuController(actions, { initialId: abilities[0] ? `rom-skill-${abilities[0].id}` : "rom-skills-close", onBack: close });
if (!open) return null;
if (!(open ?? storeOpen)) return null;
const primary = classById(classId);
const secondary = secondaryClassId ? classById(secondaryClassId) : null;
+6
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import { AbilityTooltipTarget } from "./AbilityTooltip";
export interface SpellbookAbilityView {
id: string;
@@ -149,6 +150,10 @@ function SpellbookDialog({
? "Learn in talents"
: `Unlocks at level ${ability.level}`;
return (
<AbilityTooltipTarget key={ability.id} ability={{ ...ability,
lockedUntilLevel: ability.level > playerLevel ? ability.level : undefined,
talentLocked: ability.talentLocked && !ability.known,
}}>
<ControllerButton
key={ability.id}
controlId={`spell-${ability.id}`}
@@ -184,6 +189,7 @@ function SpellbookDialog({
? <em>{ability.talentLocked && !ability.known ? "Talent" : `Level ${ability.level}`}</em>
: <em className="spellbook-ability__known">{ability.passive ? "Passive" : "Known"}</em>}
</ControllerButton>
</AbilityTooltipTarget>
);
})}
{!visibleAbilities.length && (
+7 -1
View File
@@ -13,7 +13,11 @@ function treeSigil(name: string): string {
return name.split(/\s+/).map((word) => word[0]).join("").slice(0, 2).toUpperCase();
}
export default function TalentGameplayPanel({ readOnly = false }: { readOnly?: boolean } = {}) {
export default function TalentGameplayPanel({ readOnly = false, open, onClose }: {
readOnly?: boolean;
open?: boolean;
onClose?: () => void;
} = {}) {
const classId = useCombatStore((state) => state.classId);
const level = useCombatStore((state) => state.level);
const talentRanks = useCombatStore((state) => state.talentRanks);
@@ -45,5 +49,7 @@ export default function TalentGameplayPanel({ readOnly = false }: { readOnly?: b
onInvest={(nodeId) => { if (!readOnly) useCombatStore.getState().allocateTalent(nodeId); }}
onReset={() => { if (!readOnly) useCombatStore.getState().resetTalents(); }}
readOnly={readOnly}
open={open}
onClose={onClose}
/>;
}
+2 -2
View File
@@ -242,7 +242,7 @@ function TalentsDialog({
<><strong>{availablePoints}</strong><span>points available</span></>
)}
</div>
<button type="button" className="icon-button" onClick={onClose} aria-label="Return to pause menu">×</button>
<button type="button" className="icon-button" onClick={onClose} aria-label="Close talents">×</button>
</header>
<p className="talents-panel__intro">
@@ -436,7 +436,7 @@ function TalentsDialog({
>Reset talents</ControllerButton>
</div>
<footer className="game-panel__footer talents-panel__footer">
<span><kbd>D-pad</kbd> Navigate</span><span><kbd>A</kbd> Learn / inspect</span><span><kbd>B</kbd> Pause menu</span>
<span><kbd>D-pad</kbd> Navigate</span><span><kbd>A</kbd> Learn / inspect</span><span><kbd>B</kbd> Back</span>
</footer>
</section>
</div>
+38 -12
View File
@@ -2,15 +2,18 @@ import { useEffect, useRef, useState } from "react";
import { useShellStore } from "../app/shellStore";
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
import { useCombatStore } from "../game/combatStore";
import { defaultActionBarForCharacter } from "../game/abilityCatalog";
import { abilityAtLevel, defaultActionBarForCharacter } from "../game/abilityCatalog";
import { useGameStore } from "../game/store";
import {
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout,
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
resolveHudScreen, type HudScreen,
} from "../game/uiLayout";
import { INPUT_ACTION_BY_ID, conflictingInputActions } from "../input/keybinds";
import { profileInputBindings } from "../game/keybindPreferences";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { AbilityTooltipTarget } from "./AbilityTooltip";
import { currentDisplayLayout } from "../platform/displayLayout";
export function UiLayoutEditor() {
const open = useGameStore((state) => state.overlay === "ui-layout");
@@ -18,6 +21,8 @@ export function UiLayoutEditor() {
const session = useShellStore((state) => state.session);
const cloudSave = useCloudSaveStatus();
const abilities = useCombatStore((state) => state.abilities);
const level = useCombatStore((state) => state.level);
const talentRanks = useCombatStore((state) => state.talentRanks);
const [tab, setTab] = useState<UiDevice>(ui.device);
const [name, setName] = useState("My UI");
const [slot, setSlot] = useState(0);
@@ -30,13 +35,13 @@ export function UiLayoutEditor() {
const file = useRef<HTMLInputElement>(null);
const wasOpen = useRef(false);
useEffect(() => {
if (open && !wasOpen.current) {
if (open && (!wasOpen.current || !useUiLayoutStore.getState().draft)) {
useUiLayoutStore.getState().begin();
setTab(useUiLayoutStore.getState().device); setMessage("");
}
if (!open && wasOpen.current) useUiLayoutStore.getState().finish(false);
wasOpen.current = open;
}, [open]);
}, [open, ui.draft !== null]);
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
@@ -69,6 +74,7 @@ export function UiLayoutEditor() {
}, [open]);
if (!open || !ui.draft) return null;
const draft = ui.draft;
const dualScreen = currentDisplayLayout() !== "single";
const attempt = (run: () => void) => { try { run(); } catch (error) { setMessage(error instanceof Error ? error.message : "Could not save this UI."); } };
const saveAndClose = () => attempt(() => { ui.finish(true); useGameStore.getState().closeOverlay(); });
const validName = name.trim().length > 0;
@@ -87,7 +93,7 @@ export function UiLayoutEditor() {
<button type="button" onClick={() => setDockRight(!dockRight)} aria-label={dockRight ? "Dock editor left" : "Dock editor right"}>{dockRight ? "←" : "→"}</button>
<button type="button" onClick={() => setCollapsed(!collapsed)} aria-label={collapsed ? "Expand editor" : "Collapse editor"}>{collapsed ? "+" : "−"}</button></header>
{!collapsed && <div className="ui-layout-editor__body">
<p>Drag a widget’s label to move it. Hidden and inactive widgets appear as placeholders. Use the list to show them again.</p>
<p>Choose a screen for each element below. Collapse this panel and drag a widget’s label to move it. Use the list to show hidden elements again.</p>
<details open><summary>UI templates · 6 slots</summary>
<div className="ui-layout-editor__tabs">{(["mobile", "pc"] as const).map((device) => <button key={device} type="button" aria-pressed={tab === device} onClick={() => chooseSlot(device, 0)}>{device === "pc" ? "PC" : "Mobile"} · 3 slots</button>)}</div>
<div className="ui-layout-editor__slots">{ui.preferences.slots[tab].map((template, index) => <button type="button" key={index} aria-pressed={slot === index} onClick={() => chooseSlot(tab, index)}><small>Slot {index + 1}</small><strong>{template?.name ?? "Empty"}</strong></button>)}</div>
@@ -131,20 +137,30 @@ export function UiLayoutEditor() {
})}>Replace with imported UI</button><button type="button" onClick={() => setPendingImport(null)}>Keep existing UI</button></div>}
<small>{session?.accessToken ? "Templates save to your account and upload with cloud saves. Sign in on another device to load them." : "Offline profile: saved on this device. Use export/import to transfer your UI."}</small>
</details>
<details><summary>Elements · move / hide</summary>
{(Object.entries(HUD_ELEMENTS) as [HudElementId, string][]).filter(([id]) => !id.startsWith("bar-") || ui.device === "pc").map(([id, label]) => <div key={id} className={`ui-layout-editor__element ${ui.selectedElement === id ? "is-selected" : ""}`}>
<label><input type="checkbox" checked={draft.elements[id].visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], visible: event.target.checked } } }))} />{label}</label>
<details open={dualScreen}><summary>Elements · screen / move / hide</summary>
{(Object.entries(HUD_ELEMENTS) as [HudElementId, string][]).filter(([id]) => !id.startsWith("bar-") || ui.device === "pc").map(([id, label]) => <div key={id} className={`ui-layout-editor__element ${ui.selectedElement === id ? "is-selected" : ""}`}
onClick={() => useUiLayoutStore.setState({ selectedElement: id })} onFocus={() => useUiLayoutStore.setState({ selectedElement: id })}>
<label>{label}</label>
<select aria-label={`${label} screen`} value={dualScreen ? resolveHudScreen(id, draft, true) : draft.elements[id].visible ? draft.elements[id].screen ?? "auto" : "hidden"}
onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
[id]: { ...layout.elements[id], screen: event.target.value as HudScreen, visible: event.target.value !== "hidden", position: null, width: null },
} }))}>
{!dualScreen && <option value="auto">Automatic</option>}
<option value="top">Top</option><option value="bottom">Bottom</option><option value="hidden">Hidden</option>
</select>
<button type="button" aria-label={`Reset ${label} position`} onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], position: null, width: null } } }))}>Reset</button>
</div>)}
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button>
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements, dpsMeterDisplay: "auto" }))}>Reset all elements</button>
{dualScreen && <p>Top: party health and the dungeon. Bottom: action bars, buffs and debuffs. Collapse this panel to drag labels on either display.</p>}
</details>
<details open className="ui-layout-editor__dps"><summary>DPS meter</summary>
<details className="ui-layout-editor__dps"><summary>DPS meter</summary>
<label><input type="checkbox" checked={draft.elements.dps.visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: event.target.checked } },
elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: event.target.checked,
screen: event.target.checked && layout.elements.dps.screen === "hidden" ? "auto" : layout.elements.dps.screen } },
}))} />Show DPS meter</label>
<label>Screen<select aria-label="DPS meter screen" value={draft.dpsMeterDisplay} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
dpsMeterDisplay: event.target.value as typeof layout.dpsMeterDisplay,
elements: { ...layout.elements, dps: { ...layout.elements.dps, position: null } },
elements: { ...layout.elements, dps: { ...layout.elements.dps, screen: "auto", position: null } },
}))}>
<option value="auto">Automatic · PC lower left / Thor bottom</option>
<option value="main">Main screen</option><option value="companion">Companion screen</option>
@@ -173,7 +189,17 @@ export function UiLayoutEditor() {
{selected && <div className="ui-layout-editor__assignment"><strong>Bar {selected.bar + 1} · Button {selected.slot + 1}{ui.mode === "bindings" ? " · Press a key…" : ""}</strong>
<button type="button" onClick={() => setAbility(null)}>Clear ability</button>
<label>Find ability<input value={search} onChange={(event) => setSearch(event.target.value)} /></label>
<div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((ability) => <button key={ability.id} type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button>)}</div>
<div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((definition) => {
const talentRank = definition.talentEntryId === undefined ? undefined : talentRanks[`coa-entry-${definition.talentEntryId}`] ?? 0;
const ability = abilityAtLevel(definition, level, talentRank);
return <AbilityTooltipTarget key={ability.id} ability={{
name: ability.name, description: ability.description, target: ability.target,
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000, range: ability.range.max,
lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
talentLocked: ability.talentEntryId !== undefined && !talentRank,
}}><button type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button></AbilityTooltipTarget>;
})}</div>
</div>}
</>}
</details>}
+47
View File
@@ -0,0 +1,47 @@
.boss-testing-screen {
--boss-test-border: rgba(177, 199, 218, 0.22);
--boss-test-panel: rgba(14, 23, 33, 0.76);
display: flex;
flex-direction: column;
min-height: 100%;
overflow: auto;
padding: clamp(16px, 3vw, 40px);
gap: 20px;
}
.boss-testing-header { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; }
.boss-testing-header > div:nth-child(2) { flex: 1; }
.boss-testing-header h1, .boss-testing-selected h2 { margin: 5px 0; }
.boss-testing-header span, .boss-testing-help { color: var(--front-muted, #b1bbc5); }
.boss-testing-main { width: min(100%, 1060px); margin: 0 auto; display: grid; gap: 18px; }
.boss-testing-main p { margin: 0; line-height: 1.6; }
.boss-testing-filters { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 14px; }
.boss-testing-main label { display: grid; gap: 8px; font-weight: 600; min-width: 0; }
.boss-testing-main input, .boss-testing-main select {
color: #e8edf2;
background: #17232f;
border: 1px solid var(--boss-test-border);
padding: 12px;
border-radius: 8px;
width: 100%;
min-width: 0;
font: inherit;
}
.boss-testing-main select option { background: #17232f; }
.boss-testing-selected { background: var(--boss-test-panel); border: 1px solid var(--boss-test-border); border-radius: 12px; padding: 20px; display: grid; gap: 8px; }
.boss-testing-status { width: fit-content; padding: 5px 10px; border-radius: 5px; background: #354454; }
.boss-testing-status--ready { color: #b6f0cd; }
.boss-testing-status--needs-testing { color: #f4db9e; }
.boss-testing-status--not-ready { color: #f7b7b7; }
.boss-testing-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.boss-testing-actions [aria-pressed="true"] { border-color: #d6b66b; box-shadow: inset 0 0 0 1px #d6b66b; }
.boss-testing-progress { display: grid; gap: 8px; }
.boss-testing-progress progress { width: 100%; accent-color: #d6b66b; }
.boss-testing-help { font-size: 0.9rem; }
.boss-testing-review .boss-testing-actions { margin: 18px 0; }
@media (max-width: 700px) {
.boss-testing-filters { grid-template-columns: 1fr 1fr; }
.boss-testing-filters label:first-child { grid-column: 1 / -1; }
.boss-testing-header .front-brand { display: none; }
.boss-testing-header { gap: 12px; }
.boss-testing-actions > button { flex: 1 1 180px; }
}
+37
View File
@@ -0,0 +1,37 @@
.main-menu-character-editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.main-menu-character-editor__header {
display: flex;
align-items: center;
gap: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--edge);
}
.main-menu-character-editor__header > div:nth-child(2) {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
}
.main-menu-character-editor__header strong { color: var(--gold); }
.main-menu-character-editor__header span { color: var(--muted); font-size: 12px; }
.main-menu-character-editor__panel { position: relative; flex: 1; min-height: 0; }
.main-menu-character-editor__panel .modal-backdrop {
padding: 0;
background: transparent;
backdrop-filter: none;
}
.main-menu-character-editor__panel .game-panel {
max-width: 100%;
max-height: 100%;
}
.main-menu-character-editor__panel .keybinds-panel { width: min(1100px, 100%); height: 100%; }
@media (max-width: 640px), (max-height: 500px) {
.main-menu-character-editor { gap: 6px; }
.main-menu-character-editor__header { gap: 10px; padding-bottom: 6px; }
.main-menu-character-editor__header .front-brand { display: none; }
.main-menu-character-editor__header .front-button { padding: 8px 12px; font-size: 12px; }
}
+29
View File
@@ -0,0 +1,29 @@
import { createElement, type ComponentType } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { AbilityBindingsPanel } from "./AbilityBindingsPanel";
import { KeybindsPanel } from "./KeybindsPanel";
import { RomSkillsPanel } from "./RomSkillsPanel";
const editors: {
Panel: ComponentType<{ open?: boolean; onClose?: () => void }>;
title: string;
close: string;
}[] = [
{ Panel: AbilityBindingsPanel, title: "Ability controls", close: "Close button assignments" },
{ Panel: KeybindsPanel, title: "Keybinds", close: "Close keybinds" },
{ Panel: RomSkillsPanel, title: "Skills", close: ">Close</button>" },
];
describe.each(editors)("standalone main-menu editor: $title", ({ Panel, title, close }) => {
it("can open without changing the gameplay overlay", () => {
const markup = renderToStaticMarkup(createElement(Panel, { open: true, onClose: () => undefined }));
expect(markup).toContain('role="dialog"');
expect(markup).toContain(title);
expect(markup).toContain(close);
});
it("respects an explicit closed state", () => {
expect(renderToStaticMarkup(createElement(Panel, { open: false }))).toBe("");
});
});
+167
View File
@@ -0,0 +1,167 @@
/* Logical sizing uses a common physical density for both panels (160 px/in). */
.thor-preview-frame { min-height: 0; }
.thor-preview-label, .thor-preview-hinge { width: 100%; }
.thor-preview-top, .thor-preview-bottom, .dedicated-display-surface, .bottom-display-root { container-type: size; }
.companion-hud-shell { position: relative; width: 100%; height: 100%; overflow: hidden; background: radial-gradient(ellipse at top, #1c3827, #07110d 70%); }
.companion-hud-shell .hud-top-right { display: contents; }
.companion-hud-shell .player-status-panel { position: absolute; top: 12px; left: 12px; width: calc(100% - 24px); grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.companion-hud-shell .player-status-panel__section { padding: 8px; }
.companion-hud-shell .player-status-panel__section > header, .companion-hud-shell .player-status-panel__empty { font-size: 12px; }
.companion-hud-shell .player-status-panel__section > header b { min-width: 22px; height: 22px; font-size: 12px; }
.companion-hud-shell .player-status-panel__icons { justify-content: flex-start; min-height: 44px; }
.companion-hud-shell .player-status-panel :is(.aura, .timed-effect) { width: 44px; height: 44px; }
.companion-hud-shell :is(.aura__timer, .timed-effect__timer) { font-size: 11px; }
.companion-hud-shell .player-status-tooltip { top: 100%; left: 0; width: 100%; font-size: 13px; }
.companion-hud-shell .player-status-tooltip > header strong { font-size: 14px; }
.companion-hud-shell .player-status-tooltip p { font-size: 13px; }
.companion-hud-shell .player-status-tooltip :is(footer, header > span) { font-size: 11px; }
.companion-hud-shell .hud-bottom-stack { bottom: 76px; width: calc(100% - 16px); }
.companion-hud-shell .hud-bottom-stack .action-bar { --action-slot-size: 44px; width: min(420px, 100%); margin-top: 12px; }
.companion-hud-shell .action-slot__binding { min-width: 18px; height: 18px; font-size: 10px; }
.companion-hud-shell .action-bar__layer-status span { font-size: 11px; }
.companion-hud-shell .action-bar__detail strong { font-size: 12px; }
.companion-hud-shell .action-bar__detail span { font-size: 10px; }
.companion-hud-shell .action-slot__target { font-size: 10px; }
.companion-hud-shell .party-frame { min-height: 48px; }
.companion-hud-shell .party-frame__identity strong { font-size: 13px; }
.companion-hud-shell .party-frame__identity small { font-size: 10px; }
.companion-hud-shell .party-frame__health { height: 14px; }
.companion-hud-shell .party-frame__health b, .companion-hud-shell .party-frames > header { font-size: 10px; }
.companion-hud-shell .party-command-bar button { min-height: 44px; padding-inline: 2px; }
.companion-hud-shell .party-command-bar strong { font-size: 11px; }
.companion-hud-shell .party-command-bar kbd { display: none; }
.companion-hud-shell .hud-minimap__caption strong { font-size: 13px; }
.companion-hud-shell .hud-minimap__caption span, .companion-hud-shell .target-frame small { font-size: 11px; }
.companion-hud-shell .dps-meter { position: absolute; top: 116px; left: 12px; bottom: auto; width: min(var(--dps-meter-width, 230px), calc(100% - 24px)); max-height: 64px; font-size: 12px; }
.companion-hud-shell .experience-bar { bottom: 56px; left: 12px; right: 12px; width: calc(100% - 24px); height: 16px; }
.companion-hud-shell .experience-bar__label { font-size: 10px; }
.companion-hud-menus { position: absolute; bottom: max(6px, env(safe-area-inset-bottom)); left: 8px; right: 8px; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 5px; pointer-events: auto; z-index: 30; }
.companion-hud-menus button { min-width: 0; min-height: 44px; padding: 4px; border: 1px solid var(--edge); border-radius: 6px; background: #172b20; color: var(--ink); font-size: 13px; }
.ui-layout-editor { font-size: 14px; }
.ui-layout-editor__body { min-height: 0; }
.ui-layout-editor :is(button, select, input:not([type=checkbox])) { min-height: 44px; }
.ui-layout-editor > header button { min-width: 44px; }
.ui-layout-editor__element { flex-wrap: wrap; padding: 6px 0; border-bottom: 1px solid var(--edge); }
.ui-layout-editor__element label { flex: 1 1 120px; margin: 0; }
.ui-layout-editor__element select { min-width: 78px; }
.editable-hud__handle { min-width: 44px; min-height: 44px; padding: 6px 8px; font-size: 12px; }
.editable-hud--editing { min-height: 44px; }
.editable-hud--editing.editable-hud--selected { z-index: 26; outline: 2px solid var(--gold); }
.companion-hud-shell .editable-hud--editing.editable-hud--selected { z-index: 40; }
.companion-hud-shell .dungeon-complete-banner { flex-wrap: wrap; }
.companion-hud-shell .dungeon-complete-banner p { flex-basis: calc(100% - 42px); }
.companion-hud-shell .dungeon-complete-banner .button { flex: 1; min-height: 44px; font-size: 13px; }
.companion-hud-shell .dungeon-complete-banner strong { font-size: 14px; }
.companion-hud-shell .dungeon-complete-banner small { font-size: 12px; }
/* Container queries also work in the stacked preview: the window is not a panel. */
@container (max-width: 650px) {
.front-surface--bottom { padding: 10px; font-size: 13px; overflow-y: auto; }
.companion-panel { grid-template-rows: 44px minmax(0, 1fr) auto; }
.companion-panel > header > b { font-size: 11px; }
.companion-panel .front-brand { gap: 6px; }
.companion-panel .front-brand small { font-size: 8px; }
.companion-panel .front-brand strong { font-size: 15px; }
.companion-panel > main { min-height: 0; overflow: auto; padding: 8px 0; gap: 8px; }
.companion-roster > main { grid-template-columns: minmax(0, 1fr); }
.companion-selection-card { display: none; }
.companion-mini-roster { overflow: auto; }
.companion-mini-roster button { min-height: 48px; padding: 8px; }
.companion-mini-roster button strong { font-size: 14px; }
.companion-mini-roster button small { font-size: 11px; }
.companion-actions { flex-wrap: wrap; padding-top: 6px; }
.companion-actions button { min-height: 44px; min-width: 100px; font-size: 12px; }
.companion-dungeons > main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; }
.companion-dungeon-role { display: block; }
.companion-dungeon-role .character-preview { display: none; }
.companion-dungeon-role h2 { font-size: 17px; }
.companion-role-options { display: flex; gap: 6px; }
.companion-role-options button { flex: 1; min-height: 44px; padding: 6px; }
.companion-role-options strong { font-size: 14px; }
.companion-role-options small { font-size: 10px; }
.companion-category-tabs button { min-height: 44px; }
.companion-category-tabs button strong { font-size: 13px; }
.companion-create > main { grid-template-columns: 55% minmax(0, 1fr); }
.companion-create main > .character-preview { min-width: 0; height: 240px; }
.companion-create h2 { font-size: 24px; }
.companion-create dl { font-size: 14px; }
.companion-panel dl dt, .companion-panel dl dd { font-size: 13px; }
.companion-feature-grid article strong { font-size: 13px; }
.companion-feature-grid article small { font-size: 11px; }
.companion-login > footer { font-size: 11px; }
}
@container (min-width: 700px) and (max-width: 1000px) {
.front-surface--top { padding: 12px; }
.front-surface--top :is(p, small, label, dt, dd) { font-size: max(11px, 0.7rem); }
.front-surface--top button { min-height: 44px; }
.login-hero h1 { font-size: 44px; }
.login-form { padding: 20px; }
.login-form h2 { font-size: 25px; }
.character-select-screen { grid-template-columns: 220px minmax(0, 1fr); }
.roster-header, .character-select-screen > .controller-legend { grid-column: 1 / 3; }
.roster-details { display: none; }
.roster-stage-continue { display: flex; }
.character-create-screen { grid-template-columns: 42% 58%; grid-template-rows: 47px 3px minmax(0, 1fr) 58px; }
.creator-header, .creator-progress, .creator-footer { grid-column: 1 / 3; }
.creator-races { grid-column: 1; }
.creator-preview-stage, .appearance-preview-stage { display: none; }
.creator-classes { grid-column: 2; }
.appearance-controls { grid-column: 1 / 3; }
.creator-footer { gap: 8px; }
.dungeon-select-screen { grid-template-columns: 260px minmax(0, 1fr); gap: 10px; }
.dungeon-select-detail { grid-template-columns: 1fr; gap: 10px; }
.dungeon-select-map { display: none; }
.dungeon-select-copy { padding: 8px; }
.dungeon-role-option { min-height: 48px; }
.front-surface--top :is(.race-option > span, .class-option strong, .roster-character small, .main-menu-mode small) { font-size: 12px; }
.race-option > b { font-size: 10px; }
.creator-classes { grid-template-rows: auto minmax(100px, 1fr) auto; overflow-y: auto; }
.class-summary { max-height: 155px; overflow-y: auto; }
.class-option small { font-size: 11px; }
.game-panel :is(p, small, label, dt, dd, em), .game-panel :is(button, select, input) { font-size: 12px; }
.game-panel :is(button, select, input), .pause-menu__actions .button { min-height: 44px; }
.game-panel .icon-button { min-width: 44px; }
.game-panel__footer { font-size: 11px; }
.game-panel :is(.party-roster strong, .spellbook-ability strong, .inventory-member strong) { font-size: 13px; }
.game-panel :is(.equipment-slot, .inventory-attributes-tabs button, .bindings-slot) { min-height: 44px; }
.party-frames { width: 230px; }
.party-frames > header { font-size: 11px; min-height: 18px; }
.party-frame { min-height: 48px; }
.party-frame__identity strong { font-size: 13px; }
.party-frame__identity small { font-size: 10px; }
.party-frame__health { height: 14px; }
.party-frame__health b { font-size: 10px; }
.party-command-bar strong { font-size: 11px; }
.party-command-bar kbd { display: none; }
.dungeon-complete-banner { width: min(610px, calc(100% - 24px)); }
.dungeon-complete-banner strong { font-size: 14px; }
.dungeon-complete-banner small { font-size: 11px; }
.dungeon-complete-banner .button, .defeated-prompt__panel .button { min-height: 44px; font-size: 12px; }
.defeated-prompt__panel > small { font-size: 11px; }
.dungeon-results :is(button, select) { min-height: 44px; font-size: 13px; }
.hud-minimap__caption span, .target-frame :is(small, header span) { font-size: 11px; }
.hud-minimap__caption strong { font-size: 13px; }
.target-frame header strong { font-size: 13px; }
.class-hall-screen { display: grid; grid-template-columns: 230px minmax(0, 1fr); grid-template-rows: 54px minmax(0, 1fr) 24px; gap: 10px; }
.class-hall-header { grid-column: 1 / 3; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--front-line); }
.class-hall-header > div:not(.front-brand) { flex: 1; }
.class-hall-header .front-brand { flex-direction: row; }
.class-hall-header > button { padding: 8px 14px; border: 1px solid var(--front-line); border-radius: 6px; color: var(--ink); background: #172b20; }
.class-hall-character { grid-column: 1; grid-row: 2; display: grid; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; overflow: hidden; }
.class-hall-character > .character-preview { height: 100%; min-height: 0; }
.class-hall-character .character-preview figcaption { display: none; }
.class-hall-character > div { padding: 10px; }
.class-hall-character h1 { margin: 4px 0; font-size: 25px; }
.class-hall-character small { display: block; }
.class-hall-content { grid-column: 2; grid-row: 2; min-height: 0; overflow: auto; }
.class-hall-screen > .controller-legend { grid-column: 1 / 3; grid-row: 3; }
.content-manager-screen { display: grid; grid-template-rows: 60px minmax(0, 1fr) 24px; gap: 12px; }
.content-manager-header { display: flex; align-items: center; gap: 16px; }
.content-manager-header .front-brand { flex-direction: row; }
.content-manager-header > div:not(.front-brand) { flex: 1; }
.content-manager-main { display: grid; grid-template-columns: 300px minmax(0, 1fr); min-height: 0; gap: 12px; }
.content-manager-summary, .content-manager-packs { padding: 12px; min-height: 0; overflow: auto; }
.content-manager-summary h1 { font-size: 26px; line-height: 1.15; }
.content-manager-summary__actions { flex-wrap: wrap; gap: 8px; }
}