-
Main display960 x 540 CSS / 1920 x 1080
-
{top}
+
+
Top · 6 inches1920 × 1080 · {THOR_DISPLAYS.top.widthMm.toFixed(1)} × {THOR_DISPLAYS.top.heightMm.toFixed(1)} mm
+
{top}
AYN THOR
-
Companion display620 x 540 CSS / 1240 x 1080
-
{bottom}
+
Bottom · 3.92 inches1240 × 1080 · {THOR_DISPLAYS.bottom.widthMm.toFixed(1)} × {THOR_DISPLAYS.bottom.heightMm.toFixed(1)} mm
+
{bottom}
);
}
diff --git a/src/game/BossTestingBridge.tsx b/src/game/BossTestingBridge.tsx
new file mode 100644
index 00000000..ada1cdc5
--- /dev/null
+++ b/src/game/BossTestingBridge.tsx
@@ -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;
+}
diff --git a/src/game/CombatBridge.tsx b/src/game/CombatBridge.tsx
index 9575ff56..99436111 100644
--- a/src/game/CombatBridge.tsx
+++ b/src/game/CombatBridge.tsx
@@ -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);
diff --git a/src/game/OnlineSessionBridge.tsx b/src/game/OnlineSessionBridge.tsx
index 751596be..6c714711 100644
--- a/src/game/OnlineSessionBridge.tsx
+++ b/src/game/OnlineSessionBridge.tsx
@@ -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;
}
diff --git a/src/game/bossCards.ts b/src/game/bossCards.ts
index cb19ce93..a5a7db99 100644
--- a/src/game/bossCards.ts
+++ b/src/game/bossCards.ts
@@ -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
= Object.create(null);
+ const lastClaims: Record = 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) => {
diff --git a/src/game/bossTesting.test.ts b/src/game/bossTesting.test.ts
new file mode 100644
index 00000000..bc026378
--- /dev/null
+++ b/src/game/bossTesting.test.ts
@@ -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]);
+ });
+});
diff --git a/src/game/bossTesting.ts b/src/game/bossTesting.ts
new file mode 100644
index 00000000..6e2dfcb1
--- /dev/null
+++ b/src/game/bossTesting.ts
@@ -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): 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(value: T, ids: ReadonlyMap): 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: [] } },
+ },
+ };
+}
diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts
index d70d1113..1f577459 100644
--- a/src/game/combatStore.ts
+++ b/src/game/combatStore.ts
@@ -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;
diff --git a/src/game/dungeonRegistry.ts b/src/game/dungeonRegistry.ts
index 36c0b9cd..7382200f 100644
--- a/src/game/dungeonRegistry.ts
+++ b/src/game/dungeonRegistry.ts
@@ -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();
+
+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 {
diff --git a/src/game/dungeonSession.ts b/src/game/dungeonSession.ts
index 9b60fda9..2ca1cafb 100644
--- a/src/game/dungeonSession.ts
+++ b/src/game/dungeonSession.ts
@@ -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,
diff --git a/src/game/store.ts b/src/game/store.ts
index a4200b1b..d41b56eb 100644
--- a/src/game/store.ts
+++ b/src/game/store.ts
@@ -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((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((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((set) => ({
const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw };
return {
gameMode: "dungeon",
- gmTestMode: false,
+ gmTestMode: Boolean(state.bossTest),
activeGmReviewTarget: null,
activeSpawn,
overlay: null,
diff --git a/src/game/uiLayout.test.ts b/src/game/uiLayout.test.ts
index acec0c15..34e95d68 100644
--- a/src/game/uiLayout.test.ts
+++ b/src/game/uiLayout.test.ts
@@ -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 };
diff --git a/src/game/uiLayout.ts b/src/game/uiLayout.ts
index 498ce23e..dd8feb53 100644
--- a/src/game/uiLayout.ts
+++ b/src/game/uiLayout.ts
@@ -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();
diff --git a/src/game/uiLayoutStore.ts b/src/game/uiLayoutStore.ts
index c859c151..5a3ca7ad 100644
--- a/src/game/uiLayoutStore.ts
+++ b/src/game/uiLayoutStore.ts
@@ -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";
}
diff --git a/src/main.tsx b/src/main.tsx
index e6a21d67..f4daae3b 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -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();
diff --git a/src/platform/BottomDisplayApp.tsx b/src/platform/BottomDisplayApp.tsx
index dea88934..ddf37ee8 100644
--- a/src/platform/BottomDisplayApp.tsx
+++ b/src/platform/BottomDisplayApp.tsx
@@ -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 ;
+ return {snapshot.phase === "game"
+ ?
+ : };
}
diff --git a/src/platform/RemoteDungeonHud.tsx b/src/platform/RemoteDungeonHud.tsx
new file mode 100644
index 00000000..778ab128
--- /dev/null
+++ b/src/platform/RemoteDungeonHud.tsx
@@ -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 ;
+}
diff --git a/src/platform/authoritativeDualScreenRuntime.ts b/src/platform/authoritativeDualScreenRuntime.ts
index 7ba33920..bbda1da1 100644
--- a/src/platform/authoritativeDualScreenRuntime.ts
+++ b/src/platform/authoritativeDualScreenRuntime.ts
@@ -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) => {
if (event.data.type === "companion-ready") {
companionReady = true;
@@ -62,6 +71,10 @@ export function connectAuthoritativeDualScreen(
unsubscribeParty();
unsubscribeUi();
unsubscribeDps();
+ unsubscribeOnline();
+ unsubscribeGroup();
+ unsubscribeRun();
+ unsubscribeEncounters();
gamePublisher.dispose();
};
}
diff --git a/src/platform/dualScreenSync.ts b/src/platform/dualScreenSync.ts
index f3b84f8c..d7c6cd8e 100644
--- a/src/platform/dualScreenSync.ts
+++ b/src/platform/dualScreenSync.ts
@@ -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 };
+ | { name: "editDpsMeter"; update: Partial }
+ | { name: "editHudElement"; id: HudElementId; update: Partial }
+ | { 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 = {};
+ 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 = {};
diff --git a/src/platform/hudSnapshot.test.ts b/src/platform/hudSnapshot.test.ts
new file mode 100644
index 00000000..0c283287
--- /dev/null
+++ b/src/platform/hudSnapshot.test.ts
@@ -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);
+ }
+ });
+});
diff --git a/src/platform/hudSnapshot.ts b/src/platform/hudSnapshot.ts
new file mode 100644
index 00000000..2d020a7a
--- /dev/null
+++ b/src/platform/hudSnapshot.ts
@@ -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(state: T, keys: readonly K[]): Pick {
+ return Object.fromEntries(keys.map((key) => [key, state[key]])) as Pick;
+}
+
+/** 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;
diff --git a/src/platform/thorDisplayProfile.test.ts b/src/platform/thorDisplayProfile.test.ts
new file mode 100644
index 00000000..2de92125
--- /dev/null
+++ b/src/platform/thorDisplayProfile.test.ts
@@ -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);
+ }
+ });
+});
diff --git a/src/platform/thorDisplayProfile.ts b/src/platform/thorDisplayProfile.ts
new file mode 100644
index 00000000..cd376ca0
--- /dev/null
+++ b/src/platform/thorDisplayProfile.ts
@@ -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),
+};
diff --git a/src/scene/BossTestingRoom.tsx b/src/scene/BossTestingRoom.tsx
new file mode 100644
index 00000000..78450e3d
--- /dev/null
+++ b/src/scene/BossTestingRoom.tsx
@@ -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
+
+
+
+ {([
+ [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) =>
+
+
+
+ )}
+
+ ;
+}
diff --git a/src/scene/DungeonEnvironment.tsx b/src/scene/DungeonEnvironment.tsx
index 7b9cb4b3..5abd7034 100644
--- a/src/scene/DungeonEnvironment.tsx
+++ b/src/scene/DungeonEnvironment.tsx
@@ -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 ;
if (!hasProductionAssets) return ;
return (
diff --git a/src/styles.css b/src/styles.css
index f60b9fad..20505667 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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; }
diff --git a/src/ui/AbilityBindingsPanel.tsx b/src/ui/AbilityBindingsPanel.tsx
index a1860e8d..1546bab3 100644
--- a/src/ui/AbilityBindingsPanel.tsx
+++ b/src/ui/AbilityBindingsPanel.tsx
@@ -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 void }) {
const panel = useRef(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(() => 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 (
+
+}
{definition.label}{locked ? `${ability!.name} · Level ${ability!.unlockLevel}` : ability?.name ?? "Empty"}
+
);
};
@@ -363,7 +379,7 @@ function AbilityBindingsDialog() {
{isWow ? "WoW loadout · 5 bars · 60 customizable slots" : "Controller loadout · 24 customizable slots"}
Ability controls
-