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

+157
View File
@@ -4,6 +4,30 @@ This file is the durable record of runtime behavior, regressions, and the
evidence required before calling a visual bug fixed. Update it whenever scene, evidence required before calling a visual bug fixed. Update it whenever scene,
camera, input, physics, mob-rendering, or asset-loading code changes. camera, input, physics, mob-rendering, or asset-loading code changes.
## 2026-09-14 Ability hover tooltips
- Entered Stormwind Stockade with the existing offline level-17 Priest through
the normal roster, dungeon, and role screens. Hovering Smite on the controller
bar displayed its description, Mana cost, cast time, cooldown, range, and
target. Leaving the button removed the tooltip.
- Opened Ability controls and its ability picker. Hovering Shadow Word: Pain
displayed its own description without assigning it. Flash Heal was readable
by hover and keyboard focus despite its level-20 lock. Switching from keyboard
inspection to another hovered ability left exactly one tooltip visible.
- Checked the picker at the default 650 x 490 viewport and at 1280 x 800.
Tooltip DOM bounds remained inside the viewport. The in-app screenshot
scales unusually with a viewport override; reset the override afterward.
- Enabled desktop bars in an unsaved UI-layout draft. Hovering the leftmost
Smite button showed the tooltip above the bar, clamped to the left edge and
outside the HUD's transformed layout container. Discarded the layout draft.
- A temporary Flash Heal assignment during input testing was restored to the
original Smite assignment. No character or template was created or deleted.
- Screenshot evidence: `playtest-artifacts/ability-tooltips/desktop-bar.png`
and `playtest-artifacts/ability-tooltips/locked-picker.png`.
- Automated verification: all 64 UI tests passed, including nine tooltip
content, unavailable-control, and viewport-placement regression tests.
TypeScript, the production build, and the release candidate check passed.
## 2026-09-10 Personal UI templates and desktop action bars ## 2026-09-10 Personal UI templates and desktop action bars
- Opened Wailing Caverns through the normal offline roster, dungeon, and role - Opened Wailing Caverns through the normal offline roster, dungeon, and role
@@ -3155,3 +3179,136 @@ Vite's existing large-chunk advisory remains informational.
`artifacts/ability-controls/wow-picker.png`, and `artifacts/ability-controls/wow-picker.png`, and
`artifacts/ability-controls/wow-picker-end.png`. Cloud upload and physical `artifacts/ability-controls/wow-picker-end.png`. Cloud upload and physical
gamepad input were not exercised by this offline browser pass. gamepad input were not exercised by this offline browser pass.
## Account-wide boss card collections — 2026-09-14
- Moved boss card counts and reward receipts out of character profiles into one
collection per account owner. Gameplay starts with that account collection;
menu and in-game collection screens show the same account totals.
- Migrates existing character logs by summing their rarity counts, retaining the
newest reward receipts, and removing the old character fields. Reloading or
downloading an old cloud roster again does not repeat the migration.
- Cloud uploads now include the account collection, including accounts with an
empty roster. Migrated cloud logs upload without waiting for another boss kill.
Higher cached counts and newer receipts survive an older cloud download.
- Automated integration checks verified earning on a second character,
switching back, account sign-out isolation, character deletion, and restoration
into a separate device cache. Collection markup identifies the account scope.
- Validation: 164 tests passed across 19 files, with six shelved-mode tests
skipped. This persistence change did not alter character models or equipment.
- TypeScript, prebuild asset audits, and release hygiene passed. The first
shared-output bundle check encountered a missing staged asset; an isolated
production build and complete bundle check passed with all 5,529 objects.
Validation output is under `artifacts/boss-cards/account-build`.
## Main-menu talents and controls — 2026-09-14
- Added Talents, Keybinds, and Button assignments destinations using the existing
editors. RoM characters show their automatically ranked Skills instead of
a talent-point editor. Closing a panel or using Back returns to the main menu.
- A menu-only character subscription initializes the selected character and
saves talent ranks and controller ability assignments without mounting the
dungeon simulation. Level, experience, inventory, and equipment are preserved.
Keyboard bindings and WoW bar assignments retain the existing profile saving
behavior; keybind drafts require Save changes.
- Live offline browser QA at 1280x720 opened all three editors for the level-17
Priest Rolecheck. The talent tree showed eight available points, controls
showed the character's skill names, and the assignment picker showed current
Smite and future unlock levels. Canceling a keybind edit and using Done returned
correctly; Escape first canceled the ability picker and then closed the editor.
This browser pass did not change the character's build or saved controls.
- At a 390x844 viewport, the talent dialog's measured bounds fit the screen,
with no horizontal content overflow and a visible main-menu Back button.
Browser screenshot scaling was unreliable at this override; visual evidence
below covers the default desktop viewport. Physical gamepads and cloud saves
were not exercised.
- Persistence tests cover allocation, reset, assignment, reopening, storage
failure, and owner/selection/phase/close isolation. UI and profile regressions
passed. TypeScript, production bundling, and complete release-bundle validation
passed after retrying a shared-output staging race; release hygiene passed.
- Evidence: `playtest-artifacts/main-menu-editors/main-menu.png`,
`playtest-artifacts/main-menu-editors/talents.png`,
`playtest-artifacts/main-menu-editors/keybinds.png`, and
`playtest-artifacts/main-menu-editors/button-assignments.png`.
## AYN Thor physical-size UI review — 2026-09-14
- Browser profiles use the Thor's 6-inch 1920x1080 upper panel and 3.92-inch
1240x1080 lower panel. Panel sizes come from
[AYN's specifications](https://www.ayntec.com/products/ayn-thor); resolutions
are also documented in the
[Thor manual](https://ae-pic-a1.aliexpress-media.com/kf/S21d979b6c8d840ddaebfeb976094e0cfP.pdf).
Derived physical rectangles are 132.8x74.7 mm and 75.1x65.4 mm. At a common
160 CSS pixels per inch, browser viewports are 837x471 and 473x412, with
independent device scale factors. A 44-pixel control represents about 7 mm
on either panel. The combined preview uses these relative physical sizes.
- Dungeon defaults put party/self health, targets, minimap, and encounter
information upstairs. Controller actions, cast bar, buffs/debuffs, experience,
and DPS meter appear downstairs. Shared HUD components render once on their
assigned panel; the old duplicate tactical party-health panel is replaced.
- Every HUD element has Top / Bottom / Hidden in the mobile UI editor. Touch
dragging from the lower page updates the upper page's authoritative draft.
Selection raises the widget above overlapping controls. Save, cancel,
reset, rapid reopening, and a full reload/dungeon reentry were verified.
Legacy templates without a screen field retain their defaults. Single-screen
play keeps visible elements on its primary display.
- Enlarged text, icons, and touch controls; corrected roster, creator, class
hall, downloads, and dungeon/role layouts for the actual panel dimensions.
Character creation uses the lower model preview. Editor controls scroll with
Save and Cancel kept visible. Buff tooltips and the completion banner use
readable text; a lower-screen completion banner wraps its buttons below it.
- Edge browser QA uses two Android/touch-emulated pages in one isolated offline
context. BroadcastChannel carries actual snapshots and touch commands between
pages. Covered login, empty/populated roster, both creation steps, main menu,
class hall, menu talents/keybinds/button assignments, reports, downloads,
dungeon/role selection, gameplay, pause, map, party, boss guide, spellbook,
talents, ability controls, inventory, boss cards, options, keybinds, issue
reporting, UI editor, defeat/respawn, completion/results, and combined preview.
- Verified lower action casting changes the upper combat cooldown; party frames
can move downstairs, hide, save, reload, and select the player remotely.
Buff inspection opens by touch. Results open automatically and can reopen
from the banner; finishing through the lower banner returns both pages to
the main menu. Completion uses an isolated presentation fixture, rather than
a live boss clear. Dungeon captures include equipped fallback bodies; this
review does not establish authentic race-model loading.
- All 42 layout, keybind, HUD-layering, display, snapshot, and dual-screen
regressions passed. A complete production build with asset audits and release
bundle validation passed; final UI edits also passed production compilation.
Browser captures have no unscrollable controls clipped outside their panel
and no uncaught UI errors. Physical hardware, Android WebView installation,
live online grouping, and shelved game modes were not exercised.
- Reproduce with `node scripts/playtest-thor-ui.cjs` while Vite runs at port
5176, or set `THOR_TEST_URL`. Evidence is in
`playtest-artifacts/thor-ui/results.json` and paired PNGs, including
`14-final-defaults`, `14d-results`, `14e-complete-bottom`, and
`15-combined-preview.png`.
## Boss Testing (2026-09-14)
- Main menu now opens a searchable boss catalog with game and readiness
filters, selected-boss fights, and random fights. Selecting a boss installs
its missing dungeon content pack and dependencies automatically.
- Testing uses an empty procedural room with one initial boss. Existing
combat, encounter phases, summoned adds, and relevant mechanic objects are
retained. Tests grant no experience, loot, or boss cards, and do not persist
changes to the character's progression or inventory.
- Pause and victory review offer Ready, Needs testing, and Not ready labels,
boss-specific reports, retry, and return to the main menu. Labels persist
through the server, with local pending saves retained while offline.
- Regression checks passed: 98 focused client tests, 19 server tests, and a
complete production build with asset audits and release bundle validation.
- Production Edge playtest passed selection, active Lady Anacondra mechanics,
all three readiness labels, report submission, retry, victory review,
persistence, random selection, and a 390-pixel mobile layout without
horizontal overflow. Victory uses deterministic damage through the combat
store; this is not a natural full-fight clear. Experience and saved character
data were unchanged, and no uncaught browser errors occurred.
- Selecting Taragaman the Hungerer from an uninstalled Ragefire Chasm pack
downloaded the pack and dependencies, then loaded the boss model and active
mechanics in the test room. A second browser page received the testing room
snapshot and opened boss review through the lower-display button. Physical
dual-screen hardware was not exercised.
- Reproduce with `node scripts/playtest-boss-testing.cjs`; the script starts
an isolated production server and test database. Evidence is in
`playtest-artifacts/boss-testing/results.json` and screenshots
`01-main-menu.png` through `12-bottom-display-review.png`.
@@ -139,6 +139,8 @@ public class ThorDualScreenPlugin extends Plugin implements DisplayManager.Displ
if (forceEnabled) scheduleEnsurePresentation(300L); if (forceEnabled) scheduleEnsurePresentation(300L);
}); });
presentation.show(); presentation.show();
bridge.getWebView().getSettings().setUseWideViewPort(true);
bridge.getWebView().getSettings().setLoadWithOverviewMode(true);
bridge.getWebView().loadUrl(currentUrl); bridge.getWebView().loadUrl(currentUrl);
} }
@@ -279,6 +281,8 @@ public class ThorDualScreenPlugin extends Plugin implements DisplayManager.Displ
WebSettings settings = webView.getSettings(); WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true); settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true); settings.setDomStorageEnabled(true);
settings.setUseWideViewPort(true);
settings.setLoadWithOverviewMode(true);
settings.setDatabaseEnabled(true); settings.setDatabaseEnabled(true);
settings.setMediaPlaybackRequiresUserGesture(false); settings.setMediaPlaybackRequiresUserGesture(false);
settings.setAllowFileAccess(true); settings.setAllowFileAccess(true);
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.42", "version": "0.1.43",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.42", "version": "0.1.43",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.42", "version": "0.1.43",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.42", "versionName": "0.1.43",
"versionCode": 1042 "versionCode": 1043
} }
+168
View File
@@ -0,0 +1,168 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const directory = path.resolve('playtest-artifacts/boss-testing');
fs.mkdirSync(directory, { recursive: true });
const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir: path.join(directory, 'test-database'), enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(4187, '127.0.0.1', resolve));
console.log('Started isolated production test server');
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
const page = await context.newPage();
page.setDefaultTimeout(60000);
const errors = [];
const assetFiles = fs.readdirSync(path.resolve('dist/assets'));
const mainModule = `/assets/${assetFiles.find((file) => /^index-.*\.js$/.test(file))}`;
const combatModule = `/assets/${assetFiles.find((file) => /^combatStore-.*\.js$/.test(file))}`;
await page.addInitScript(({ mainModule, combatModule }) => {
window.bossTestingStores = async () => {
const main = Object.values(await import(mainModule));
const combat = Object.values(await import(combatModule));
return {
useGameStore: main.find((value) => value?.getState && 'bossTest' in value.getState()),
useShellStore: main.find((value) => value?.getState && 'characters' in value.getState()),
useCombatStore: combat.find((value) => value?.getState && 'damageMob' in value.getState()),
};
};
}, { mainModule, combatModule });
page.on('pageerror', (error) => errors.push(error.message));
const capture = async (name) => {
await page.screenshot({ path: path.join(directory, `${name}.png`) });
console.log(`Captured ${name}`);
};
try {
await page.goto(base, { waitUntil: 'domcontentloaded' });
console.log('Production menu loaded');
await page.getByRole('button', { name: /Play offline/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('Bosstester');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue|Enter world/ }).first().click();
await page.locator('.main-menu-screen').waitFor();
await capture('01-main-menu');
await page.getByRole('button', { name: /Boss Testing/ }).click();
await page.getByRole('heading', { name: 'Boss Testing', exact: true }).waitFor();
const picker = page.getByLabel(/Choose a boss/);
const option = await picker.locator('option').evaluateAll((options) => options.find((entry) => /Lady Anacondra.*Wailing Caverns/.test(entry.textContent))?.value);
assert(option, 'Lady Anacondra is selectable');
await picker.selectOption(option);
await page.getByRole('button', { name: 'Test selected boss', exact: true }).waitFor({ state: 'visible' });
await capture('02-boss-selection');
await page.getByRole('button', { name: 'Test selected boss', exact: true }).click();
if (await page.getByRole('alert').count()) throw new Error(await page.getByRole('alert').innerText());
await page.locator('canvas').waitFor();
await page.waitForFunction(async () => {
const { useGameStore, useCombatStore } = await window.bossTestingStores();
const game = useGameStore.getState();
return game.assetStatus === 'ready' && game.bossTest?.runtimeIds.every((id) => useCombatStore.getState().mobs[id]);
}, null, { timeout: 90000 });
await page.waitForTimeout(4000);
await capture('03-empty-room');
const before = await page.evaluate(async () => {
const { useGameStore, useCombatStore, useShellStore } = await window.bossTestingStores();
const game = useGameStore.getState();
const combat = useCombatStore.getState();
const boss = game.bossTest;
return { boss, xp: combat.xp, saved: JSON.stringify(useShellStore.getState().characters), count: Object.keys(combat.mobs).length };
});
assert.equal(before.count, 1, 'Only the selected boss spawns initially');
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Boss readiness & report', exact: true }).click();
await page.getByRole('button', { name: 'Mark not ready', exact: true }).click();
await page.getByRole('status').filter({ hasText: 'Marked not ready.' }).waitFor();
await page.getByRole('button', { name: 'Mark needs testing', exact: true }).click();
await page.getByRole('status').filter({ hasText: 'Marked needs testing.' }).waitFor();
await capture('04-pause-review');
await page.getByRole('button', { name: 'Submit a boss report', exact: true }).click();
await page.getByLabel('Describe the issue', { exact: true }).fill('Boss Testing playtest: verify mechanic telegraphs.');
await page.getByRole('button', { name: 'Submit report', exact: true }).click();
await page.getByText('Report submitted.', { exact: false }).waitFor();
await capture('05-boss-report');
await page.getByRole('button', { name: 'Back to boss review', exact: true }).click();
await page.getByRole('button', { name: 'Fight this boss again', exact: true }).click();
await page.waitForTimeout(1000);
await page.evaluate(async () => {
const { useGameStore, useCombatStore } = await window.bossTestingStores();
const game = useGameStore.getState();
const combat = useCombatStore.getState();
combat.selectMob(game.bossTest.runtimeIds[0]);
combat.damageMob(game.bossTest.runtimeIds[0], 1000000, Date.now());
});
await page.getByText('Boss defeated', { exact: true }).waitFor();
await page.getByRole('button', { name: 'Mark ready', exact: true }).click();
await page.getByRole('status').filter({ hasText: 'Marked ready.' }).waitFor();
await capture('06-victory-review');
const after = await page.evaluate(async () => {
const { useCombatStore, useShellStore } = await window.bossTestingStores();
return { xp: useCombatStore.getState().xp, saved: JSON.stringify(useShellStore.getState().characters) };
});
assert.equal(after.xp, before.xp);
assert.equal(after.saved, before.saved);
const reports = runtime.database.listDungeonReports().reports;
assert.equal(reports[0].bossKey, before.boss.key);
assert.equal(runtime.database.listBossTestingReviews().reviews[0].status, 'ready');
await page.getByRole('button', { name: 'Return to main menu', exact: true }).click();
await page.locator('.main-menu-screen').waitFor();
await page.getByRole('button', { name: /Boss Testing/ }).click();
await picker.selectOption(option);
await page.getByRole('article').getByText('Ready', { exact: true }).waitFor();
await page.setViewportSize({ width: 390, height: 844 });
await capture('07-mobile-selection');
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'No mobile horizontal overflow');
await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByLabel('Search bosses').fill('Lady Anacondra');
await page.getByRole('button', { name: 'Fight a random boss', exact: true }).click();
await page.locator('canvas').waitFor();
await page.waitForTimeout(3000);
await capture('08-random-fight');
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Return to main menu', exact: true }).click();
await page.getByRole('button', { name: /Boss Testing/ }).click();
const missing = await picker.locator('option').evaluateAll((options) => options.find((entry) => /Taragaman the Hungerer.*Ragefire Chasm/.test(entry.textContent))?.value);
assert(missing, 'An uninstalled boss is selectable');
await picker.selectOption(missing);
await capture('09-automatic-download');
await page.waitForFunction(() => {
const button = [...document.querySelectorAll('button')].find((entry) => entry.textContent === 'Test selected boss');
const error = document.querySelector('[role="alert"]');
if (error) throw new Error(error.textContent);
return button && !button.disabled;
}, null, { timeout: 120000 });
const installed = await page.evaluate(() => JSON.parse(localStorage.getItem('healer-man.content-state.v2') || '{}'));
assert(installed.packs['dungeon:ragefire-chasm'], 'The selected boss pack downloads automatically');
await page.getByRole('button', { name: 'Test selected boss', exact: true }).click();
await page.waitForFunction(async () => {
const { useGameStore, useCombatStore } = await window.bossTestingStores();
const game = useGameStore.getState();
return game.assetStatus === 'ready' && game.bossTest?.runtimeIds.every((id) => useCombatStore.getState().mobs[id]);
}, null, { timeout: 90000 });
await page.waitForTimeout(1500);
await capture('10-downloaded-boss-room');
const bottom = await page.context().newPage();
bottom.on('pageerror', (error) => errors.push(error.message));
await bottom.setViewportSize({ width: 1240, height: 1080 });
await bottom.goto(`${base}/?display=bottom&nativeLayout=1`, { waitUntil: 'domcontentloaded' });
await bottom.getByRole('button', { name: 'Boss review', exact: true }).waitFor();
await bottom.screenshot({ path: path.join(directory, '11-bottom-display.png') });
await bottom.getByRole('button', { name: 'Boss review', exact: true }).click();
await page.getByRole('heading', { name: 'Taragaman the Hungerer', exact: true }).waitFor();
await capture('12-bottom-display-review');
await bottom.close();
assert.deepEqual(errors, [], 'No browser runtime errors');
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ passed: true, boss: before.boss, initialMobCount: before.count, xpUnchanged: true, profileUnchanged: true, automaticDownload: 'dungeon:ragefire-chasm', report: reports[0], errors }, null, 2)}\n`);
console.log('Boss Testing live flow passed');
} catch (error) {
await capture('failure');
console.log((await page.locator('body').innerText()).slice(-2000));
console.log('Browser errors:', errors);
throw error;
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+249
View File
@@ -0,0 +1,249 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const directory = path.resolve('playtest-artifacts/thor-ui');
fs.mkdirSync(directory, { recursive: true });
const base = process.env.THOR_TEST_URL || 'http://127.0.0.1:5176';
const results = [];
(async () => {
console.log('Launching Thor UI browser');
const browser = await chromium.launch({ channel: 'msedge', headless: true });
console.log('Browser launched');
const context = await browser.newContext({ hasTouch: true, userAgent: 'Mozilla/5.0 (Linux; Android 13; AYN Thor) AppleWebKit/537.36 Chrome/130.0.0.0 Mobile Safari/537.36' });
await context.addInitScript(() => performance.setResourceTimingBufferSize(20000));
const top = await context.newPage();
const bottom = await context.newPage();
console.log('Two pages ready');
const errors = [];
for (const [page, width, height, diagonal] of [[top, 1920, 1080, 6], [bottom, 1240, 1080, 3.92]]) {
const ppi = Math.hypot(width, height) / diagonal;
const viewport = { width: Math.round(width / ppi * 160), height: Math.round(height / ppi * 160) };
await page.setViewportSize(viewport);
const cdp = await context.newCDPSession(page);
await cdp.send('Emulation.setDeviceMetricsOverride', { ...viewport, deviceScaleFactor: ppi / 160, mobile: false });
console.log('Configured panel', viewport);
page.setDefaultTimeout(60000);
page.on('pageerror', (error) => errors.push(error.message));
}
const capture = async (name) => {
for (const [surface, page] of [['top', top], ['bottom', bottom]]) {
await page.screenshot({ path: path.join(directory, `${name}-${surface}.png`) });
const overflow = await page.evaluate(() => {
const root = document.querySelector('#root');
const buttons = [...document.querySelectorAll('button')].filter((node) => node.getBoundingClientRect().width && getComputedStyle(node).visibility !== 'hidden');
const clipped = buttons.filter((node) => {
const rect = node.getBoundingClientRect();
if (rect.right <= innerWidth + 1 && rect.left >= -1 && rect.bottom <= innerHeight + 1 && rect.top >= -1) return false;
for (let parent = node.parentElement; parent && parent !== root; parent = parent.parentElement) {
const css = getComputedStyle(parent);
if (/(auto|scroll)/.test(css.overflow + css.overflowY + css.overflowX)) return false;
}
return true;
});
return { width: innerWidth, height: innerHeight, clipped: clipped.map((node) => node.textContent.trim().slice(0, 60)) };
});
results.push({ name, surface, ...overflow });
}
console.log(`Captured ${name}`);
};
try {
console.log('Opening top');
await top.goto(`${base}/?display=top&nativeLayout=1`, { waitUntil: 'domcontentloaded' });
console.log('Top DOM ready');
await top.getByLabel('Username', { exact: true }).waitFor();
await bottom.goto(`${base}/?display=bottom&nativeLayout=1`, { waitUntil: 'domcontentloaded' });
await bottom.locator('.companion-panel').waitFor();
await capture('01-login');
await top.getByRole('button', { name: /Play offline/ }).click();
await top.locator('.character-select-screen').waitFor();
await capture('02-roster-empty');
await top.getByRole('button', { name: /Create character/ }).click();
await top.locator('.character-create-screen').waitFor();
await capture('03-character-create');
await top.getByRole('button', { name: /Customize & name/ }).tap();
await top.getByLabel('Character name').fill('Thortester');
await capture('03b-appearance');
await top.getByRole('button', { name: /Create character/ }).tap();
await top.locator('.character-select-screen').waitFor();
await capture('04-roster');
await top.bringToFront();
await top.waitForTimeout(2000);
await capture('04b-roster-rendered');
await top.getByRole('button', { name: /Continue|Enter world/ }).first().click();
await top.locator('.main-menu-screen').waitFor();
await capture('05-main-menu');
await top.getByRole('button', { name: /Class Hall/ }).tap();
await top.locator('.class-hall-screen').waitFor();
await capture('05b-class-hall');
await top.locator('.class-hall-header button').tap();
for (const name of ['Talents', 'Keybinds', 'Button assignments']) {
await top.getByRole('button', { name: new RegExp(name) }).tap();
await top.locator('.main-menu-character-editor').waitFor();
await top.locator('.main-menu-character-editor .game-panel').waitFor();
await capture(`05-editor-${name.toLowerCase().replaceAll(' ', '-')}`);
await top.getByRole('button', { name: 'Back to main menu', exact: true }).tap();
}
await top.getByRole('button', { name: /View Reports/ }).tap();
await top.getByRole('heading', { name: 'View Reports' }).waitFor();
await capture('05c-reports');
await top.getByRole('button', { name: /Back/ }).first().tap();
await top.getByRole('button', { name: /Downloads/ }).tap();
await top.locator('.content-manager-screen').waitFor();
await capture('05d-downloads');
await top.getByRole('button', { name: 'Back', exact: true }).tap();
await top.getByRole('button', { name: /^Dungeons/ }).click();
await top.locator('.dungeon-select-screen').waitFor();
await capture('06-dungeon-picker');
await top.getByRole('button', { name: /Choose role/ }).click();
await capture('07-role-picker');
await top.locator('.dungeon-role-option').first().click();
await top.locator('.game-shell').waitFor();
await bottom.locator('.companion-hud-shell').waitFor();
await top.waitForFunction(async () => { const { useGameStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/store.ts').name); return ['ready', 'proxy', 'error'].includes(useGameStore.getState().assetStatus); }, null, { timeout: 120000 });
await top.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
await top.waitForTimeout(1500);
await capture('08-dungeon');
assert.equal(await bottom.locator('[data-hud-element="party"]').count(), 0);
assert.equal(await top.locator('[data-hud-element="actions"]').count(), 0);
assert.equal(await top.locator('[data-hud-element="player"]').count(), 0);
assert.equal(await bottom.locator('.action-bar').count(), 1);
assert.equal(await bottom.locator('.player-status-panel').count(), 1);
await bottom.locator('.aura--inspectable').first().tap();
await bottom.getByRole('tooltip').waitFor();
await capture('08b-buff-inspection');
await bottom.getByRole('button', { name: /^Fade,/ }).tap();
await top.waitForFunction(async () => {
const { useCombatStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/combatStore.ts').name);
return Object.values(useCombatStore.getState().cooldowns).some((endsAt) => endsAt > Date.now());
});
for (const overlay of ['pause', 'map', 'party', 'boss-guide', 'spellbook', 'talents', 'bindings', 'inventory', 'boss-cards', 'options', 'keybinds', 'report-issue']) {
await top.evaluate(async (overlay) => { const { useGameStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/store.ts').name); useGameStore.getState().openOverlay(overlay); }, overlay);
await top.waitForTimeout(500);
await capture(`09-${overlay}`);
}
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
await top.locator('.ui-layout-editor').waitFor();
assert.equal(await top.getByLabel('Party frames screen').inputValue(), 'top');
assert.equal(await top.getByLabel('Buffs and debuffs screen').inputValue(), 'bottom');
await top.getByLabel('Party frames screen').selectOption('bottom');
await bottom.getByRole('button', { name: 'Move Party frames', exact: true }).waitFor();
assert.equal(await top.locator('[data-hud-element="party"]').count(), 0);
await capture('10-editor-party-bottom');
const handle = bottom.getByRole('button', { name: 'Move Party frames', exact: true });
const rect = await handle.boundingBox();
const cdp = await context.newCDPSession(bottom);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: rect.x + 10, y: rect.y + 10 }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: rect.x + 90, y: rect.y + 60 }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await top.waitForFunction(async () => {
const { useUiLayoutStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/uiLayoutStore.ts').name);
return useUiLayoutStore.getState().draft.elements.party.position !== null;
});
await top.getByLabel('Party frames screen').selectOption('hidden');
await bottom.waitForFunction(() => !document.querySelector('[data-hud-element="party"]'));
await top.getByLabel('Party frames screen').selectOption('bottom');
await top.getByRole('button', { name: 'Cancel edits', exact: true }).tap();
await top.locator('.ui-layout-editor').waitFor({ state: 'hidden' });
await bottom.waitForFunction(() => !document.querySelector('[data-hud-element="party"]'));
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
await top.getByLabel('Party frames screen').selectOption('bottom');
await capture('11-editor');
await top.getByRole('button', { name: 'Save UI', exact: true }).tap();
await top.waitForFunction(() => !document.querySelector('.ui-layout-editor'));
await capture('12-saved');
await top.reload({ waitUntil: 'domcontentloaded' });
await top.locator('.character-select-screen').waitFor();
await top.getByRole('button', { name: /Continue/ }).tap();
await top.getByRole('button', { name: /^Dungeons/ }).tap();
await top.getByRole('button', { name: /Choose role/ }).tap();
await top.locator('.dungeon-role-option').first().tap();
await bottom.locator('[data-hud-element="party"] .party-frame').first().waitFor();
assert.equal(await top.locator('[data-hud-element="party"]').count(), 0);
await bottom.locator('.party-frame--player').tap();
await top.waitForFunction(async () => {
const { usePartyStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/partyStore.ts').name);
return usePartyStore.getState().selfSelected;
});
const partyBounds = await bottom.locator('.party-frames').boundingBox();
const menuBounds = await bottom.locator('.companion-hud-menus').boundingBox();
assert.ok(partyBounds.y + partyBounds.height <= menuBounds.y, 'Relocated party frames must fit above the bottom navigation');
await capture('13-persisted-party-bottom');
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
await top.getByRole('button', { name: 'Reset all elements', exact: true }).tap();
await top.getByRole('button', { name: 'Save UI', exact: true }).tap();
await capture('14-final-defaults');
await top.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
await top.evaluate(async () => {
const load = (pathname) => import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === pathname).name);
const { useGameStore } = await load('/src/game/store.ts');
const { useCombatStore } = await load('/src/game/combatStore.ts');
useGameStore.setState({ paused: true, overlay: null });
useCombatStore.setState({ health: 0 });
});
await top.locator('.defeated-prompt').waitFor();
assert.equal(await bottom.locator('.defeated-prompt').count(), 0);
await capture('14b-defeated');
await top.getByRole('button', { name: 'Respawn at entrance', exact: true }).tap();
await top.locator('.defeated-prompt').waitFor({ state: 'hidden' });
// Isolated offline presentation fixture: exercise the completed-run UI without a live boss clear.
await top.evaluate(async () => {
const load = (pathname) => import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === pathname).name);
const { useGameStore } = await load('/src/game/store.ts');
const { useDungeonRunStore } = await load('/src/game/dungeonRunStore.ts');
const { usePartyStore } = await load('/src/game/partyStore.ts');
const { useCombatStore } = await load('/src/game/combatStore.ts');
const progress = useDungeonRunStore.getState().progress;
const now = performance.now();
const party = [{ name: 'Thortester', classId: useCombatStore.getState().classId, level: 1, role: 'healer', npc: false },
...usePartyStore.getState().members.map((member) => ({ name: member.name, classId: member.classId, level: member.level, role: member.role, npc: true }))];
useGameStore.setState({ paused: true, overlay: null });
useDungeonRunStore.setState({ progress: { ...progress, startedAt: now - 60000, finishedAt: now, durationMs: 60000, party } });
});
await top.locator('.dungeon-complete-banner').waitFor();
assert.equal(await bottom.locator('.dungeon-complete-banner').count(), 0);
await capture('14c-complete');
await top.locator('.dungeon-results').waitFor();
await capture('14d-results');
await top.getByRole('button', { name: /Stay and loot/ }).tap();
await top.getByRole('button', { name: 'Run results', exact: true }).tap();
await top.locator('.dungeon-results').waitFor();
await top.getByRole('button', { name: /Stay and loot/ }).tap();
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
await top.getByLabel('Dungeon completion screen').selectOption('bottom');
await top.getByRole('button', { name: 'Save UI', exact: true }).tap();
await bottom.locator('.dungeon-complete-banner').waitFor();
assert.equal(await top.locator('.dungeon-complete-banner').count(), 0);
await capture('14e-complete-bottom');
await bottom.getByRole('button', { name: 'Finish dungeon', exact: true }).tap();
await top.locator('.main-menu-screen').waitFor();
await top.evaluate(async () => {
const { useUiLayoutStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/uiLayoutStore.ts').name);
const state = useUiLayoutStore.getState(); state.begin(); state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, completion: { visible: true, position: null, width: null } } })); state.finish(true);
});
const previewCdp = await context.newCDPSession(top);
await top.setViewportSize({ width: 1200, height: 1100 });
await previewCdp.send('Emulation.setDeviceMetricsOverride', { width: 1200, height: 1100, deviceScaleFactor: 1, mobile: false });
await top.goto(`${base}/?layout=thor-preview`, { waitUntil: 'domcontentloaded' });
await top.getByRole('button', { name: /Continue/ }).first().tap();
await top.locator('.thor-preview-top').getByRole('button', { name: /^Dungeons/ }).tap();
await top.locator('.thor-preview-top').getByRole('button', { name: /Choose role/ }).tap();
await top.locator('.thor-preview-top .dungeon-role-option').first().tap();
await top.locator('.thor-preview-top .loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
await top.waitForTimeout(1000);
assert.equal((await top.locator('.thor-preview-top').boundingBox()).width, 837);
assert.equal((await top.locator('.thor-preview-bottom').boundingBox()).width, 473);
assert.equal(await top.locator('.thor-preview-bottom [data-hud-element="party"]').count(), 0);
assert.equal(await top.locator('.thor-preview-top .action-bar').count(), 0);
await top.screenshot({ path: path.join(directory, '15-combined-preview.png'), fullPage: true });
results.push({ name: '15-combined-preview', surface: 'both', clipped: [] });
assert.deepEqual(results.filter((result) => result.clipped.length), [], 'Controls must fit each screen or an intentional scroll region');
assert.equal(errors.length, 0, errors.join('\n'));
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ results, errors }, null, 2)}\n`);
} finally {
console.log('Browser errors', errors);
await top.screenshot({ path: path.join(directory, 'last-top.png') });
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ results, errors }, null, 2)}\n`);
await browser.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+43 -3
View File
@@ -19,11 +19,22 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
revision INTEGER NOT NULL DEFAULT 0 revision INTEGER NOT NULL DEFAULT 0
) STRICT; ) STRICT;
CREATE INDEX IF NOT EXISTS dungeon_reports_dungeon_status_idx ON dungeon_reports(dungeon_id, status); CREATE INDEX IF NOT EXISTS dungeon_reports_dungeon_status_idx ON dungeon_reports(dungeon_id, status);
CREATE TABLE IF NOT EXISTS boss_testing_reviews (
boss_key TEXT PRIMARY KEY,
dungeon_id TEXT NOT NULL,
boss_name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('ready', 'needs-testing', 'not-ready')),
reviewed_by TEXT NOT NULL,
reviewed_at INTEGER NOT NULL
) STRICT;
`); `);
const columns = new Set(database.prepare("PRAGMA table_info(dungeon_reports)").all().map((column) => column.name));
if (!columns.has("boss_key")) database.exec("ALTER TABLE dungeon_reports ADD COLUMN boss_key TEXT");
if (!columns.has("boss_name")) database.exec("ALTER TABLE dungeon_reports ADD COLUMN boss_name TEXT");
const insert = database.prepare(` const insert = database.prepare(`
INSERT INTO dungeon_reports ( INSERT INTO dungeon_reports (
id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at, boss_key, boss_name
) VALUES (?, ?, ?, 'Submitted', ?, ?, ?, ?) ) VALUES (?, ?, ?, 'Submitted', ?, ?, ?, ?, ?, ?)
`); `);
const byId = database.prepare("SELECT * FROM dungeon_reports WHERE id = ?"); const byId = database.prepare("SELECT * FROM dungeon_reports WHERE id = ?");
const list = database.prepare(` const list = database.prepare(`
@@ -45,6 +56,14 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
function validateStatus(status) { function validateStatus(status) {
if (!policy.statuses.includes(status)) throw new ErrorType("Choose a valid report status.", 400, "invalid_report_status"); if (!policy.statuses.includes(status)) throw new ErrorType("Choose a valid report status.", 400, "invalid_report_status");
} }
function validateBoss(input) {
validateDungeon(input?.dungeonId);
if (typeof input.bossKey !== "string" || !input.bossKey.startsWith(`${input.dungeonId}/`)
|| input.bossKey.length <= input.dungeonId.length + 1 || input.bossKey.length > 256
|| typeof input.bossName !== "string" || !input.bossName.trim() || input.bossName.length > 128) {
throw new ErrorType("Choose a valid boss.", 400, "invalid_boss");
}
}
function publicReport(row) { function publicReport(row) {
return { return {
id: row.id, id: row.id,
@@ -56,10 +75,30 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
updatedAt: Number(row.updated_at), updatedAt: Number(row.updated_at),
updatedBy: row.updated_by_username, updatedBy: row.updated_by_username,
revision: Number(row.revision), revision: Number(row.revision),
...(row.boss_key ? { bossKey: row.boss_key, bossName: row.boss_name } : {}),
}; };
} }
return { return {
listBossTestingReviews() {
return { reviews: database.prepare("SELECT * FROM boss_testing_reviews ORDER BY boss_key").all().map((row) => ({
bossKey: row.boss_key, dungeonId: row.dungeon_id, bossName: row.boss_name,
status: row.status, reviewedBy: row.reviewed_by, reviewedAt: Number(row.reviewed_at),
})) };
},
saveBossTestingReview(account, input) {
validateBoss(input);
if (!["ready", "needs-testing", "not-ready"].includes(input.status)) {
throw new ErrorType("Choose a valid boss readiness status.", 400, "invalid_boss_status");
}
const review = { bossKey: input.bossKey, dungeonId: input.dungeonId, bossName: input.bossName.trim(),
status: input.status, reviewedBy: account?.username ?? "Guest player", reviewedAt: Date.now() };
database.prepare(`INSERT INTO boss_testing_reviews VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(boss_key) DO UPDATE SET boss_name = excluded.boss_name, status = excluded.status,
reviewed_by = excluded.reviewed_by, reviewed_at = excluded.reviewed_at`).run(
review.bossKey, review.dungeonId, review.bossName, review.status, review.reviewedBy, review.reviewedAt);
return review;
},
listDungeonReports({ status = null, dungeonId = null } = {}) { listDungeonReports({ status = null, dungeonId = null } = {}) {
if (status !== null) validateStatus(status); if (status !== null) validateStatus(status);
if (dungeonId !== null) validateDungeon(dungeonId); if (dungeonId !== null) validateDungeon(dungeonId);
@@ -70,13 +109,14 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
}, },
submitDungeonReport(account, input) { submitDungeonReport(account, input) {
validateDungeon(input?.dungeonId); validateDungeon(input?.dungeonId);
if (input?.bossKey !== undefined || input?.bossName !== undefined) validateBoss(input);
if (typeof input?.comment !== "string" || !input.comment.trim() || input.comment.length > policy.commentMaxLength) { if (typeof input?.comment !== "string" || !input.comment.trim() || input.comment.length > policy.commentMaxLength) {
throw new ErrorType("Describe the issue in 1-500 characters.", 400, "invalid_report_comment"); throw new ErrorType("Describe the issue in 1-500 characters.", 400, "invalid_report_comment");
} }
const id = randomUUID(); const id = randomUUID();
const now = Date.now(); const now = Date.now();
insert.run(id, input.dungeonId, input.comment.replace(/\r\n?/g, "\n").trim(), account?.id ?? null, insert.run(id, input.dungeonId, input.comment.replace(/\r\n?/g, "\n").trim(), account?.id ?? null,
account?.username ?? "Guest player", now, now); account?.username ?? "Guest player", now, now, input.bossKey ?? null, input.bossName?.trim() ?? null);
return publicReport(byId.get(id)); return publicReport(byId.get(id));
}, },
updateDungeonReport(account, id, input) { updateDungeonReport(account, id, input) {
+29
View File
@@ -69,6 +69,30 @@ test("persists reports and status changes across database restarts", async () =>
} finally { database?.close(); rmSync(directory, { recursive: true, force: true }); } } finally { database?.close(); rmSync(directory, { recursive: true, force: true }); }
}); });
test("persists boss readiness and structured boss reports without changing ordinary reports", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-boss-feedback-"));
let database;
try {
database = openGameDatabase({ dataDir: directory });
const boss = { dungeonId: "wailing-caverns", bossKey: "wailing-caverns/lady-anacondra", bossName: "Lady Anacondra" };
for (const status of ["ready", "needs-testing", "not-ready"]) {
assert.equal(database.saveBossTestingReview(null, { ...boss, status }).status, status);
}
assert.throws(() => database.saveBossTestingReview(null, { ...boss, status: "Closed" }), { code: "invalid_boss_status" });
assert.throws(() => database.saveBossTestingReview(null, { ...boss, bossKey: "other/boss", status: "ready" }), { code: "invalid_boss" });
const report = database.submitDungeonReport(null, { ...boss, comment: "x".repeat(500) });
assert.equal(report.bossKey, boss.bossKey);
assert.equal(report.bossName, boss.bossName);
const reviews = database.listBossTestingReviews();
database.close();
database = openGameDatabase({ dataDir: directory });
assert.deepEqual(database.listBossTestingReviews(), reviews);
assert.deepEqual(database.listDungeonReports().reports, [report]);
const ordinary = database.submitDungeonReport(null, { dungeonId: boss.dungeonId, comment: "Door issue" });
assert.equal(ordinary.bossKey, undefined);
} finally { database?.close(); rmSync(directory, { recursive: true, force: true }); }
});
test("serves public reports and GM-only status API with shelved modes disabled", async () => { test("serves public reports and GM-only status API with shelved modes disabled", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-api-")); const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-api-"));
const runtime = createGameServer({ dataDir: directory, enableShelvedModes: false }); const runtime = createGameServer({ dataDir: directory, enableShelvedModes: false });
@@ -80,6 +104,11 @@ test("serves public reports and GM-only status API with shelved modes disabled",
}); });
try { try {
const player = await runtime.database.register("ApiPlayer", "report-player"); const player = await runtime.database.register("ApiPlayer", "report-player");
const boss = { dungeonId: "wailing-caverns", bossKey: "wailing-caverns/lady-anacondra", bossName: "Lady Anacondra", status: "ready" };
const saved = await fetch(`${origin}/api/boss-testing/reviews`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(boss) });
assert.equal(saved.status, 200);
const labels = await (await fetch(`${origin}/api/boss-testing/reviews`)).json();
assert.equal(labels.reviews[0].status, "ready");
const gm = await runtime.database.register("Phenom", "report-admin"); const gm = await runtime.database.register("Phenom", "report-admin");
const created = await request("", "POST", { dungeonId: "stormwind-stockade", comment: "Issue via API" }, player.token); const created = await request("", "POST", { dungeonId: "stormwind-stockade", comment: "Issue via API" }, player.token);
assert.equal(created.status, 201); assert.equal(created.status, 201);
+13
View File
@@ -261,6 +261,19 @@ export function createGameServer(options = {}) {
json(response, 200, gameDatabase.getDungeonRun(dungeonRunMatch[1])); json(response, 200, gameDatabase.getDungeonRun(dungeonRunMatch[1]));
return; return;
} }
if (url.pathname === "/api/boss-testing/reviews" && request.method === "GET") {
json(response, 200, gameDatabase.listBossTestingReviews());
return;
}
if (url.pathname === "/api/boss-testing/reviews" && request.method === "POST") {
const token = bearerToken(request);
const account = token ? gameDatabase.authenticate(token) : null;
if (!allowReportRequest(account?.id ?? request.socket.remoteAddress)) {
throw new GameDatabaseError("Too many updates. Please wait a minute and try again.", 429, "rate_limited");
}
json(response, 200, { review: gameDatabase.saveBossTestingReview(account, await readJson(request, 8 * 1024)) });
return;
}
if (url.pathname === "/api/dungeon-reports" && request.method === "GET") { if (url.pathname === "/api/dungeon-reports" && request.method === "GET") {
json(response, 200, gameDatabase.listDungeonReports({ json(response, 200, gameDatabase.listDungeonReports({
status: url.searchParams.get("status"), status: url.searchParams.get("status"),
+11 -5
View File
@@ -1,4 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useShellStore } from "./app/shellStore";
import "./ui/uiLayout.css"; import "./ui/uiLayout.css";
import { DualDisplayFrame } from "./components/DualDisplayFrame"; import { DualDisplayFrame } from "./components/DualDisplayFrame";
import { installInput, releasePointerLock } from "./game/inputManager"; import { installInput, releasePointerLock } from "./game/inputManager";
@@ -7,7 +8,8 @@ import { connectDpsMeter } from "./game/dpsMeterStore";
import { useCombatStore } from "./game/combatStore"; import { useCombatStore } from "./game/combatStore";
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache"; import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset"; 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 { PartyBridge } from "./game/PartyBridge";
import { usePartyStore } from "./game/partyStore"; import { usePartyStore } from "./game/partyStore";
import { useGameStore } from "./game/store"; import { useGameStore } from "./game/store";
@@ -24,9 +26,7 @@ import { DungeonRunBridge } from "./game/DungeonRunBridge";
import { DungeonRunResults } from "./ui/DungeonRunResults"; import { DungeonRunResults } from "./ui/DungeonRunResults";
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge"; import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction"; import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
import { UiLayoutEditor } from "./ui/UiLayoutEditor"; import { UiLayoutEditor } from "./ui/UiLayoutEditor";
import { EditableHudElement } from "./ui/EditableHudElement";
import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore"; import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore";
import { useUiLayoutStore } from "./game/uiLayoutStore"; import { useUiLayoutStore } from "./game/uiLayoutStore";
import { profileInputBindings } from "./game/keybindPreferences"; import { profileInputBindings } from "./game/keybindPreferences";
@@ -39,7 +39,7 @@ function InputBridge() {
useEffect(() => installInput({ useEffect(() => installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars, 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) => { getBindings: (device) => {
const preferences = useUiLayoutStore.getState().preferences; const preferences = useUiLayoutStore.getState().preferences;
const bindings = profileInputBindings(preferences, device); const bindings = profileInputBindings(preferences, device);
@@ -103,6 +103,12 @@ export default function GameRuntime() {
clearDungeonRuntimeCaches(); clearDungeonRuntimeCaches();
releaseDungeonGltfCache(activeDungeon); releaseDungeonGltfCache(activeDungeon);
releaseShelvedGameRuntime(); releaseShelvedGameRuntime();
const sourceDungeonId = bossTestingSourceDungeonId(activeDungeon.id);
if (sourceDungeonId) {
useGameStore.setState({ activeDungeonId: sourceDungeonId, bossTest: null, gmTestMode: false });
useShellStore.setState({ activeDungeonId: sourceDungeonId });
}
clearBossTestingDungeons();
}, []); }, []);
return ( return (
<DualDisplayFrame <DualDisplayFrame
@@ -118,10 +124,10 @@ export default function GameRuntime() {
<CombatBridge /> <CombatBridge />
<PartyBridge /> <PartyBridge />
<DungeonRunBridge /> <DungeonRunBridge />
<BossTestingBridge />
<ShelvedGameBridges /> <ShelvedGameBridges />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary> <SceneErrorBoundary><GameScene /></SceneErrorBoundary>
<Hud /> <Hud />
<EditableHudElement id="interaction"><DungeonInteractionPrompt /></EditableHudElement>
<MapPanel /> <MapPanel />
<GameplayPanels /> <GameplayPanels />
<PauseMenu /> <PauseMenu />
+49 -6
View File
@@ -5,6 +5,10 @@ import {
createLocalAccount, createLocalAccount,
createOfflineSession, createOfflineSession,
listCharacters, listCharacters,
loadAccountBossCards,
saveAccountBossCards,
replaceCharactersForOwner,
deleteCharacter,
loadCombatPresentationSettings, loadCombatPresentationSettings,
loginLocalAccount, loginLocalAccount,
restoreSession, restoreSession,
@@ -12,7 +16,6 @@ import {
updateCharacterEquipment, updateCharacterEquipment,
updateCharacterDungeonRunCheckpoint, updateCharacterDungeonRunCheckpoint,
updateCharacterInventory, updateCharacterInventory,
updateCharacterBossCards,
updateCharacterManastormProgress, updateCharacterManastormProgress,
updateCharacterProgression, updateCharacterProgression,
updateCharacterSettings, updateCharacterSettings,
@@ -213,7 +216,7 @@ describe("local profile repository", () => {
})); }));
expect(loadCombatPresentationSettings("offline-roster", storage)) expect(loadCombatPresentationSettings("offline-roster", storage))
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS); .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(); 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 storage = new MemoryStorage();
const offline = createOfflineSession(); 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 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 character = createCharacter(draft, storage).character!;
const other = createCharacter({ ...draft, name: "Other" }, storage).character!; const other = createCharacter({ ...draft, name: "Other" }, storage).character!;
const bossCards = { counts: { "wow:lady anacondra": { normal: 7, epic: 1 } }, lastClaims: { spawn: "kill:7" } }; 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); const roster = listCharacters(offline.ownerId, storage);
expect(roster.find((entry) => entry.id === character.id)?.bossCards).toEqual(bossCards); expect(roster.find((entry) => entry.id === character.id)?.bossCards).toBeUndefined();
expect(roster.find((entry) => entry.id === other.id)?.bossCards).toEqual({ counts: {}, lastClaims: {} }); expect(roster.find((entry) => entry.id === other.id)?.bossCards).toBeUndefined();
expect(roster.find((entry) => entry.id === character.id)?.inventory).toEqual([]); 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", () => { it("persists threat-meter visibility and position per character", () => {
+50 -33
View File
@@ -1,5 +1,5 @@
import type { CharacterProfile, PlayerSession } from "./types"; 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 { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
import { import {
contentCategoryForClass, contentCategoryForClass,
@@ -62,9 +62,10 @@ interface LocalAccount {
} }
interface ProfileDatabase { interface ProfileDatabase {
version: 6; version: 7;
accounts: LocalAccount[]; accounts: LocalAccount[];
rosters: Record<string, CharacterProfile[]>; rosters: Record<string, CharacterProfile[]>;
bossCardsByOwner: Record<string, BossCardCollection>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>; presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
uiPreferencesByOwner?: Record<string, UiPreferences>; uiPreferencesByOwner?: Record<string, UiPreferences>;
} }
@@ -105,7 +106,7 @@ function browserSessionStorage(): StorageLike | null {
} }
function emptyDatabase(): ProfileDatabase { function emptyDatabase(): ProfileDatabase {
return { version: 6, accounts: [], rosters: {}, presentationSettingsByOwner: {} }; return { version: 7, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} };
} }
function isCharacterProfile(value: unknown): value is CharacterProfile { function isCharacterProfile(value: unknown): value is CharacterProfile {
@@ -121,6 +122,9 @@ function isCharacterProfile(value: unknown): value is CharacterProfile {
} }
function normalizeCharacterProfile(value: CharacterProfile): 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 & { const legacy = value as CharacterProfile & {
actionBindings?: unknown; actionBindings?: unknown;
experience?: unknown; experience?: unknown;
@@ -180,7 +184,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
: undefined; : undefined;
const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint); const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint);
return { return {
...value, ...profile,
level, level,
experience, experience,
talentRanks, talentRanks,
@@ -191,7 +195,6 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
actionLoadouts, actionLoadouts,
inventory, inventory,
equipment, equipment,
bossCards: normalizeBossCardCollection(value.bossCards),
manastormProgress, manastormProgress,
dungeonRunCheckpoint, dungeonRunCheckpoint,
settings, settings,
@@ -205,10 +208,11 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
version?: number; version?: number;
accounts?: unknown; accounts?: unknown;
rosters?: unknown; rosters?: unknown;
bossCardsByOwner?: unknown;
presentationSettingsByOwner?: unknown; presentationSettingsByOwner?: unknown;
uiPreferencesByOwner?: unknown; uiPreferencesByOwner?: unknown;
}) | null; }) | 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(); return emptyDatabase();
} }
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean( 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.salt === "string"
&& typeof account.verifier.digest === "string", && typeof account.verifier.digest === "string",
)); ));
const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record<string, unknown>).map(([ownerId, roster]) => [ const rawCards = parsed.bossCardsByOwner && typeof parsed.bossCardsByOwner === "object" && !Array.isArray(parsed.bossCardsByOwner)
ownerId, ? parsed.bossCardsByOwner : {};
Array.isArray(roster) const bossCardsByOwner = Object.fromEntries(Object.entries(rawCards).map(([owner, cards]) => [owner, normalizeBossCardCollection(cards)]));
? roster.filter(isCharacterProfile).map(normalizeCharacterProfile).slice(0, MAX_CHARACTERS) 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 const rawPresentationSettings = parsed.presentationSettingsByOwner
&& typeof parsed.presentationSettingsByOwner === "object" && typeof parsed.presentationSettingsByOwner === "object"
&& !Array.isArray(parsed.presentationSettingsByOwner) && !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 rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)])); const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
const database: ProfileDatabase = { version: 6, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner }; const database: ProfileDatabase = { version: 7, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
if (parsed.version !== 6 || presentationSettingsMigrated) writeDatabase(database, storage); if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated) writeDatabase(database, storage);
return database; return database;
} catch { } catch {
return emptyDatabase(); return emptyDatabase();
@@ -270,7 +281,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void { function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void {
storage?.setItem(DATABASE_KEY, JSON.stringify(database)); storage?.setItem(DATABASE_KEY, JSON.stringify(database));
if (!storage || !rosterChangeListener) return; 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] ?? []); rosterChangeListener(ownerId, database.rosters[ownerId] ?? []);
} }
} }
@@ -452,15 +463,19 @@ export function replaceCharactersForOwner(
ownerId: string, ownerId: string,
characters: unknown, characters: unknown,
storage: StorageLike | null = browserLocalStorage(), storage: StorageLike | null = browserLocalStorage(),
accountBossCards?: unknown,
): CharacterProfile[] { ): CharacterProfile[] {
if (!storage) return []; if (!storage) return [];
const database = readDatabase(storage); const database = readDatabase(storage);
database.rosters[ownerId] = Array.isArray(characters) const roster = Array.isArray(characters) ? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : [];
? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS).map((character) => normalizeCharacterProfile({ const importedCards = accountBossCards === undefined
...character, ? mergeBossCardCollections(roster.map((character) => character.bossCards), "sum")
ownerId, : normalizeBossCardCollection(accountBossCards);
})) database.bossCardsByOwner[ownerId] = mergeBossCardCollections([database.bossCardsByOwner[ownerId], importedCards]);
: []; database.rosters[ownerId] = roster.map((character) => normalizeCharacterProfile({
...character,
ownerId,
}));
writeDatabase(database, storage); writeDatabase(database, storage);
return listCharacters(ownerId, storage); return listCharacters(ownerId, storage);
} }
@@ -730,22 +745,24 @@ export function updateCharacterEquipment(
return updated; 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, ownerId: string,
characterId: string,
bossCards: BossCardCollection, bossCards: BossCardCollection,
storage: StorageLike | null = browserLocalStorage(), storage: StorageLike | null = browserLocalStorage(),
): CharacterProfile | null { ): BossCardCollection {
if (!storage) return null; const normalized = normalizeBossCardCollection(bossCards);
if (!storage) return normalized;
const database = readDatabase(storage); const database = readDatabase(storage);
let updated: CharacterProfile | null = null; database.bossCardsByOwner[ownerId] = normalized;
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { writeDatabase(database, storage);
if (character.id !== characterId) return character; return normalized;
updated = normalizeCharacterProfile({ ...character, bossCards });
return updated;
});
if (updated) writeDatabase(database, storage);
return updated;
} }
export function updateCharacterSettings( 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 updatedAt: number;
readonly updatedBy: string | null; readonly updatedBy: string | null;
readonly revision: number; readonly revision: number;
readonly bossKey?: string;
readonly bossName?: string;
} }
export interface DungeonReportSnapshot { export interface DungeonReportSnapshot {
@@ -40,10 +42,11 @@ export function fetchDungeonReports(): Promise<DungeonReportSnapshot> {
return requestOnlineJson<DungeonReportSnapshot>("/api/dungeon-reports"); 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", { const response = await requestOnlineJson<{ report: DungeonReport }>("/api/dungeon-reports", {
method: "POST", method: "POST",
body: JSON.stringify({ dungeonId, comment }), body: JSON.stringify({ dungeonId, comment, ...boss }),
}, session?.kind === "account" ? session.accessToken : undefined); }, session?.kind === "account" ? session.accessToken : undefined);
return response.report; 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, replaceCharactersForOwner,
setRosterChangeListener, setRosterChangeListener,
loadUiPreferences, loadUiPreferences,
loadAccountBossCards,
saveUiPreferences, saveUiPreferences,
type AccountResult, type AccountResult,
} from "./accountRepository"; } from "./accountRepository";
@@ -23,7 +24,7 @@ interface AuthResponse {
interface CloudSaveResponse { interface CloudSaveResponse {
revision: number; revision: number;
updatedAt: number | null; updatedAt: number | null;
data: { version?: number; characters?: unknown; uiPreferences?: unknown }; data: { version?: number; characters?: unknown; uiPreferences?: unknown; bossCards?: unknown };
} }
interface MeResponse { interface MeResponse {
@@ -106,11 +107,12 @@ function flushCloudSave(): void {
pendingCharacters = null; pendingCharacters = null;
if (!session?.accessToken || !characters) return; if (!session?.accessToken || !characters) return;
const uiPreferences = loadUiPreferences(session.ownerId); const uiPreferences = loadUiPreferences(session.ownerId);
const bossCards = loadAccountBossCards(session.ownerId);
const sequence = saveSequence; const sequence = saveSequence;
saveQueue = saveQueue.then(async () => { saveQueue = saveQueue.then(async () => {
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", { await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
method: "PUT", method: "PUT",
body: JSON.stringify({ data: { version: 1, characters, uiPreferences } }), body: JSON.stringify({ data: { version: 2, characters, uiPreferences, bossCards } }),
}, session.accessToken); }, session.accessToken);
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." }); if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." });
}).catch((error) => { }).catch((error) => {
@@ -149,12 +151,18 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise<Char
activateOnlineSession(session); activateOnlineSession(session);
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken); const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
suppressUpload = true; suppressUpload = true;
let characters: CharacterProfile[];
try { try {
saveUiPreferences(session.ownerId, cloud.data?.uiPreferences); 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 { } finally {
suppressUpload = false; 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> { 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" }, "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", () => { 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, createCharacter as persistCharacter,
deleteCharacter as removeCharacter, deleteCharacter as removeCharacter,
listCharacters, listCharacters,
loadAccountBossCards,
loadCombatPresentationSettings, loadCombatPresentationSettings,
restoreSession as restoreStoredSession, restoreSession as restoreStoredSession,
saveSession, saveSession,
updateCharacterActionBindings, updateCharacterActionBindings,
updateCharacterEquipment, updateCharacterEquipment,
updateCharacterInventory, updateCharacterInventory,
updateCharacterBossCards, saveAccountBossCards as persistAccountBossCards,
updateCharacterManastormProgress, updateCharacterManastormProgress,
updateCharacterDungeonRunCheckpoint, updateCharacterDungeonRunCheckpoint,
updateCharacterProgression, updateCharacterProgression,
@@ -39,7 +40,7 @@ import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from
import type { DungeonId } from "../game/dungeonRegistry"; import type { DungeonId } from "../game/dungeonRegistry";
import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults"; import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults";
import type { InventoryItem } from "../game/lootTypes"; 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 { EquipmentAssignments } from "../game/equipment";
import type { GameplaySettings } from "../game/combatStore"; import type { GameplaySettings } from "../game/combatStore";
import { import {
@@ -70,6 +71,7 @@ export interface ShellState {
phase: ShellPhase; phase: ShellPhase;
session: PlayerSession | null; session: PlayerSession | null;
characters: CharacterProfile[]; characters: CharacterProfile[];
bossCards: BossCardCollection;
selectedCharacterId: string | null; selectedCharacterId: string | null;
activeCharacter: CharacterProfile | null; activeCharacter: CharacterProfile | null;
draft: CharacterDraft; draft: CharacterDraft;
@@ -111,7 +113,7 @@ export interface ShellState {
saveActiveProgression: (level: number, experience: number, talentRanks: Record<string, number>) => void; saveActiveProgression: (level: number, experience: number, talentRanks: Record<string, number>) => void;
saveActiveActionBindings: (actionBindings: Readonly<Record<string, string | null>>) => void; saveActiveActionBindings: (actionBindings: Readonly<Record<string, string | null>>) => void;
saveActiveInventory: (inventory: readonly InventoryItem[]) => void; saveActiveInventory: (inventory: readonly InventoryItem[]) => void;
saveActiveBossCards: (bossCards: BossCardCollection) => void; saveAccountBossCards: (bossCards: BossCardCollection) => void;
saveActiveEquipment: (equipment: EquipmentAssignments) => void; saveActiveEquipment: (equipment: EquipmentAssignments) => void;
saveActiveSettings: (settings: GameplaySettings) => void; saveActiveSettings: (settings: GameplaySettings) => void;
updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => 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(); const session = restoreStoredSession();
if (!session) return { if (!session) return {
phase: "login", phase: "login",
session: null, session: null,
characters: [], characters: [],
bossCards: emptyBossCardCollection(),
selectedCharacterId: null, selectedCharacterId: null,
combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS, combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS,
}; };
@@ -152,6 +155,7 @@ function restoredState(): Pick<ShellState, "phase" | "session" | "characters" |
phase: "characters", phase: "characters",
session, session,
characters, characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters[0]?.id ?? null, selectedCharacterId: characters[0]?.id ?? null,
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId), combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
}; };
@@ -174,6 +178,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
phase: "characters", phase: "characters",
session, session,
characters, characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters[0]?.id ?? null, selectedCharacterId: characters[0]?.id ?? null,
activeCharacter: null, activeCharacter: null,
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId), combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
@@ -189,6 +194,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
phase: "login", phase: "login",
session: null, session: null,
characters: [], characters: [],
bossCards: emptyBossCardCollection(),
selectedCharacterId: null, selectedCharacterId: null,
activeCharacter: null, activeCharacter: null,
draft: defaultDraft(), draft: defaultDraft(),
@@ -276,6 +282,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ set({
phase: "characters", phase: "characters",
characters, characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: result.character.id, selectedCharacterId: result.character.id,
draft: defaultDraft(), draft: defaultDraft(),
notice: `${result.character.name} is ready for the expedition.`, notice: `${result.character.name} is ready for the expedition.`,
@@ -439,7 +446,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
return true; return true;
}, },
returnToMainMenu: () => { returnToMainMenu: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false }); useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame(); if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame();
const { session, characters, activeCharacter, selectedCharacterId } = get(); const { session, characters, activeCharacter, selectedCharacterId } = get();
if (!session) { if (!session) {
@@ -477,7 +484,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
}, },
enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID), enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID),
returnToCharacters: () => { returnToCharacters: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false }); useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
const session = get().session; const session = get().session;
if (!session) { if (!session) {
set({ phase: "login", activeCharacter: null }); set({ phase: "login", activeCharacter: null });
@@ -488,6 +495,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ set({
phase: "characters", phase: "characters",
characters, characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters.some((character) => character.id === currentId) selectedCharacterId: characters.some((character) => character.id === currentId)
? currentId ? currentId
: characters[0]?.id ?? null, : 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), characters: state.characters.map((character) => character.id === updated.id ? updated : character),
})); }));
}, },
saveActiveBossCards: (bossCards) => { saveAccountBossCards: (bossCards) => {
const { session, activeCharacter } = get(); const { session } = get();
if (!session || !activeCharacter) return; if (!session) return;
const updated = updateCharacterBossCards(session.ownerId, activeCharacter.id, bossCards); set({ bossCards: persistAccountBossCards(session.ownerId, bossCards) });
if (!updated) return;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
}, },
saveActiveEquipment: (equipment) => { saveActiveEquipment: (equipment) => {
const { session, activeCharacter } = get(); const { session, activeCharacter } = get();
@@ -651,6 +654,7 @@ if (restored.session?.accessToken) {
useShellStore.setState({ useShellStore.setState({
session: refreshedSession, session: refreshedSession,
characters, characters,
bossCards: loadAccountBossCards(refreshedSession.ownerId),
selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId) selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId)
? useShellStore.getState().selectedCharacterId ? useShellStore.getState().selectedCharacterId
: characters[0]?.id ?? null, : characters[0]?.id ?? null,
+1
View File
@@ -42,6 +42,7 @@ export interface CharacterProfile {
actionLoadouts?: Record<string, Record<string, string | null>>; actionLoadouts?: Record<string, Record<string, string | null>>;
inventory: InventoryItem[]; inventory: InventoryItem[];
equipment: EquipmentAssignments; equipment: EquipmentAssignments;
/** Legacy character log, read only during migration to the account collection. */
bossCards?: BossCardCollection; bossCards?: BossCardCollection;
/** Present on normalized and newly created profiles; optional for legacy callers. */ /** Present on normalized and newly created profiles; optional for legacy callers. */
manastormProgress?: ManastormProgress; manastormProgress?: ManastormProgress;
+6 -5
View File
@@ -5,6 +5,7 @@ import { controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime"; import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import { useInputBindingLabels } from "../input/useInputBindingLabels"; import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { resolveDisplayLayout } from "../platform/displayLayout"; import { resolveDisplayLayout } from "../platform/displayLayout";
import { THOR_DISPLAYS } from "../platform/thorDisplayProfile";
import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting"; import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
interface DualDisplayFrameProps { interface DualDisplayFrameProps {
@@ -136,12 +137,12 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
} }
return ( return (
<div className="thor-preview-frame"> <div className="thor-preview-frame" style={{ width: THOR_DISPLAYS.top.cssWidth + 80 }}>
<div className="thor-preview-label"><b>Main display</b><span>960 x 540 CSS / 1920 x 1080</span></div> <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">{top}</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-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-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">{bottom}</div> <div className="thor-preview-bottom" style={{ width: THOR_DISPLAYS.bottom.cssWidth, height: THOR_DISPLAYS.bottom.cssHeight }}>{bottom}</div>
</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(() => { useEffect(() => {
if (!character) return; if (!character) return;
useCombatStore.getState().initializeCharacter({ ...character, role: activeRole }); useCombatStore.getState().initializeCharacter({ ...character, bossCards: useShellStore.getState().bossCards, role: activeRole });
useCombatStore.getState().synchronizeActors(); useCombatStore.getState().synchronizeActors();
if (gameMode === "manastorm") { if (gameMode === "manastorm") {
activateManastormActiveSpellLoadout( activateManastormActiveSpellLoadout(
@@ -66,12 +66,14 @@ export function CombatBridge() {
useEffect(() => { useEffect(() => {
if (!character) return; if (!character) return;
const characterId = character.id; const characterId = character.id;
const ownerId = useShellStore.getState().session?.ownerId;
const unsubscribe = useCombatStore.subscribe((state, previousState) => { const unsubscribe = useCombatStore.subscribe((state, previousState) => {
// Save APIs intentionally target the active character. Avoid persisting a // Save APIs intentionally target the active character. Avoid persisting a
// final combat notification during the render-to-effect-cleanup window // final combat notification during the render-to-effect-cleanup window
// after Character Select has already replaced the active character. // after Character Select has already replaced the active character.
const shell = useShellStore.getState(); 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 // Persisted collections are replaced immutably by combat actions. A
// reference/primitive comparison therefore detects the same changes as // reference/primitive comparison therefore detects the same changes as
@@ -101,7 +103,7 @@ export function CombatBridge() {
shell.saveActiveInventory(state.inventory); shell.saveActiveInventory(state.inventory);
} }
if (state.bossCards !== previousState.bossCards) { if (state.bossCards !== previousState.bossCards) {
shell.saveActiveBossCards(state.bossCards); shell.saveAccountBossCards(state.bossCards);
} }
if (state.equipment !== previousState.equipment) { if (state.equipment !== previousState.equipment) {
shell.saveActiveEquipment(state.equipment); shell.saveActiveEquipment(state.equipment);
+3 -2
View File
@@ -246,6 +246,7 @@ function applyRelayEvent(
} }
export function OnlineSessionBridge() { export function OnlineSessionBridge() {
const bossTest = useGameStore((state) => state.bossTest);
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const activeActivity = useOnlineGroupStore((state) => state.activeActivity); const activeActivity = useOnlineGroupStore((state) => state.activeActivity);
const group = useOnlineGroupStore((state) => state.snapshot.group); const group = useOnlineGroupStore((state) => state.snapshot.group);
@@ -256,7 +257,7 @@ export function OnlineSessionBridge() {
const applyingRef = useRef(false); const applyingRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!session?.accessToken || !activeActivity || !group) { if (bossTest || !session?.accessToken || !activeActivity || !group) {
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null }); configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
reset(); reset();
return undefined; return undefined;
@@ -362,7 +363,7 @@ export function OnlineSessionBridge() {
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null }); configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
reset(); 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; return null;
} }
+29
View File
@@ -49,6 +49,35 @@ export function normalizeBossCardCollection(value: unknown): BossCardCollection
return { counts, lastClaims }; 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. */ /** Independent bonus rolls; Normal is always awarded, including with all three bonuses. */
export function rollBossCards(random: () => number = Math.random): readonly BossCardRarity[] { export function rollBossCards(random: () => number = Math.random): readonly BossCardRarity[] {
return BOSS_CARD_RARITIES.filter((rarity) => { 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 health = mob.health - applied;
const killed = health <= 0; const killed = health <= 0;
const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt; 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 beforeLevel = work.progression.level;
const pendingLoot = killed && mob.lootSourceId const pendingLoot = testing ? [] : killed && mob.lootSourceId
? rollBossLoot(mob.lootSourceId, beforeLevel, { ? rollBossLoot(mob.lootSourceId, beforeLevel, {
now, now,
bonusItemChance: mob.bonusLootChance, bonusItemChance: mob.bonusLootChance,
}) })
: mob.pendingLoot; : mob.pendingLoot;
const card = killed && mob.boss const card = killed && mob.boss && !testing
? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name) ? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name)
: null; : null;
const bossCardDrop = card ? { const bossCardDrop = card ? {
@@ -2648,7 +2649,7 @@ function damageMobInWork(
claimId: `${now}:${mob.deathRevision + 1}:${mob.id}`, claimId: `${now}:${mob.deathRevision + 1}:${mob.id}`,
rarities: rollBossCards(), rarities: rollBossCards(),
} satisfies BossCardDrop : mob.bossCardDrop; } 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; const xpAwarded = canAward ? grantMobExperience(classId, work, mob.xpReward) : 0;
engageMobGroupInWork(work, id, now, source.actorId); engageMobGroupInWork(work, id, now, source.actorId);
const engagedMob = work.mobs[id] ?? mob; 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)); 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 { 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 { export function requireDungeonDefinition(id: string): DungeonDefinition {
+9 -3
View File
@@ -5,8 +5,8 @@ import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { resetPartyRuntimeClock } from "./partyRuntime"; import { resetPartyRuntimeClock } from "./partyRuntime";
import { usePartyStore } from "./partyStore"; import { usePartyStore } from "./partyStore";
import { useGameStore, type GameMode } from "./store"; import { useGameStore, type BossTestSession, type GameMode } from "./store";
import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry"; import { bossTestingSourceDungeonId, dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
import type { WorldSpawn } from "./dungeonTypes"; import type { WorldSpawn } from "./dungeonTypes";
import type { GmReviewTarget } from "./gmContentReview"; import type { GmReviewTarget } from "./gmContentReview";
import { shelvedDungeonSpawn } from "@shelvedDungeonSession"; import { shelvedDungeonSpawn } from "@shelvedDungeonSession";
@@ -27,6 +27,7 @@ export interface DungeonSessionOptions {
readonly gameMode?: GameMode; readonly gameMode?: GameMode;
readonly spawn?: WorldSpawn; readonly spawn?: WorldSpawn;
readonly gmTestMode?: boolean; readonly gmTestMode?: boolean;
readonly bossTest?: BossTestSession | null;
readonly gmReviewTarget?: GmReviewTarget | null; readonly gmReviewTarget?: GmReviewTarget | null;
/** Used by in-game encounter transitions so character progression and inventory survive. */ /** Used by in-game encounter transitions so character progression and inventory survive. */
readonly preserveCharacter?: boolean; readonly preserveCharacter?: boolean;
@@ -47,7 +48,10 @@ export function activateDungeonSession(
options: DungeonSessionOptions = {}, options: DungeonSessionOptions = {},
): boolean { ): boolean {
const definition = dungeonDefinitionById(dungeonId); 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; return false;
} }
if ((difficultyId !== undefined && !definition.difficultyVariants.some((variant) => variant.id === difficultyId)) if ((difficultyId !== undefined && !definition.difficultyVariants.some((variant) => variant.id === difficultyId))
@@ -107,6 +111,7 @@ export function activateDungeonSession(
gameMode: options.gameMode, gameMode: options.gameMode,
spawn: shelvedDungeonSpawn(dungeonId, options.spawn), spawn: shelvedDungeonSpawn(dungeonId, options.spawn),
gmTestMode: options.gmTestMode, gmTestMode: options.gmTestMode,
bossTest: options.bossTest,
gmReviewTarget: options.gmReviewTarget, gmReviewTarget: options.gmReviewTarget,
bossDifficulty, bossDifficulty,
}); });
@@ -128,6 +133,7 @@ export function restartActiveDungeonSession(): boolean {
gameMode: game.gameMode, gameMode: game.gameMode,
spawn: game.activeSpawn, spawn: game.activeSpawn,
gmTestMode: game.gmTestMode, gmTestMode: game.gmTestMode,
bossTest: game.bossTest,
gmReviewTarget: game.activeGmReviewTarget, gmReviewTarget: game.activeGmReviewTarget,
preserveCharacter: true, preserveCharacter: true,
bossDifficulty: game.activeBossDifficulty, 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 AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
export type InputMode = "keyboard" | "gamepad"; export type InputMode = "keyboard" | "gamepad";
export type GameMode = "dungeon" | "manastorm"; 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 { interface PlayerSnapshot {
position: Vector3Tuple; position: Vector3Tuple;
@@ -24,6 +32,7 @@ export interface GameState {
activeBossDifficulty: number; activeBossDifficulty: number;
gameMode: GameMode; gameMode: GameMode;
gmTestMode: boolean; gmTestMode: boolean;
bossTest: BossTestSession | null;
activeGmReviewTarget: GmReviewTarget | null; activeGmReviewTarget: GmReviewTarget | null;
activeSpawn: WorldSpawn; activeSpawn: WorldSpawn;
sessionRevision: number; sessionRevision: number;
@@ -55,6 +64,7 @@ export interface GameState {
readonly gameMode?: GameMode; readonly gameMode?: GameMode;
readonly spawn?: WorldSpawn; readonly spawn?: WorldSpawn;
readonly gmTestMode?: boolean; readonly gmTestMode?: boolean;
readonly bossTest?: BossTestSession | null;
readonly gmReviewTarget?: GmReviewTarget | null; readonly gmReviewTarget?: GmReviewTarget | null;
readonly bossDifficulty?: number; readonly bossDifficulty?: number;
}, },
@@ -88,6 +98,7 @@ export const useGameStore = create<GameState>((set) => ({
activeBossDifficulty: 1, activeBossDifficulty: 1,
gameMode: "dungeon", gameMode: "dungeon",
gmTestMode: false, gmTestMode: false,
bossTest: null,
activeGmReviewTarget: null, activeGmReviewTarget: null,
activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw }, activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw },
sessionRevision: 0, sessionRevision: 0,
@@ -125,6 +136,7 @@ export const useGameStore = create<GameState>((set) => ({
activeBossDifficulty: validBossDifficulty(options?.bossDifficulty) ? options.bossDifficulty : 1, activeBossDifficulty: validBossDifficulty(options?.bossDifficulty) ? options.bossDifficulty : 1,
gameMode: options?.gameMode ?? "dungeon", gameMode: options?.gameMode ?? "dungeon",
gmTestMode: options?.gmTestMode ?? false, gmTestMode: options?.gmTestMode ?? false,
bossTest: options?.bossTest ?? null,
activeGmReviewTarget: options?.gmReviewTarget ?? null, activeGmReviewTarget: options?.gmReviewTarget ?? null,
activeSpawn, activeSpawn,
sessionRevision: state.sessionRevision + 1, sessionRevision: state.sessionRevision + 1,
@@ -279,7 +291,7 @@ export const useGameStore = create<GameState>((set) => ({
const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw }; const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw };
return { return {
gameMode: "dungeon", gameMode: "dungeon",
gmTestMode: false, gmTestMode: Boolean(state.bossTest),
activeGmReviewTarget: null, activeGmReviewTarget: null,
activeSpawn, activeSpawn,
overlay: null, overlay: null,
+21
View File
@@ -2,9 +2,30 @@ import { describe, expect, it } from "vitest";
import { import {
actionKeyForEvent, assignDesktopAbility, bindDesktopSlot, defaultUiLayout, desktopSlotForKey, actionKeyForEvent, assignDesktopAbility, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resetDesktopAbilities, resolveDpsMeterDisplay, exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resetDesktopAbilities, resolveDpsMeterDisplay,
HUD_ELEMENTS, resolveHudScreen,
} from "./uiLayout"; } from "./uiLayout";
describe("personal UI layouts", () => { 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", () => { it("saves and clears abilities on inactive WoW bars without changing keys, templates, or mobile controls", () => {
const ui = normalizeUiPreferences(null); const ui = normalizeUiPreferences(null);
ui.slots.pc[0] = { name: "Original", layout: ui.current.pc }; 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 BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const;
export const HUD_ELEMENTS = { export const HUD_ELEMENTS = {
party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap", 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", friendly: "Friendly target", threat: "Threat meter", dps: "DPS meter", cast: "Cast bar",
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar", hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt", 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", "bar-4": "Action bar 4", "bar-5": "Action bar 5",
} as const; } as const;
export type HudElementId = keyof typeof HUD_ELEMENTS; export type HudElementId = keyof typeof HUD_ELEMENTS;
export type HudScreen = "auto" | "top" | "bottom" | "hidden";
export interface HudElementLayout { export interface HudElementLayout {
visible: boolean; visible: boolean;
screen?: HudScreen;
position: NormalizedHudPosition | null; position: NormalizedHudPosition | null;
width: number | null; width: number | null;
} }
@@ -87,6 +89,7 @@ export function normalizeUiLayout(value: unknown): UiLayout {
const element = record(elements[id]); const element = record(elements[id]);
defaults.elements[id] = { defaults.elements[id] = {
visible: element.visible !== false, 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, 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, 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" { 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; 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 { export function normalizeUiPreferences(value: unknown): UiPreferences {
const source = record(value); const source = record(value);
const current = record(source.current); const current = record(source.current);
@@ -174,7 +190,7 @@ export function importUiPreferences(text: string): UiPreferences {
if (id === "dps" && record(layout.elements)[id] === undefined) continue; if (id === "dps" && record(layout.elements)[id] === undefined) continue;
const element = record(record(layout.elements)[id]); const element = record(record(layout.elements)[id]);
const position = record(element.position); 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."); || (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>(); 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"; import { publishInputPreferences } from "../input/keybindRuntime";
export function currentUiDevice(): UiDevice { 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) || (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches && window.innerWidth < 1100)
? "mobile" : "pc"; ? "mobile" : "pc";
} }
+6
View File
@@ -4,15 +4,21 @@ import { Capacitor } from "@capacitor/core";
import App from "./App"; import App from "./App";
import { startControllerInput } from "./input/controller"; import { startControllerInput } from "./input/controller";
import { currentDisplayLayout } from "./platform/displayLayout"; import { currentDisplayLayout } from "./platform/displayLayout";
import { THOR_DISPLAYS } from "./platform/thorDisplayProfile";
import { initializeContentSystem } from "./content/contentManager"; import { initializeContentSystem } from "./content/contentManager";
import "./styles.css"; import "./styles.css";
import "./ui/dungeonReports.css"; import "./ui/dungeonReports.css";
import "./ui/thorUi.css";
const BottomDisplayApp = lazy(() => import("./platform/BottomDisplayApp").then((module) => ({ default: module.BottomDisplayApp }))); const BottomDisplayApp = lazy(() => import("./platform/BottomDisplayApp").then((module) => ({ default: module.BottomDisplayApp })));
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const displayMode = params.get("display"); const displayMode = params.get("display");
const nativeLayoutRequested = params.has("nativeLayout"); 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 (Capacitor.isNativePlatform() || nativeLayoutRequested) document.documentElement.classList.add("native-platform");
if (displayMode === "top" || displayMode === "bottom") document.documentElement.dataset.displaySurface = displayMode; if (displayMode === "top" || displayMode === "bottom") document.documentElement.dataset.displaySurface = displayMode;
document.documentElement.dataset.displayLayout = currentDisplayLayout(); document.documentElement.dataset.displayLayout = currentDisplayLayout();
+4 -1
View File
@@ -15,6 +15,7 @@ import {
import { normalizeInputPreferences } from "../input/keybinds"; import { normalizeInputPreferences } from "../input/keybinds";
import { publishInputPreferences } from "../input/keybindRuntime"; import { publishInputPreferences } from "../input/keybindRuntime";
import { useForcedThorDisplays } from "./useThorDualScreen"; import { useForcedThorDisplays } from "./useThorDualScreen";
import { RemoteDungeonHud } from "./RemoteDungeonHud";
export function BottomDisplayApp() { export function BottomDisplayApp() {
useForcedThorDisplays(); 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 { createRateLimitedPublisher } from "./rateLimitedPublisher";
import { useUiLayoutStore } from "../game/uiLayoutStore"; import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore"; 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( export function connectAuthoritativeDualScreen(
channel: BroadcastChannel, channel: BroadcastChannel,
@@ -38,6 +42,11 @@ export function connectAuthoritativeDualScreen(
const unsubscribeDps = useDpsMeterStore.subscribe(() => { const unsubscribeDps = useDpsMeterStore.subscribe(() => {
if (companionReady) gamePublisher.request(); 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>) => { channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "companion-ready") { if (event.data.type === "companion-ready") {
companionReady = true; companionReady = true;
@@ -62,6 +71,10 @@ export function connectAuthoritativeDualScreen(
unsubscribeParty(); unsubscribeParty();
unsubscribeUi(); unsubscribeUi();
unsubscribeDps(); unsubscribeDps();
unsubscribeOnline();
unsubscribeGroup();
unsubscribeRun();
unsubscribeEncounters();
gamePublisher.dispose(); gamePublisher.dispose();
}; };
} }
+33 -1
View File
@@ -32,6 +32,10 @@ import { activeUiLayout, useUiLayoutStore } from "../game/uiLayoutStore";
import { normalizeHudPosition } from "../game/hudLayout"; import { normalizeHudPosition } from "../game/hudLayout";
import { resolveDpsMeterDisplay, type HudElementLayout } from "../game/uiLayout"; import { resolveDpsMeterDisplay, type HudElementLayout } from "../game/uiLayout";
import { currentDisplayLayout } from "./displayLayout"; 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"; export { createDualScreenChannel } from "./dualScreenChannel";
@@ -95,6 +99,7 @@ export interface CompanionPartyObjectiveSnapshot {
} }
export interface CompanionGameSnapshot { export interface CompanionGameSnapshot {
hud: HudSnapshot;
dpsMeter: { dpsMeter: {
values: DpsMeterSnapshot; values: DpsMeterSnapshot;
layout: HudElementLayout; layout: HudElementLayout;
@@ -172,7 +177,15 @@ export type CompanionCommand =
| { name: "setPartyCommand"; command: PartyCommand } | { name: "setPartyCommand"; command: PartyCommand }
| { name: "recallParty" } | { name: "recallParty" }
| { name: "resetAtEntrance" } | { 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 = export type DualScreenMessage =
| { type: "companion-ready" } | { type: "companion-ready" }
@@ -265,6 +278,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
draft: shell.draft, draft: shell.draft,
notice: shell.notice, notice: shell.notice,
game: { game: {
hud: currentHudSnapshot(),
dpsMeter: { dpsMeter: {
values: currentDpsMeterSnapshot(), values: currentDpsMeterSnapshot(),
layout: uiLayout.elements.dps, layout: uiLayout.elements.dps,
@@ -342,6 +356,24 @@ export function executeCompanionCommand(command: CompanionCommand): void {
const game = useGameStore.getState(); const game = useGameStore.getState();
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
switch (command.name) { 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": { case "editDpsMeter": {
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft) break; if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft) break;
const update: Partial<HudElementLayout> = {}; 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 { useGameStore } from "../game/store";
import { ProductionDungeon } from "./ProductionDungeon"; import { ProductionDungeon } from "./ProductionDungeon";
import { PrototypeDungeon } from "./PrototypeDungeon"; import { PrototypeDungeon } from "./PrototypeDungeon";
import { BossTestingRoom } from "./BossTestingRoom";
export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void }) { export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void }) {
const activeDungeonId = useGameStore((state) => state.activeDungeonId); const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const gameMode = useGameStore((state) => state.gameMode); const gameMode = useGameStore((state) => state.gameMode);
const bossTest = useGameStore((state) => state.bossTest);
const sessionRevision = useGameStore((state) => state.sessionRevision); const sessionRevision = useGameStore((state) => state.sessionRevision);
const definition = requireDungeonDefinition(activeDungeonId); const definition = requireDungeonDefinition(activeDungeonId);
const manastormPackage = gameMode === "manastorm" const manastormPackage = gameMode === "manastorm"
@@ -31,6 +33,7 @@ export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void
&& assets.collision.length > 0; && assets.collision.length > 0;
useEffect(() => { useEffect(() => {
if (bossTest) return;
if (hasProductionAssets) return; if (hasProductionAssets) return;
const missing = [ const missing = [
...definition.assets.visual.filter((_, index) => !assets.visual[index]).map((asset) => asset.fileName), ...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"}.`); useGameStore.getState().setAssetStatus("proxy", `Waiting for ${missing.join(" and ") || "generated dungeon assets"}.`);
onReady?.(); 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} />; if (!hasProductionAssets) return <PrototypeDungeon definition={definition} />;
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
+26 -2
View File
@@ -28,6 +28,30 @@ html, body, #root {
body { color: var(--ink); } body { color: var(--ink); }
button { font: inherit; } 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 { .game-shell, .scene-shell {
position: relative; position: relative;
width: 100%; 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 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 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.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-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 { 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; } .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; display: grid; justify-items: center; gap: .1rem; padding: .2rem;
border: 1px solid #9dad8277; background: #08130feb; color: #fff; 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 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 .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; } .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, type ActionBindingLayer,
} from "../game/actionBindings"; } from "../game/actionBindings";
import { useCombatStore } from "../game/combatStore"; import { useCombatStore } from "../game/combatStore";
import { defaultActionBarForCharacter } from "../game/abilityCatalog"; import { abilityAtLevel, defaultActionBarForCharacter, type AbilityDefinition } from "../game/abilityCatalog";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { actionKeyLabel, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout"; import { actionKeyLabel, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore"; import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
@@ -20,6 +20,7 @@ import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import { useInputBindingLabels } from "../input/useInputBindingLabels"; import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useMenuController, type MenuAction } from "../input/useMenuController"; import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton"; import { ControllerButton } from "./ControllerButton";
import { AbilityTooltipTarget, type AbilityTooltipData } from "./AbilityTooltip";
function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: string; color: string }) { function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: string; color: string }) {
const [failed, setFailed] = useState(false); 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 panel = useRef<HTMLElement>(null);
const keyLabel = useInputBindingLabels(); const keyLabel = useInputBindingLabels();
const bindingChord = (layer: ActionBindingLayer, control: ActionBindingControl) => `${keyLabel("controller", `ability:${layer}:${control}`)} / ${keyLabel("pc", `ability:${layer}:${control}`)}`; 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 secondaryClassId = useCombatStore((state) => state.secondaryClassId);
const level = useCombatStore((state) => state.level); const level = useCombatStore((state) => state.level);
const abilities = useCombatStore((state) => state.abilities); 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 bindings = useCombatStore((state) => state.actionBindings);
const pcLayout = useUiLayoutStore((state) => state.preferences.current.pc); const pcLayout = useUiLayoutStore((state) => state.preferences.current.pc);
const [layout, setLayout] = useState<BindingLayout>(() => currentUiDevice() === "pc" && pcLayout.wowBars ? "wow" : "controller"); const [layout, setLayout] = useState<BindingLayout>(() => currentUiDevice() === "pc" && pcLayout.wowBars ? "wow" : "controller");
@@ -133,7 +147,7 @@ function AbilityBindingsDialog() {
] ]
: ordered; : ordered;
}, [abilities, editingAbilityId]); }, [abilities, editingAbilityId]);
const close = () => openOverlay("pause"); const close = onClose ?? (() => openOverlay("pause"));
const saveWowLayout = (update: (current: UiLayout) => UiLayout) => { const saveWowLayout = (update: (current: UiLayout) => UiLayout) => {
try { try {
const ui = useUiLayoutStore.getState(); const ui = useUiLayoutStore.getState();
@@ -335,6 +349,7 @@ function AbilityBindingsDialog() {
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null; const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
const locked = Boolean(ability && ability.unlockLevel > level); const locked = Boolean(ability && ability.unlockLevel > level);
return ( return (
<AbilityTooltipTarget key={control} ability={tooltipData(ability)} bindingLabel={bindingChord(layer, control)}>
<ControllerButton <ControllerButton
key={control} key={control}
controlId={slotControlId(control)} controlId={slotControlId(control)}
@@ -352,6 +367,7 @@ function AbilityBindingsDialog() {
: <span className="ability-bindings__empty" aria-hidden="true">+</span>} : <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> <span><strong>{definition.label}</strong><small>{locked ? `${ability!.name} · Level ${ability!.unlockLevel}` : ability?.name ?? "Empty"}</small></span>
</ControllerButton> </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> <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> <h2 id="ability-bindings-title">Ability controls</h2>
</div> </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> </header>
<nav className="ability-bindings__layouts" aria-label="Action bar style"> <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 ability = abilities.find((candidate) => candidate.id === slot.abilityId);
const locked = Boolean(ability && ability.unlockLevel > level); const locked = Boolean(ability && ability.unlockLevel > level);
const name = ability?.name ?? (slot.abilityId ? "Unavailable for this character" : "Empty"); 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" : ""}`} 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-label={`${controlLabel(index)}: ${name}${locked ? `, unlocks at level ${ability!.unlockLevel}` : ""}`}
aria-pressed={editingControl === index} title={`${controlLabel(index)}: ${name}`} onClick={() => beginEditing(index)}> aria-pressed={editingControl === index} title={`${controlLabel(index)}: ${name}`} onClick={() => beginEditing(index)}>
<kbd>{actionKeyLabel(slot.key) || "—"}</kbd> <kbd>{actionKeyLabel(slot.key) || "—"}</kbd>
{ability ? <AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} /> : <span className="ability-bindings__empty" aria-hidden="true">+</span>} {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> <span><strong>Button {index + 1}</strong><small>{locked ? `${name} · Level ${ability!.unlockLevel}` : name}</small></span>
</ControllerButton>; </ControllerButton></AbilityTooltipTarget>;
})} })}
</div> </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> <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> <h3>Choose an ability</h3>
<div className="ability-bindings__choices"> <div className="ability-bindings__choices">
{pickerAbilities.map((ability) => ( {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} /> <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> <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> </ControllerButton>
</AbilityTooltipTarget>
))} ))}
<ControllerButton controlId="binding-choice-empty" selectedId={controller.selectedId} select={controller.select} type="button" className={editingAbilityId === null ? "is-current" : ""} onClick={() => assign(null)}> <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> <span className="ability-bindings__empty" aria-hidden="true">×</span>
@@ -507,7 +526,7 @@ function AbilityBindingsDialog() {
); );
} }
export function AbilityBindingsPanel() { export function AbilityBindingsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
const open = useGameStore((state) => state.overlay === "bindings"); const storeOpen = useGameStore((state) => state.overlay === "bindings");
return open ? <AbilityBindingsDialog /> : null; 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 { useEffect, useState, type CSSProperties } from "react";
import type { AbilityTarget } from "../game/abilityCatalog"; import type { AbilityTarget } from "../game/abilityCatalog";
import { AbilityTooltipTarget } from "./AbilityTooltip";
import { useInputBindingLabels } from "../input/useInputBindingLabels"; import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { import {
ACTION_LAYER_LABELS, ACTION_LAYER_LABELS,
@@ -21,6 +22,10 @@ export interface ActionBarItem {
disabled?: boolean; disabled?: boolean;
lockedUntilLevel?: number; lockedUntilLevel?: number;
target?: AbilityTarget; target?: AbilityTarget;
cooldownSeconds?: number;
castSeconds?: number;
range?: number;
talentLocked?: boolean;
} }
export type ActionBarItems = Readonly<Record<ActionBindingControl, ActionBarItem | null>>; export type ActionBarItems = Readonly<Record<ActionBindingControl, ActionBarItem | null>>;
@@ -94,6 +99,7 @@ function ActionSlot({
: ""; : "";
return ( return (
<AbilityTooltipTarget ability={item} bindingLabel={chord}>
<button <button
type="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" : ""}`} 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> <em>{item!.cooldownRemaining! < 10 ? item!.cooldownRemaining!.toFixed(1) : Math.ceil(item!.cooldownRemaining!)}</em>
)} )}
</button> </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 { ActionBar, ActionIcon, type ActionBarItem, type ActionBarItems } from "./ActionBar";
import { useInputBindingLabels } from "../input/useInputBindingLabels"; import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useArenaStore } from "../game/arenaStore"; 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; 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, resourceCost: ability.cost.amount, resourceLabel: kit.resource.name,
cooldownDuration: Math.max(ability.cooldownMs, ability.gcdMs) / 1000, cooldownDuration: Math.max(ability.cooldownMs, ability.gcdMs) / 1000,
cooldownRemaining: remaining, 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:")), disabled: blocked || !actor?.alive || !learned || remaining > 0 || actor.effects.some((effect) => effect.id.startsWith("casting:")),
lockedUntilLevel: learned ? undefined : ability.unlockLevel, 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))` }}> {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) => { {bar.slots.map((slot, slotIndex) => {
const item = slot.abilityId ? items[slot.abilityId] : null; 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"} 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> <kbd>{slot.label}</kbd><ActionIcon item={item ?? null} /><span>{item?.name ?? "Empty"}</span>
{item && (item.cooldownRemaining ?? 0) > 0 && <em>{item.cooldownRemaining!.toFixed(1)}</em>} {item && (item.cooldownRemaining ?? 0) > 0 && <em>{item.cooldownRemaining!.toFixed(1)}</em>}
</button>; </button></AbilityTooltipTarget>;
})} })}
</section>)} </section>)}
</nav> </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", () => { it("shows per-rarity copy totals and every dungeon for the visible boss", () => {
const boss = BOSS_CARD_CATALOG[0]; const boss = BOSS_CARD_CATALOG[0];
const markup = renderToStaticMarkup(createElement(BossCardCollection, { const markup = renderToStaticMarkup(createElement(BossCardCollection, {
onClose: () => {}, characterName: "Collector", onClose: () => {},
collection: { counts: { [boss.key]: { normal: 7, rare: 1 } }, lastClaims: {} }, collection: { counts: { [boss.key]: { normal: 7, rare: 1 } }, lastClaims: {} },
})); }));
expect(markup).toContain("7 copies"); expect(markup).toContain("7 copies");
expect(markup).toContain("1 copy"); expect(markup).toContain("1 copy");
expect(markup).toContain("8 total copies"); expect(markup).toContain("8 total copies");
expect(markup).toContain("Account-wide collection log");
expect(markup).toContain("Group by dungeon"); expect(markup).toContain("Group by dungeon");
expect(markup).toContain("Group by rarity"); expect(markup).toContain("Group by rarity");
for (const dungeon of boss.dungeons) expect(markup).toContain(dungeon.title); 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 { lazy, Suspense, useMemo, useState, type CSSProperties } from "react";
import { selectedCharacter, useShellStore } from "../app/shellStore"; import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { BOSS_CARD_CATALOG, groupBossCards, type BossCardGrouping } from "../game/bossCardCatalog"; 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 { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton"; import { ControllerButton } from "./ControllerButton";
import { dungeonContentIsUsable } from "../content/contentManager"; import { dungeonContentIsUsable } from "../content/contentManager";
@@ -11,13 +10,11 @@ import { useContentManagerSnapshot } from "../content/useContentManager";
import "./bossCards.css"; import "./bossCards.css";
const BossCardPortrait = lazy(() => import("./BossCardPortrait")); const BossCardPortrait = lazy(() => import("./BossCardPortrait"));
const EMPTY_COLLECTION = emptyBossCardCollection();
const PAGE_SIZE = 8; const PAGE_SIZE = 8;
export function BossCardCollection({ onClose, collection, characterName }: { export function BossCardCollection({ onClose, collection }: {
readonly onClose: () => void; readonly onClose: () => void;
readonly collection: Collection; readonly collection: Collection;
readonly characterName: string;
}) { }) {
useContentManagerSnapshot(); useContentManagerSnapshot();
const [earnedOnly, setEarnedOnly] = useState(false); 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()}> 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"> <section className="boss-collection" role="dialog" aria-modal="true" aria-labelledby="boss-collection-title">
<header className="boss-collection__header"> <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> <ControllerButton {...controlProps} controlId="cards-close" className="icon-button" onClick={onClose} aria-label="Close boss card collection">×</ControllerButton>
</header> </header>
<div className="boss-collection__summary"> <div className="boss-collection__summary">
@@ -100,11 +97,7 @@ export function BossCardCollection({ onClose, collection, characterName }: {
} }
export default function BossCardCollectionScreen({ onClose }: { readonly onClose?: () => void }) { export default function BossCardCollectionScreen({ onClose }: { readonly onClose?: () => void }) {
const savedCharacter = useShellStore(selectedCharacter); const collection = useShellStore((state) => state.bossCards);
const activeCharacter = useShellStore((state) => state.activeCharacter);
const phase = useShellStore((state) => state.phase);
const liveCollection = useCombatStore((state) => state.bossCards);
const character = activeCharacter ?? savedCharacter;
return <BossCardCollection onClose={onClose ?? (() => useGameStore.getState().closeOverlay())} 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() { function BossGuideDialog() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId); const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const bossTest = useGameStore((state) => state.bossTest);
const openOverlay = useGameStore((state) => state.openOverlay); 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 [selectedDungeonId, setSelectedDungeonId] = useState(initialDungeon?.id ?? "");
const selectedDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon; 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) const selectedBoss = selectedDungeon?.bosses.find((boss) => boss.id === selectedBossId)
?? selectedDungeon?.bosses[0]; ?? selectedDungeon?.bosses[0];
const close = () => openOverlay("pause"); 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 { DpsMeterWidget } from "./DpsMeterWidget";
import { useUiLayoutStore } from "../game/uiLayoutStore"; import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore"; import { useDpsMeterStore } from "../game/dpsMeterStore";
import { currentDisplayLayout } from "../platform/displayLayout";
import { DungeonCompanionHud } from "./DungeonCompanionHud";
function safePercent(current: number, maximum: number): number { function safePercent(current: number, maximum: number): number {
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0; 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> <p className="companion-kicker">Ready on both screens</p>
<h2>One roster. Two ways to play.</h2> <h2>One roster. Two ways to play.</h2>
<div className="companion-feature-grid"> <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>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> <article><b>03</b><span><strong>PC fallback</strong><small>This panel opens as an overlay</small></span></article>
</div> </div>
</main> </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> </FrontSurface>
); );
} }
@@ -297,6 +299,19 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
return <CompanionDungeonSelection selected={selected} send={send} />; 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") { if (snapshot.phase === "manastorms") {
return <ShelvedCompanionPanel selected={selected} send={send} />; return <ShelvedCompanionPanel selected={selected} send={send} />;
} }
@@ -510,11 +525,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
} }
export function LocalCompanionPanel() { export function LocalCompanionPanel() {
useShellStore(); const shell = useShellStore();
useGameStore(); useGameStore();
useCombatStore(); useCombatStore();
usePartyStore(); usePartyStore();
useUiLayoutStore(); useUiLayoutStore();
useDpsMeterStore(); useDpsMeterStore();
if (shell.phase === "game" && currentDisplayLayout() !== "single") return <DungeonCompanionHud send={executeCompanionCommand} />;
return <CompanionPanel snapshot={currentCompanionSnapshot()} 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 { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { EditableHudElement } from "./EditableHudElement"; import { EditableHudElement } from "./EditableHudElement";
import { ActionIcon, type ActionBarItem } from "./ActionBar"; import { ActionIcon, type ActionBarItem } from "./ActionBar";
import { AbilityTooltipTarget } from "./AbilityTooltip";
export function DesktopActionBars({ items }: { items: Readonly<Record<string, ActionBarItem>> }) { export function DesktopActionBars({ items }: { items: Readonly<Record<string, ActionBarItem>> }) {
const ui = useUiLayoutStore(); const ui = useUiLayoutStore();
@@ -19,7 +20,8 @@ export function DesktopActionBars({ items }: { items: Readonly<Record<string, Ac
{bar.slots.map((button, s) => { {bar.slots.map((button, s) => {
const item = button.abilityId ? items[button.abilityId] : null; const item = button.abilityId ? items[button.abilityId] : null;
const selected = editing && ui.selectedButton?.bar === b && ui.selectedButton.slot === s; 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-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} 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)}`} 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> <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>} {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>} {editing && !item && <small>{s + 1}</small>}
</button>; </button></AbilityTooltipTarget>;
})} })}
</section> </section>
</EditableHudElement>; })}</>; </EditableHudElement>; })}</>;
+3 -1
View File
@@ -4,11 +4,13 @@ import { resolveDpsMeterDisplay } from "../game/uiLayout";
import { currentDpsMeterSnapshot, useDpsMeterStore } from "../game/dpsMeterStore"; import { currentDpsMeterSnapshot, useDpsMeterStore } from "../game/dpsMeterStore";
import { EditableHudElement } from "./EditableHudElement"; import { EditableHudElement } from "./EditableHudElement";
import { DpsMeterWidget } from "./DpsMeterWidget"; import { DpsMeterWidget } from "./DpsMeterWidget";
import { useHudSurface } from "./HudSurface";
export function DpsMeter() { export function DpsMeter() {
const { surface } = useHudSurface();
useDpsMeterStore(); useDpsMeterStore();
const ui = useUiLayoutStore(); const ui = useUiLayoutStore();
const layout = ui.draft ?? ui.preferences.current[currentUiDevice()]; 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>; 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 { useGameStore } from "../game/store";
import { useInputBindingLabels } from "../input/useInputBindingLabels"; 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 label = useInputBindingLabels();
const playerPosition = useGameStore((state) => state.playerPosition); const playerPosition = useGameStore((state) => state.playerPosition);
const inputMode = useGameStore((state) => state.inputMode); const inputMode = useGameStore((state) => state.inputMode);
@@ -21,19 +24,20 @@ export function DungeonInteractionPrompt() {
activeDungeonEncounterRevision, activeDungeonEncounterRevision,
); );
void playerPosition; 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; if (!candidate) return null;
return ( return (
<button <button
type="button" type="button"
className="dungeon-interaction-prompt" className="dungeon-interaction-prompt"
onClick={() => interactWithActiveDungeonObject()} onClick={onInteract ?? (() => interactWithActiveDungeonObject())}
aria-label={`${candidate.object.interaction!.prompt}: ${candidate.object.name}`} aria-label={`${candidate.prompt}: ${candidate.name}`}
> >
<span className="dungeon-interaction-prompt__key">{label(inputMode === "gamepad" ? "controller" : "pc", "loot")}</span> <span className="dungeon-interaction-prompt__key">{label(inputMode === "gamepad" ? "controller" : "pc", "loot")}</span>
<span> <span>
<strong>{candidate.object.interaction!.prompt}</strong> <strong>{candidate.prompt}</strong>
<small>{candidate.object.name} · {candidate.state.label}</small> <small>{candidate.name} · {candidate.label}</small>
</span> </span>
</button> </button>
); );
+9 -7
View File
@@ -8,6 +8,7 @@ import { ControllerButton } from "./ControllerButton";
export function DungeonIssueReportPanel() { export function DungeonIssueReportPanel() {
const dungeonId = useGameStore((state) => state.activeDungeonId); const dungeonId = useGameStore((state) => state.activeDungeonId);
const bossTest = useGameStore((state) => state.bossTest);
const openOverlay = useGameStore((state) => state.openOverlay); const openOverlay = useGameStore((state) => state.openOverlay);
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
@@ -16,12 +17,12 @@ export function DungeonIssueReportPanel() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const busy = useRef(false); const busy = useRef(false);
const form = useRef<HTMLFormElement>(null); 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 canSubmit = Boolean(comment.trim()) && comment.length <= DUNGEON_REPORT_COMMENT_MAX_LENGTH && !submitting;
const actions = useMemo<MenuAction[]>(() => [ const actions = useMemo<MenuAction[]>(() => [
{ id: "report-back", run: back, enabled: !submitting }, { id: "report-back", run: back, enabled: !submitting },
{ id: "report-submit", run: () => form.current?.requestSubmit(), enabled: canSubmit && !submitted }, { 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 controller = useMenuController(actions, { initialId: "report-back", onBack: back });
const submit = async (event: FormEvent) => { const submit = async (event: FormEvent) => {
@@ -31,7 +32,8 @@ export function DungeonIssueReportPanel() {
setSubmitting(true); setSubmitting(true);
setError(""); setError("");
try { try {
await submitDungeonReport(session, dungeonId, comment); await submitDungeonReport(session, bossTest?.sourceDungeonId ?? dungeonId, comment,
bossTest ? { bossKey: bossTest.key, bossName: bossTest.name } : undefined);
setSubmitted(true); setSubmitted(true);
} catch (failure) { } catch (failure) {
setError(failure instanceof Error ? failure.message : "Your report could not be submitted. Please try again."); setError(failure instanceof Error ? failure.message : "Your report could not be submitted. Please try again.");
@@ -44,9 +46,9 @@ export function DungeonIssueReportPanel() {
return ( return (
<div className="modal-backdrop" role="presentation"> <div className="modal-backdrop" role="presentation">
<section className="pause-menu dungeon-issue-report" role="dialog" aria-modal="true" aria-labelledby="issue-report-title"> <section className="pause-menu dungeon-issue-report" role="dialog" aria-modal="true" aria-labelledby="issue-report-title">
<p className="eyebrow">Dungeon feedback</p> <p className="eyebrow">{bossTest ? "Boss feedback" : "Dungeon feedback"}</p>
<h2 id="issue-report-title">Report an issue</h2> <h2 id="issue-report-title">{bossTest ? "Submit a boss report" : "Report an issue"}</h2>
<p>{dungeonById(dungeonId)?.title ?? dungeonId}</p> <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> : ( {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)}> <form ref={form} onSubmit={(event) => void submit(event)}>
<label htmlFor="dungeon-issue-comment">Describe the issue</label> <label htmlFor="dungeon-issue-comment">Describe the issue</label>
@@ -62,7 +64,7 @@ export function DungeonIssueReportPanel() {
</form> </form>
)} )}
<ControllerButton controlId="report-back" selectedId={controller.selectedId} select={controller.select} <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> </section>
</div> </div>
); );
+1
View File
@@ -23,6 +23,7 @@ function ReportCard({ report, canManage, saving, onUpdate, selectedId, select }:
return ( return (
<article className={`dungeon-report-card ${report.status === "Closed" ? "is-closed" : ""}`}> <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> <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> <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> <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>} {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 { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom"; 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 { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import "./uiLayout.css"; import "./uiLayout.css";
@@ -22,23 +24,26 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
onChange?: (update: Partial<HudElementLayout>) => void; onChange?: (update: Partial<HudElementLayout>) => void;
}) { }) {
const overlay = useGameStore((state) => state.overlay); const overlay = useGameStore((state) => state.overlay);
const surface = useHudSurface();
const dualScreen = currentDisplayLayout() !== "single";
const editing = editingOverride ?? overlay === "ui-layout"; const editing = editingOverride ?? overlay === "ui-layout";
const state = useUiLayoutStore(); const state = useUiLayoutStore();
const layout = state.draft ?? state.preferences.current[currentUiDevice()]; const layout = state.draft ?? state.preferences.current[currentUiDevice()];
const element = elementLayout ?? layout.elements[id]; 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 [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [host, setHost] = useState<Element | null>(null); const [host, setHost] = useState<Element | null>(null);
const drag = useRef<{ pointerId: number; dx: number; dy: number } | 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 [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 fallback = FALLBACKS[id] ?? [.5, .35, 250];
const position = element.position ?? (bottomAnchor !== undefined const position = element.position ?? (bottomAnchor !== undefined
? { x: .5, y: 1 - bottomAnchor / Math.max(1, bounds.height) } ? { x: .5, y: 1 - bottomAnchor / Math.max(1, bounds.height) }
: origin ?? { x: fallback[0], y: fallback[1] }); : origin ?? { x: fallback[0], y: fallback[1] });
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2]; 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(() => { useLayoutEffect(() => {
if (positioned || !ref.current) return; if (positioned || !ref.current) return;
@@ -66,18 +71,19 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
const observer = new ResizeObserver(measure); const observer = new ResizeObserver(measure);
observer.observe(node); observer.observe(host); measure(); observer.observe(node); observer.observe(host); measure();
return () => observer.disconnect(); return () => observer.disconnect();
}, [positioned, host, hostSelector]); }, [positioned, host, hostSelector, screen]);
const updatePosition = (x: number, y: number) => { 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 }; const update = { position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width };
if (onChange) onChange(update); 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 } } })); 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 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 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 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} 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 }); } }}> onClickCapture={(event) => { if (editing && !id.startsWith("bar-")) { event.preventDefault(); event.stopPropagation(); useUiLayoutStore.setState({ selectedElement: id }); } }}>
{children} {children}
{editing && <button type="button" className="editable-hud__handle" aria-label={`Move ${HUD_ELEMENTS[id]}`} title="Drag to move; arrow keys nudge" {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); updatePosition(position.x + delta[0] * .01, position.y + delta[1] * .01);
}}>{HUD_ELEMENTS[id]}{!element.visible ? " · Hidden" : ""}</button>} }}>{HUD_ELEMENTS[id]}{!element.visible ? " · Hidden" : ""}</button>}
</div>; </div>;
if (dualScreen && screen !== surface.surface) return null;
return positioned && host ? createPortal(content, host) : content; return positioned && host ? createPortal(content, host) : content;
} }
+27 -9
View File
@@ -1,4 +1,7 @@
import { useInputBindingLabels } from "../input/useInputBindingLabels"; import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { HudSurfaceContext } from "./HudSurface";
import type { HudElementId, HudElementLayout } from "../game/uiLayout";
import { DungeonInteractionPrompt } from "./DungeonInteractionPrompt";
import { import {
useCallback, useCallback,
useEffect, 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 bindingLabel = useInputBindingLabels();
const ui = useUiLayoutStore(); const ui = useUiLayoutStore();
const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars; 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 runProgress = useDungeonRunStore((state) => state.progress);
const finalBossDefeated = runProgress?.dungeonId === activeDungeonId && runProgress.finishedAt !== null; const finalBossDefeated = runProgress?.dungeonId === activeDungeonId && runProgress.finishedAt !== null;
const dungeonComplete = gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId)); const dungeonComplete = completion ?? (gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId)));
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => setClock(Date.now()), 100); 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, disabled: talentLocked || level < ability.unlockLevel || health <= 0 || remaining > 0 || Boolean(activeCast) || cannotPay,
lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined, lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined,
target: ability.target, target: ability.target,
cooldownSeconds: ability.cooldownMs / 1_000,
castSeconds: ability.castTimeMs / 1_000,
range: ability.range.max,
talentLocked,
}; };
return [catalogAbility.id, item]; return [catalogAbility.id, item];
})), [assignedAbilities, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]); })), [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 ( 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 /> <ShelvedHud />
<DpsMeter /> <DpsMeter />
<BossLootWindow /> {surface === "top" && <BossLootWindow />}
<EditableHudElement id="interaction"><DungeonInteractionPrompt interaction={interaction} onInteract={onInteract} /></EditableHudElement>
<EditableHudElement id="completion">{dungeonComplete ? ( <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} finalBossOnly={!activeDungeonRunIsComplete(activeDungeonId)} durationMs={runProgress?.timed ? runProgress.durationMs : null}
onResults={finalBossDefeated ? () => useGameStore.getState().openOverlay("dungeon-results") : undefined} /> onResults={finalBossDefeated ? () => useGameStore.getState().openOverlay("dungeon-results") : undefined} />
) : null}</EditableHudElement> ) : null}</EditableHudElement>
@@ -409,7 +426,7 @@ export function Hud() {
<EditableHudElement id="party"><PartyFrames /></EditableHudElement> <EditableHudElement id="party"><PartyFrames /></EditableHudElement>
<EditableHudElement id="summons"><SummonFrames now={clock} /></EditableHudElement> <EditableHudElement id="summons"><SummonFrames now={clock} /></EditableHudElement>
{pendingGroundTarget ? ( {surface === "top" && pendingGroundTarget ? (
<section className="ground-target-reticle" role="status" aria-live="polite"> <section className="ground-target-reticle" role="status" aria-live="polite">
<div aria-hidden="true"><i /><b /></div> <div aria-hidden="true"><i /><b /></div>
<strong>Place ground effect</strong> <strong>Place ground effect</strong>
@@ -507,7 +524,7 @@ export function Hud() {
items={actionItems} items={actionItems}
activeLayer={actionLayer} activeLayer={actionLayer}
onActivate={castBinding} onActivate={castBinding}
inputMode={inputMode} inputMode={surface === "bottom" ? "gamepad" : inputMode}
feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null} feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null}
/></EditableHudElement>} /></EditableHudElement>}
</div> </div>
@@ -527,8 +544,8 @@ export function Hud() {
].map((asset) => asset.fileName).join(" and ")}.`}</span> ].map((asset) => asset.fileName).join(" and ")}.`}</span>
</div> </div>
)}</EditableHudElement> )}</EditableHudElement>
<LoadingOverlay /> {surface === "top" && <LoadingOverlay />}
{health <= 0 ? ( {surface === "top" && health <= 0 ? (
<DefeatedPrompt <DefeatedPrompt
gameMode={gameMode} gameMode={gameMode}
resurrectionCharges={resurrectionCharges} resurrectionCharges={resurrectionCharges}
@@ -539,5 +556,6 @@ export function Hud() {
/> />
) : null} ) : null}
</div> </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}`), "Insert", "Home", "End", "PageUp", "PageDown", ...Array.from({ length: 10 }, (_, i) => `Numpad${i}`),
"NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide", "NumpadDecimal", "NumpadEnter", "NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide", "NumpadDecimal", "NumpadEnter",
]; ];
function KeybindsDialog() { function KeybindsDialog({ onClose }: { onClose?: () => void }) {
const [device, setDevice] = useState<InputDevice>(() => defaultInputDevice(Capacitor.isNativePlatform(), currentUiDevice() === "mobile")); const [device, setDevice] = useState<InputDevice>(() => defaultInputDevice(Capacitor.isNativePlatform(), currentUiDevice() === "mobile"));
const [draft, setDraft] = useState(() => useUiLayoutStore.getState().preferences); const [draft, setDraft] = useState(() => useUiLayoutStore.getState().preferences);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -57,7 +57,7 @@ function KeybindsDialog() {
const shown = INPUT_ACTIONS.filter((entry) => (group === "All actions" || entry.group === group) const shown = INPUT_ACTIONS.filter((entry) => (group === "All actions" || entry.group === group)
&& (!conflictsOnly || conflicts.some((conflict) => conflict.id === entry.id)) && (!conflictsOnly || conflicts.some((conflict) => conflict.id === entry.id))
&& `${entry.label} ${detail(entry.id)} ${inputBindingLabel(device, bindings[entry.id])}`.toLowerCase().includes(search.toLowerCase())); && `${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 endCapture = () => { setListening(false); setCapturingInput(false); };
const cancelEdit = () => { endCapture(); setEditing(null); setPending(null); setResetPending(false); }; const cancelEdit = () => { endCapture(); setEditing(null); setPending(null); setResetPending(false); };
const apply = (id: string, key: string | null) => { const apply = (id: string, key: string | null) => {
@@ -211,7 +211,7 @@ function KeybindsDialog() {
</section> </section>
</div>; </div>;
} }
export function KeybindsPanel() { export function KeybindsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
const open = useGameStore((state) => state.overlay === "keybinds"); const storeOpen = useGameStore((state) => state.overlay === "keybinds");
return open ? <KeybindsDialog /> : null; 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 { OnlineGroupPanel } from "./OnlineGroupPanel";
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures"; import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { DungeonReportsScreen } from "./DungeonReportsScreen"; import { DungeonReportsScreen } from "./DungeonReportsScreen";
import { MainMenuCharacterEditor, type MainMenuEditor } from "./MainMenuCharacterEditor";
const BossCardCollectionScreen = lazy(() => import("./BossCardCollection")); const BossCardCollectionScreen = lazy(() => import("./BossCardCollection"));
const BossTestingScreen = lazy(() => import("./BossTestingScreen").then((module) => ({ default: module.BossTestingScreen })));
const GAME_MODES = [ const GAME_MODES = [
{ {
@@ -68,16 +70,22 @@ export function MainMenuScreen() {
const [contentManagerOpen, setContentManagerOpen] = useState(false); const [contentManagerOpen, setContentManagerOpen] = useState(false);
const [reportsOpen, setReportsOpen] = useState(false); const [reportsOpen, setReportsOpen] = useState(false);
const [cardsOpen, setCardsOpen] = 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 (cardsOpen) return <Suspense fallback={<p role="status">Opening collection log…</p>}><BossCardCollectionScreen onClose={() => setCardsOpen(false)} /></Suspense>;
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />; if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(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 onOpenDownloads: () => void;
readonly onOpenReports: () => void; readonly onOpenReports: () => void;
readonly onOpenCards: () => void; readonly onOpenCards: () => void;
readonly onOpenBossTesting: () => void;
readonly onOpenEditor: (editor: MainMenuEditor) => void;
}) { }) {
const activeCharacter = useShellStore((state) => state.activeCharacter); const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter); const rosterCharacter = useShellStore(selectedCharacter);
@@ -118,11 +126,17 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
run: returnToCharacters, run: returnToCharacters,
neighbors: { up: "mode-dungeons", left: "mode-dungeons" }, 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 }] : []), ...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
{ id: "mode-reports", run: onOpenReports }, { id: "mode-reports", run: onOpenReports },
{ id: "mode-cards", run: onOpenCards }, { 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, { const controller = useMenuController(actions, {
initialId: "mode-dungeons", initialId: "mode-dungeons",
onBack: returnToCharacters, 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> <p>Select a destination. More adventures will unlock here as they are built.</p>
</header> </header>
<OnlineGroupPanel /> <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} <ControllerButton controlId="mode-cards" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenCards}> 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> <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 { useMenuController, type MenuAction } from "../input/useMenuController";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel"; import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton"; import { ControllerButton } from "./ControllerButton";
import { AbilityTooltipTarget } from "./AbilityTooltip";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface"; import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel"; import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore"; import { useOnlineGroupStore } from "../app/onlineGroupStore";
@@ -413,6 +414,7 @@ export function ManastormSelectScreen() {
const capped = !selected const capped = !selected
&& selectedSpellIds.length >= spellLoadoutModel.maxSelections; && selectedSpellIds.length >= spellLoadoutModel.maxSelections;
return ( return (
<AbilityTooltipTarget key={spell.id} ability={spell}>
<ControllerButton <ControllerButton
key={spell.id} key={spell.id}
controlId={`manastorm-spell-${spell.id}`} controlId={`manastorm-spell-${spell.id}`}
@@ -420,8 +422,8 @@ export function ManastormSelectScreen() {
select={controller.select} select={controller.select}
className={`manastorm-spell-option ${selected ? "is-active" : ""}`} className={`manastorm-spell-option ${selected ? "is-active" : ""}`}
type="button" type="button"
disabled={capped} aria-disabled={capped}
onClick={() => toggleSpell(spell.id)} onClick={() => { if (!capped) toggleSpell(spell.id); }}
title={`${spell.description} · Client Spell.dbc ID ${spell.dbcSpellId}`} title={`${spell.description} · Client Spell.dbc ID ${spell.dbcSpellId}`}
> >
<img src={spell.icon} alt="" aria-hidden="true" /> <img src={spell.icon} alt="" aria-hidden="true" />
@@ -430,6 +432,7 @@ export function ManastormSelectScreen() {
<small>Spell {spell.dbcSpellId}</small> <small>Spell {spell.dbcSpellId}</small>
</span> </span>
</ControllerButton> </ControllerButton>
</AbilityTooltipTarget>
); );
})} })}
{!spellLoadoutModel?.options.length ? ( {!spellLoadoutModel?.options.length ? (
+3 -1
View File
@@ -20,6 +20,7 @@ import { auraDefinitionIsDispellableDebuff } from "./auraDisplay";
import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../game/partyResurrection"; import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../game/partyResurrection";
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination"; import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { useHudSurface } from "./HudSurface";
const PARTY_COMMANDS: readonly { const PARTY_COMMANDS: readonly {
command: PartyCommand; command: PartyCommand;
@@ -212,6 +213,7 @@ function OnlineMemberFrame({
/** Compact roster for the player plus the selected number of targetable AI allies. */ /** Compact roster for the player plus the selected number of targetable AI allies. */
export function PartyFrames() { export function PartyFrames() {
const { onResurrect } = useHudSurface();
const character = useShellStore((state) => state.activeCharacter); const character = useShellStore((state) => state.activeCharacter);
const playerRole = useShellStore((state) => state.activeDungeonRole); const playerRole = useShellStore((state) => state.activeDungeonRole);
const playerHealth = useCombatStore((state) => state.health); const playerHealth = useCombatStore((state) => state.health);
@@ -342,7 +344,7 @@ export function PartyFrames() {
title={bossCombat ? "Unavailable during boss combat." : !onlineSessionOwnsWorldSimulation() title={bossCombat ? "Unavailable during boss combat." : !onlineSessionOwnsWorldSimulation()
? "Only the party's simulation host can resurrect NPC teammates." ? "Only the party's simulation host can resurrect NPC teammates."
: "Resurrect fallen NPC teammates at the selected checkpoint. They will return to you."} : "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> <strong>Rez dead members</strong>
<small>{bossCombat ? "Unavailable during boss combat" : !onlineSessionOwnsWorldSimulation() <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 { useInputBindingLabels } from "../input/useInputBindingLabels";
import { INPUT_ACTION_BY_ID } from "../input/keybinds"; import { INPUT_ACTION_BY_ID } from "../input/keybinds";
import { DungeonIssueReportPanel } from "./DungeonIssueReportPanel"; import { DungeonIssueReportPanel } from "./DungeonIssueReportPanel";
import { BossTestingReviewPanel } from "./BossTestingReviewPanel";
function PauseDialog() { function PauseDialog() {
const bindingLabel = useInputBindingLabels(); const bindingLabel = useInputBindingLabels();
@@ -14,6 +15,7 @@ function PauseDialog() {
const openOverlay = useGameStore((state) => state.openOverlay); const openOverlay = useGameStore((state) => state.openOverlay);
const reset = useGameStore((state) => state.resetAtEntrance); const reset = useGameStore((state) => state.resetAtEntrance);
const gameMode = useGameStore((state) => state.gameMode); const gameMode = useGameStore((state) => state.gameMode);
const bossTest = useGameStore((state) => state.bossTest);
const activeCharacter = useShellStore((state) => state.activeCharacter); const activeCharacter = useShellStore((state) => state.activeCharacter);
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu); const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
@@ -50,12 +52,13 @@ function PauseDialog() {
{ id: "pause-options", run: () => openOverlay("options") }, { id: "pause-options", run: () => openOverlay("options") },
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") }, { id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
...(gameMode === "dungeon" ? [{ id: "pause-report-issue", run: () => openOverlay("report-issue") }] : []), ...(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") }] : []), ...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
...(gameMode === "manastorm" ...(gameMode === "manastorm"
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }] ? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
: [{ id: "pause-entrance", run: returnToEntrance }]), : [{ id: "pause-entrance", run: returnToEntrance }]),
{ id: "pause-main-menu", run: leaveDungeon }, { 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 controller = useMenuController(actions, { initialId: "pause-resume", onBack: resume });
const menuButton = (id: string, label: string, run: () => void, primary = false) => ( const menuButton = (id: string, label: string, run: () => void, primary = false) => (
@@ -73,10 +76,11 @@ function PauseDialog() {
<div className="modal-backdrop" role="presentation"> <div className="modal-backdrop" role="presentation">
<section className="pause-menu" role="dialog" aria-modal="true" aria-labelledby="pause-title"> <section className="pause-menu" role="dialog" aria-modal="true" aria-labelledby="pause-title">
<p className="eyebrow">Healer Man paused</p> <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> <p>Resume play or open any character and system menu from this hub.</p>
<div className="pause-menu__actions"> <div className="pause-menu__actions">
{menuButton("pause-resume", "Resume expedition", resume, true)} {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-party", "Party management", () => openOverlay("party"))}
{menuButton("pause-inventory", "Inventory & equipment", () => openOverlay("inventory"))} {menuButton("pause-inventory", "Inventory & equipment", () => openOverlay("inventory"))}
{menuButton("pause-boss-cards", "Boss card collection", () => openOverlay("boss-cards"))} {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} {isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
{gameMode === "manastorm" {gameMode === "manastorm"
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm) ? 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)} {menuButton("pause-main-menu", "Return to main menu", leaveDungeon)}
</div> </div>
<dl className="controls-list"> <dl className="controls-list">
@@ -110,5 +114,6 @@ function PauseDialog() {
export function PauseMenu() { export function PauseMenu() {
const overlay = useGameStore((state) => state.overlay); const overlay = useGameStore((state) => state.overlay);
if (overlay === "report-issue") return <DungeonIssueReportPanel />; if (overlay === "report-issue") return <DungeonIssueReportPanel />;
if (overlay === "boss-test-review") return <BossTestingReviewPanel />;
return overlay === "pause" ? <PauseDialog /> : null; return overlay === "pause" ? <PauseDialog /> : null;
} }
+5 -4
View File
@@ -20,9 +20,10 @@ function visibleGroup(ability: AbilityDefinition, primaryClassId: string): (type
return "primary"; return "primary";
} }
export function RomSkillsPanel() { export function RomSkillsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
const open = useGameStore((state) => state.overlay === "talents"); const storeOpen = useGameStore((state) => state.overlay === "talents");
const close = useGameStore((state) => state.closeOverlay); const closeOverlay = useGameStore((state) => state.closeOverlay);
const close = onClose ?? closeOverlay;
const classId = useCombatStore((state) => state.classId); const classId = useCombatStore((state) => state.classId);
const secondaryClassId = useCombatStore((state) => state.secondaryClassId); const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
const level = useCombatStore((state) => state.level); const level = useCombatStore((state) => state.level);
@@ -32,7 +33,7 @@ export function RomSkillsPanel() {
{ id: "rom-skills-close", run: close }, { id: "rom-skills-close", run: close },
], [abilities, close]); ], [abilities, close]);
const controller = useMenuController(actions, { initialId: abilities[0] ? `rom-skill-${abilities[0].id}` : "rom-skills-close", onBack: 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 primary = classById(classId);
const secondary = secondaryClassId ? classById(secondaryClassId) : null; 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 { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController"; import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton"; import { ControllerButton } from "./ControllerButton";
import { AbilityTooltipTarget } from "./AbilityTooltip";
export interface SpellbookAbilityView { export interface SpellbookAbilityView {
id: string; id: string;
@@ -149,6 +150,10 @@ function SpellbookDialog({
? "Learn in talents" ? "Learn in talents"
: `Unlocks at level ${ability.level}`; : `Unlocks at level ${ability.level}`;
return ( return (
<AbilityTooltipTarget key={ability.id} ability={{ ...ability,
lockedUntilLevel: ability.level > playerLevel ? ability.level : undefined,
talentLocked: ability.talentLocked && !ability.known,
}}>
<ControllerButton <ControllerButton
key={ability.id} key={ability.id}
controlId={`spell-${ability.id}`} controlId={`spell-${ability.id}`}
@@ -184,6 +189,7 @@ function SpellbookDialog({
? <em>{ability.talentLocked && !ability.known ? "Talent" : `Level ${ability.level}`}</em> ? <em>{ability.talentLocked && !ability.known ? "Talent" : `Level ${ability.level}`}</em>
: <em className="spellbook-ability__known">{ability.passive ? "Passive" : "Known"}</em>} : <em className="spellbook-ability__known">{ability.passive ? "Passive" : "Known"}</em>}
</ControllerButton> </ControllerButton>
</AbilityTooltipTarget>
); );
})} })}
{!visibleAbilities.length && ( {!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(); 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 classId = useCombatStore((state) => state.classId);
const level = useCombatStore((state) => state.level); const level = useCombatStore((state) => state.level);
const talentRanks = useCombatStore((state) => state.talentRanks); 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); }} onInvest={(nodeId) => { if (!readOnly) useCombatStore.getState().allocateTalent(nodeId); }}
onReset={() => { if (!readOnly) useCombatStore.getState().resetTalents(); }} onReset={() => { if (!readOnly) useCombatStore.getState().resetTalents(); }}
readOnly={readOnly} readOnly={readOnly}
open={open}
onClose={onClose}
/>; />;
} }
+2 -2
View File
@@ -242,7 +242,7 @@ function TalentsDialog({
<><strong>{availablePoints}</strong><span>points available</span></> <><strong>{availablePoints}</strong><span>points available</span></>
)} )}
</div> </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> </header>
<p className="talents-panel__intro"> <p className="talents-panel__intro">
@@ -436,7 +436,7 @@ function TalentsDialog({
>Reset talents</ControllerButton> >Reset talents</ControllerButton>
</div> </div>
<footer className="game-panel__footer talents-panel__footer"> <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> </footer>
</section> </section>
</div> </div>
+38 -12
View File
@@ -2,15 +2,18 @@ import { useEffect, useRef, useState } from "react";
import { useShellStore } from "../app/shellStore"; import { useShellStore } from "../app/shellStore";
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient"; import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
import { useCombatStore } from "../game/combatStore"; import { useCombatStore } from "../game/combatStore";
import { defaultActionBarForCharacter } from "../game/abilityCatalog"; import { abilityAtLevel, defaultActionBarForCharacter } from "../game/abilityCatalog";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { import {
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout, BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout,
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice, exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
resolveHudScreen, type HudScreen,
} from "../game/uiLayout"; } from "../game/uiLayout";
import { INPUT_ACTION_BY_ID, conflictingInputActions } from "../input/keybinds"; import { INPUT_ACTION_BY_ID, conflictingInputActions } from "../input/keybinds";
import { profileInputBindings } from "../game/keybindPreferences"; import { profileInputBindings } from "../game/keybindPreferences";
import { useUiLayoutStore } from "../game/uiLayoutStore"; import { useUiLayoutStore } from "../game/uiLayoutStore";
import { AbilityTooltipTarget } from "./AbilityTooltip";
import { currentDisplayLayout } from "../platform/displayLayout";
export function UiLayoutEditor() { export function UiLayoutEditor() {
const open = useGameStore((state) => state.overlay === "ui-layout"); const open = useGameStore((state) => state.overlay === "ui-layout");
@@ -18,6 +21,8 @@ export function UiLayoutEditor() {
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const cloudSave = useCloudSaveStatus(); const cloudSave = useCloudSaveStatus();
const abilities = useCombatStore((state) => state.abilities); 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 [tab, setTab] = useState<UiDevice>(ui.device);
const [name, setName] = useState("My UI"); const [name, setName] = useState("My UI");
const [slot, setSlot] = useState(0); const [slot, setSlot] = useState(0);
@@ -30,13 +35,13 @@ export function UiLayoutEditor() {
const file = useRef<HTMLInputElement>(null); const file = useRef<HTMLInputElement>(null);
const wasOpen = useRef(false); const wasOpen = useRef(false);
useEffect(() => { useEffect(() => {
if (open && !wasOpen.current) { if (open && (!wasOpen.current || !useUiLayoutStore.getState().draft)) {
useUiLayoutStore.getState().begin(); useUiLayoutStore.getState().begin();
setTab(useUiLayoutStore.getState().device); setMessage(""); setTab(useUiLayoutStore.getState().device); setMessage("");
} }
if (!open && wasOpen.current) useUiLayoutStore.getState().finish(false); if (!open && wasOpen.current) useUiLayoutStore.getState().finish(false);
wasOpen.current = open; wasOpen.current = open;
}, [open]); }, [open, ui.draft !== null]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
@@ -69,6 +74,7 @@ export function UiLayoutEditor() {
}, [open]); }, [open]);
if (!open || !ui.draft) return null; if (!open || !ui.draft) return null;
const draft = ui.draft; 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 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 saveAndClose = () => attempt(() => { ui.finish(true); useGameStore.getState().closeOverlay(); });
const validName = name.trim().length > 0; 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={() => 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> <button type="button" onClick={() => setCollapsed(!collapsed)} aria-label={collapsed ? "Expand editor" : "Collapse editor"}>{collapsed ? "+" : "−"}</button></header>
{!collapsed && <div className="ui-layout-editor__body"> {!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> <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__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> <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>} })}>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> <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>
<details><summary>Elements · move / hide</summary> <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" : ""}`}> {(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> 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> <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>)} </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>
<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, <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> }))} />Show DPS meter</label>
<label>Screen<select aria-label="DPS meter screen" value={draft.dpsMeterDisplay} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, <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, 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="auto">Automatic · PC lower left / Thor bottom</option>
<option value="main">Main screen</option><option value="companion">Companion screen</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> {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> <button type="button" onClick={() => setAbility(null)}>Clear ability</button>
<label>Find ability<input value={search} onChange={(event) => setSearch(event.target.value)} /></label> <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>} </div>}
</>} </>}
</details>} </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; }
}