diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index 83cc78d1..aa1db759 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -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, 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 - 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-end.png`. Cloud upload and physical 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`. diff --git a/android/app/src/main/java/com/phenomrom/healerman/ThorDualScreenPlugin.java b/android/app/src/main/java/com/phenomrom/healerman/ThorDualScreenPlugin.java index 2e5ef067..4ed2efa8 100644 --- a/android/app/src/main/java/com/phenomrom/healerman/ThorDualScreenPlugin.java +++ b/android/app/src/main/java/com/phenomrom/healerman/ThorDualScreenPlugin.java @@ -139,6 +139,8 @@ public class ThorDualScreenPlugin extends Plugin implements DisplayManager.Displ if (forceEnabled) scheduleEnsurePresentation(300L); }); presentation.show(); + bridge.getWebView().getSettings().setUseWideViewPort(true); + bridge.getWebView().getSettings().setLoadWithOverviewMode(true); bridge.getWebView().loadUrl(currentUrl); } @@ -279,6 +281,8 @@ public class ThorDualScreenPlugin extends Plugin implements DisplayManager.Displ WebSettings settings = webView.getSettings(); settings.setJavaScriptEnabled(true); settings.setDomStorageEnabled(true); + settings.setUseWideViewPort(true); + settings.setLoadWithOverviewMode(true); settings.setDatabaseEnabled(true); settings.setMediaPlaybackRequiresUserGesture(false); settings.setAllowFileAccess(true); diff --git a/package-lock.json b/package-lock.json index 17f49b7b..f9e784f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.42", + "version": "0.1.43", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.42", + "version": "0.1.43", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 21883f07..abb3e9f3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.42", + "version": "0.1.43", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index 0d8caf11..6b517169 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.42", - "versionCode": 1042 + "versionName": "0.1.43", + "versionCode": 1043 } diff --git a/scripts/playtest-boss-testing.cjs b/scripts/playtest-boss-testing.cjs new file mode 100644 index 00000000..6a08226e --- /dev/null +++ b/scripts/playtest-boss-testing.cjs @@ -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; }); diff --git a/scripts/playtest-thor-ui.cjs b/scripts/playtest-thor-ui.cjs new file mode 100644 index 00000000..f9b06ac4 --- /dev/null +++ b/scripts/playtest-thor-ui.cjs @@ -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; }); diff --git a/server/dungeon-reports.mjs b/server/dungeon-reports.mjs index ea135e4d..2f1c2f4a 100644 --- a/server/dungeon-reports.mjs +++ b/server/dungeon-reports.mjs @@ -19,11 +19,22 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp revision INTEGER NOT NULL DEFAULT 0 ) STRICT; 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(` INSERT INTO dungeon_reports ( - id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at - ) VALUES (?, ?, ?, 'Submitted', ?, ?, ?, ?) + id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at, boss_key, boss_name + ) VALUES (?, ?, ?, 'Submitted', ?, ?, ?, ?, ?, ?) `); const byId = database.prepare("SELECT * FROM dungeon_reports WHERE id = ?"); const list = database.prepare(` @@ -45,6 +56,14 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp function validateStatus(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) { return { id: row.id, @@ -56,10 +75,30 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp updatedAt: Number(row.updated_at), updatedBy: row.updated_by_username, revision: Number(row.revision), + ...(row.boss_key ? { bossKey: row.boss_key, bossName: row.boss_name } : {}), }; } 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 } = {}) { if (status !== null) validateStatus(status); if (dungeonId !== null) validateDungeon(dungeonId); @@ -70,13 +109,14 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp }, submitDungeonReport(account, input) { 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) { throw new ErrorType("Describe the issue in 1-500 characters.", 400, "invalid_report_comment"); } const id = randomUUID(); const now = Date.now(); 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)); }, updateDungeonReport(account, id, input) { diff --git a/server/dungeon-reports.test.mjs b/server/dungeon-reports.test.mjs index 070daf9b..dbc03678 100644 --- a/server/dungeon-reports.test.mjs +++ b/server/dungeon-reports.test.mjs @@ -69,6 +69,30 @@ test("persists reports and status changes across database restarts", async () => } 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 () => { const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-api-")); 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 { 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 created = await request("", "POST", { dungeonId: "stormwind-stockade", comment: "Issue via API" }, player.token); assert.equal(created.status, 201); diff --git a/server/server.mjs b/server/server.mjs index 7bbfa4d0..9255f9fc 100644 --- a/server/server.mjs +++ b/server/server.mjs @@ -261,6 +261,19 @@ export function createGameServer(options = {}) { json(response, 200, gameDatabase.getDungeonRun(dungeonRunMatch[1])); 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") { json(response, 200, gameDatabase.listDungeonReports({ status: url.searchParams.get("status"), diff --git a/src/GameRuntime.tsx b/src/GameRuntime.tsx index dc823ebc..4771e321 100644 --- a/src/GameRuntime.tsx +++ b/src/GameRuntime.tsx @@ -1,4 +1,5 @@ import { useEffect } from "react"; +import { useShellStore } from "./app/shellStore"; import "./ui/uiLayout.css"; import { DualDisplayFrame } from "./components/DualDisplayFrame"; import { installInput, releasePointerLock } from "./game/inputManager"; @@ -7,7 +8,8 @@ import { connectDpsMeter } from "./game/dpsMeterStore"; import { useCombatStore } from "./game/combatStore"; import { releaseDungeonGltfCache } from "./game/dungeonAssetCache"; import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset"; -import { requireDungeonDefinition } from "./game/dungeonRegistry"; +import { bossTestingSourceDungeonId, clearBossTestingDungeons, requireDungeonDefinition } from "./game/dungeonRegistry"; +import { BossTestingBridge } from "./game/BossTestingBridge"; import { PartyBridge } from "./game/PartyBridge"; import { usePartyStore } from "./game/partyStore"; import { useGameStore } from "./game/store"; @@ -24,9 +26,7 @@ import { DungeonRunBridge } from "./game/DungeonRunBridge"; import { DungeonRunResults } from "./ui/DungeonRunResults"; import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge"; import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction"; -import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt"; import { UiLayoutEditor } from "./ui/UiLayoutEditor"; -import { EditableHudElement } from "./ui/EditableHudElement"; import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore"; import { useUiLayoutStore } from "./game/uiLayoutStore"; import { profileInputBindings } from "./game/keybindPreferences"; @@ -39,7 +39,7 @@ function InputBridge() { useEffect(() => installInput({ desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars, - inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results"].includes(useGameStore.getState().overlay ?? ""), + inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""), getBindings: (device) => { const preferences = useUiLayoutStore.getState().preferences; const bindings = profileInputBindings(preferences, device); @@ -103,6 +103,12 @@ export default function GameRuntime() { clearDungeonRuntimeCaches(); releaseDungeonGltfCache(activeDungeon); releaseShelvedGameRuntime(); + const sourceDungeonId = bossTestingSourceDungeonId(activeDungeon.id); + if (sourceDungeonId) { + useGameStore.setState({ activeDungeonId: sourceDungeonId, bossTest: null, gmTestMode: false }); + useShellStore.setState({ activeDungeonId: sourceDungeonId }); + } + clearBossTestingDungeons(); }, []); return ( + - diff --git a/src/app/accountRepository.test.ts b/src/app/accountRepository.test.ts index db90449c..abdb89b8 100644 --- a/src/app/accountRepository.test.ts +++ b/src/app/accountRepository.test.ts @@ -5,6 +5,10 @@ import { createLocalAccount, createOfflineSession, listCharacters, + loadAccountBossCards, + saveAccountBossCards, + replaceCharactersForOwner, + deleteCharacter, loadCombatPresentationSettings, loginLocalAccount, restoreSession, @@ -12,7 +16,6 @@ import { updateCharacterEquipment, updateCharacterDungeonRunCheckpoint, updateCharacterInventory, - updateCharacterBossCards, updateCharacterManastormProgress, updateCharacterProgression, updateCharacterSettings, @@ -213,7 +216,7 @@ describe("local profile repository", () => { })); expect(loadCombatPresentationSettings("offline-roster", storage)) .toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS); - expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6); + expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7); } const legacyV3Storage = new MemoryStorage(); @@ -382,18 +385,58 @@ describe("local profile repository", () => { }); }); - it("persists card copies and claim receipts per character across roster reloads", () => { + it("persists account card copies independently of character creation and deletion", () => { const storage = new MemoryStorage(); const offline = createOfflineSession(); const draft = { ownerId: offline.ownerId, name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } }; const character = createCharacter(draft, storage).character!; const other = createCharacter({ ...draft, name: "Other" }, storage).character!; const bossCards = { counts: { "wow:lady anacondra": { normal: 7, epic: 1 } }, lastClaims: { spawn: "kill:7" } }; - expect(updateCharacterBossCards(offline.ownerId, character.id, bossCards, storage)?.bossCards).toEqual(bossCards); + expect(saveAccountBossCards(offline.ownerId, bossCards, storage)).toEqual(bossCards); + expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards); + expect(loadAccountBossCards("different-account", storage)).toEqual({ counts: {}, lastClaims: {} }); const roster = listCharacters(offline.ownerId, storage); - expect(roster.find((entry) => entry.id === character.id)?.bossCards).toEqual(bossCards); - expect(roster.find((entry) => entry.id === other.id)?.bossCards).toEqual({ counts: {}, lastClaims: {} }); + expect(roster.find((entry) => entry.id === character.id)?.bossCards).toBeUndefined(); + expect(roster.find((entry) => entry.id === other.id)?.bossCards).toBeUndefined(); expect(roster.find((entry) => entry.id === character.id)?.inventory).toEqual([]); + deleteCharacter(offline.ownerId, character.id, storage); + deleteCharacter(offline.ownerId, other.id, storage); + expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards); + createCharacter({ ...draft, name: "Newcomer" }, storage); + expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards); + }); + + it("migrates and sums all legacy character logs once while retaining the newest receipts", () => { + const storage = new MemoryStorage(); + const draft = { ownerId: "one", name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } }; + const first = createCharacter(draft, storage).character!; + const second = createCharacter({ ...draft, name: "Other" }, storage).character!; + const separate = createCharacter({ ...draft, ownerId: "two" }, storage).character!; + const legacyCharacters = [ + { ...first, bossCards: { counts: { boss: { normal: 7, epic: 1 } }, lastClaims: { spawn: "100:1:spawn" } } }, + { ...second, bossCards: { counts: { boss: { normal: 3, rare: 2 } }, lastClaims: { spawn: "200:2:spawn" } } }, + ]; + storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({ + version: 6, accounts: [], rosters: { one: legacyCharacters, two: [{ ...separate, bossCards: { counts: { boss: { normal: 4 } }, lastClaims: {} } }] }, + })); + const expected = { counts: { boss: { normal: 10, rare: 2, epic: 1 } }, lastClaims: { spawn: "200:2:spawn" } }; + expect(loadAccountBossCards("one", storage)).toEqual(expected); + expect(loadAccountBossCards("one", storage)).toEqual(expected); + expect(loadAccountBossCards("two", storage).counts.boss?.normal).toBe(4); + const database = JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!); + expect(database.version).toBe(7); + expect(database.rosters.one.every((entry: { bossCards?: unknown }) => entry.bossCards === undefined)).toBe(true); + // An old cloud roster can be downloaded again without repeating the migration. + replaceCharactersForOwner("one", legacyCharacters, storage); + replaceCharactersForOwner("one", legacyCharacters, storage); + expect(loadAccountBossCards("one", storage)).toEqual(expected); + }); + + it("restores account logs from cloud without overwriting higher cached counts", () => { + const storage = new MemoryStorage(); + saveAccountBossCards("one", { counts: { boss: { normal: 8 } }, lastClaims: { spawn: "200:2:spawn" } }, storage); + replaceCharactersForOwner("one", [], storage, { counts: { boss: { normal: 7, rare: 1 } }, lastClaims: { spawn: "100:1:spawn" } }); + expect(loadAccountBossCards("one", storage)).toEqual({ counts: { boss: { normal: 8, rare: 1 } }, lastClaims: { spawn: "200:2:spawn" } }); }); it("persists threat-meter visibility and position per character", () => { diff --git a/src/app/accountRepository.ts b/src/app/accountRepository.ts index 40c7e668..4ffa055e 100644 --- a/src/app/accountRepository.ts +++ b/src/app/accountRepository.ts @@ -1,5 +1,5 @@ import type { CharacterProfile, PlayerSession } from "./types"; -import { normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards"; +import { mergeBossCardCollections, normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards"; import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout"; import { contentCategoryForClass, @@ -62,9 +62,10 @@ interface LocalAccount { } interface ProfileDatabase { - version: 6; + version: 7; accounts: LocalAccount[]; rosters: Record; + bossCardsByOwner: Record; presentationSettingsByOwner: Record; uiPreferencesByOwner?: Record; } @@ -105,7 +106,7 @@ function browserSessionStorage(): StorageLike | null { } function emptyDatabase(): ProfileDatabase { - return { version: 6, accounts: [], rosters: {}, presentationSettingsByOwner: {} }; + return { version: 7, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} }; } function isCharacterProfile(value: unknown): value is CharacterProfile { @@ -121,6 +122,9 @@ function isCharacterProfile(value: unknown): value is CharacterProfile { } function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile { + const profile = { ...value }; + // Cards now belong to the owner; legacy fields are consumed before normalization. + delete profile.bossCards; const legacy = value as CharacterProfile & { actionBindings?: unknown; experience?: unknown; @@ -180,7 +184,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile { : undefined; const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint); return { - ...value, + ...profile, level, experience, talentRanks, @@ -191,7 +195,6 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile { actionLoadouts, inventory, equipment, - bossCards: normalizeBossCardCollection(value.bossCards), manastormProgress, dungeonRunCheckpoint, settings, @@ -205,10 +208,11 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof version?: number; accounts?: unknown; rosters?: unknown; + bossCardsByOwner?: unknown; presentationSettingsByOwner?: unknown; uiPreferencesByOwner?: unknown; }) | null; - if (!parsed || ![1, 2, 3, 4, 5, 6].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") { + if (!parsed || ![1, 2, 3, 4, 5, 6, 7].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") { return emptyDatabase(); } const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean( @@ -220,12 +224,19 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof && typeof account.verifier.salt === "string" && typeof account.verifier.digest === "string", )); - const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record).map(([ownerId, roster]) => [ - ownerId, - Array.isArray(roster) - ? roster.filter(isCharacterProfile).map(normalizeCharacterProfile).slice(0, MAX_CHARACTERS) - : [], - ])); + const rawCards = parsed.bossCardsByOwner && typeof parsed.bossCardsByOwner === "object" && !Array.isArray(parsed.bossCardsByOwner) + ? parsed.bossCardsByOwner : {}; + const bossCardsByOwner = Object.fromEntries(Object.entries(rawCards).map(([owner, cards]) => [owner, normalizeBossCardCollection(cards)])); + let cardsMigrated = false; + const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record).map(([ownerId, roster]) => { + const characters = Array.isArray(roster) ? roster.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : []; + if (characters.some((character) => character.bossCards !== undefined)) { + const legacyCards = mergeBossCardCollections(characters.map((character) => character.bossCards), "sum"); + bossCardsByOwner[ownerId] = mergeBossCardCollections([bossCardsByOwner[ownerId], legacyCards]); + cardsMigrated = true; + } + return [ownerId, characters.map(normalizeCharacterProfile)]; + })); const rawPresentationSettings = parsed.presentationSettingsByOwner && typeof parsed.presentationSettingsByOwner === "object" && !Array.isArray(parsed.presentationSettingsByOwner) @@ -259,8 +270,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof ); const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {}; const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)])); - const database: ProfileDatabase = { version: 6, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner }; - if (parsed.version !== 6 || presentationSettingsMigrated) writeDatabase(database, storage); + const database: ProfileDatabase = { version: 7, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner }; + if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated) writeDatabase(database, storage); return database; } catch { return emptyDatabase(); @@ -270,7 +281,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void { storage?.setItem(DATABASE_KEY, JSON.stringify(database)); if (!storage || !rosterChangeListener) return; - for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.uiPreferencesByOwner ?? {})])) { + for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.bossCardsByOwner), ...Object.keys(database.uiPreferencesByOwner ?? {})])) { rosterChangeListener(ownerId, database.rosters[ownerId] ?? []); } } @@ -452,15 +463,19 @@ export function replaceCharactersForOwner( ownerId: string, characters: unknown, storage: StorageLike | null = browserLocalStorage(), + accountBossCards?: unknown, ): CharacterProfile[] { if (!storage) return []; const database = readDatabase(storage); - database.rosters[ownerId] = Array.isArray(characters) - ? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS).map((character) => normalizeCharacterProfile({ - ...character, - ownerId, - })) - : []; + const roster = Array.isArray(characters) ? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : []; + const importedCards = accountBossCards === undefined + ? mergeBossCardCollections(roster.map((character) => character.bossCards), "sum") + : normalizeBossCardCollection(accountBossCards); + database.bossCardsByOwner[ownerId] = mergeBossCardCollections([database.bossCardsByOwner[ownerId], importedCards]); + database.rosters[ownerId] = roster.map((character) => normalizeCharacterProfile({ + ...character, + ownerId, + })); writeDatabase(database, storage); return listCharacters(ownerId, storage); } @@ -730,22 +745,24 @@ export function updateCharacterEquipment( return updated; } -export function updateCharacterBossCards( +export function loadAccountBossCards( + ownerId: string, + storage: StorageLike | null = browserLocalStorage(), +): BossCardCollection { + return normalizeBossCardCollection(readDatabase(storage).bossCardsByOwner[ownerId]); +} + +export function saveAccountBossCards( ownerId: string, - characterId: string, bossCards: BossCardCollection, storage: StorageLike | null = browserLocalStorage(), -): CharacterProfile | null { - if (!storage) return null; +): BossCardCollection { + const normalized = normalizeBossCardCollection(bossCards); + if (!storage) return normalized; const database = readDatabase(storage); - let updated: CharacterProfile | null = null; - database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { - if (character.id !== characterId) return character; - updated = normalizeCharacterProfile({ ...character, bossCards }); - return updated; - }); - if (updated) writeDatabase(database, storage); - return updated; + database.bossCardsByOwner[ownerId] = normalized; + writeDatabase(database, storage); + return normalized; } export function updateCharacterSettings( diff --git a/src/app/bossCardAccountSync.test.ts b/src/app/bossCardAccountSync.test.ts new file mode 100644 index 00000000..3a60dbb9 --- /dev/null +++ b/src/app/bossCardAccountSync.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PlayerSession } from "./types"; + +function storage() { + const values = new Map(); + 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); + }); +}); diff --git a/src/app/bossTestingReviews.test.ts b/src/app/bossTestingReviews.test.ts new file mode 100644 index 00000000..1e6f8d12 --- /dev/null +++ b/src/app/bossTestingReviews.test.ts @@ -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 }); + }); +}); diff --git a/src/app/bossTestingReviews.ts b/src/app/bossTestingReviews.ts new file mode 100644 index 00000000..bf2e92b5 --- /dev/null +++ b/src/app/bossTestingReviews.ts @@ -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 = { + 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 { + 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; + } catch { return {}; } +} +function persist(reviews: Record) { + 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; + refresh: (session: PlayerSession | null) => Promise; + save: (boss: { readonly key: string; readonly sourceDungeonId: string; readonly name: string }, status: BossTestingStatus, + session: PlayerSession | null) => Promise; +} +export const useBossTestingReviews = create((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; } + }, +})); diff --git a/src/app/bossTestingRuntime.test.ts b/src/app/bossTestingRuntime.test.ts new file mode 100644 index 00000000..378c96a0 --- /dev/null +++ b/src/app/bossTestingRuntime.test.ts @@ -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/); + }); +}); diff --git a/src/app/bossTestingRuntime.ts b/src/app/bossTestingRuntime.ts new file mode 100644 index 00000000..80dcbbfb --- /dev/null +++ b/src/app/bossTestingRuntime.ts @@ -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 = Promise.resolve(); + +export async function prepareBossTesting(boss: TestableBoss): Promise { + 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; +} diff --git a/src/app/dungeonReports.ts b/src/app/dungeonReports.ts index 8eb8257b..073ff708 100644 --- a/src/app/dungeonReports.ts +++ b/src/app/dungeonReports.ts @@ -16,6 +16,8 @@ export interface DungeonReport { readonly updatedAt: number; readonly updatedBy: string | null; readonly revision: number; + readonly bossKey?: string; + readonly bossName?: string; } export interface DungeonReportSnapshot { @@ -40,10 +42,11 @@ export function fetchDungeonReports(): Promise { return requestOnlineJson("/api/dungeon-reports"); } -export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string): Promise { +export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string, + boss?: { readonly bossKey: string; readonly bossName: string }): Promise { const response = await requestOnlineJson<{ report: DungeonReport }>("/api/dungeon-reports", { method: "POST", - body: JSON.stringify({ dungeonId, comment }), + body: JSON.stringify({ dungeonId, comment, ...boss }), }, session?.kind === "account" ? session.accessToken : undefined); return response.report; } diff --git a/src/app/mainMenuCharacterEditing.test.ts b/src/app/mainMenuCharacterEditing.test.ts new file mode 100644 index 00000000..ee3ac9cd --- /dev/null +++ b/src/app/mainMenuCharacterEditing.test.ts @@ -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(); + 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({}); + }); +}); diff --git a/src/app/mainMenuCharacterEditing.ts b/src/app/mainMenuCharacterEditing.ts new file mode 100644 index 00000000..2ddd3b32 --- /dev/null +++ b/src/app/mainMenuCharacterEditing.ts @@ -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."); + } + }); +} diff --git a/src/app/onlineAccountClient.ts b/src/app/onlineAccountClient.ts index 0c68bef3..cb461ef4 100644 --- a/src/app/onlineAccountClient.ts +++ b/src/app/onlineAccountClient.ts @@ -5,6 +5,7 @@ import { replaceCharactersForOwner, setRosterChangeListener, loadUiPreferences, + loadAccountBossCards, saveUiPreferences, type AccountResult, } from "./accountRepository"; @@ -23,7 +24,7 @@ interface AuthResponse { interface CloudSaveResponse { revision: number; updatedAt: number | null; - data: { version?: number; characters?: unknown; uiPreferences?: unknown }; + data: { version?: number; characters?: unknown; uiPreferences?: unknown; bossCards?: unknown }; } interface MeResponse { @@ -106,11 +107,12 @@ function flushCloudSave(): void { pendingCharacters = null; if (!session?.accessToken || !characters) return; const uiPreferences = loadUiPreferences(session.ownerId); + const bossCards = loadAccountBossCards(session.ownerId); const sequence = saveSequence; saveQueue = saveQueue.then(async () => { await requestOnlineJson("/api/cloud-save", { method: "PUT", - body: JSON.stringify({ data: { version: 1, characters, uiPreferences } }), + body: JSON.stringify({ data: { version: 2, characters, uiPreferences, bossCards } }), }, session.accessToken); if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." }); }).catch((error) => { @@ -149,12 +151,18 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise("/api/cloud-save", {}, session.accessToken); suppressUpload = true; + let characters: CharacterProfile[]; try { saveUiPreferences(session.ownerId, cloud.data?.uiPreferences); - return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []); + characters = replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? [], undefined, cloud.data?.bossCards); } finally { suppressUpload = false; } + // Publish migrated logs even if the player has not earned another card yet. + if (JSON.stringify(cloud.data?.bossCards) !== JSON.stringify(loadAccountBossCards(session.ownerId))) { + queueCloudSave(session.ownerId, characters); + } + return characters; } export async function refreshOnlineSession(session: PlayerSession): Promise { diff --git a/src/app/romDualClassPersistence.test.ts b/src/app/romDualClassPersistence.test.ts index 2f54db3a..ea0cd235 100644 --- a/src/app/romDualClassPersistence.test.ts +++ b/src/app/romDualClassPersistence.test.ts @@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => { "priest:none": { "primary:face-bottom": "priest-flash-heal" }, }, }); - expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6); + expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7); }); it("locks selection to level ten and preserves separate bars through atomic swaps", () => { diff --git a/src/app/shellStore.ts b/src/app/shellStore.ts index e6fe4154..6ef6c987 100644 --- a/src/app/shellStore.ts +++ b/src/app/shellStore.ts @@ -5,13 +5,14 @@ import { createCharacter as persistCharacter, deleteCharacter as removeCharacter, listCharacters, + loadAccountBossCards, loadCombatPresentationSettings, restoreSession as restoreStoredSession, saveSession, updateCharacterActionBindings, updateCharacterEquipment, updateCharacterInventory, - updateCharacterBossCards, + saveAccountBossCards as persistAccountBossCards, updateCharacterManastormProgress, updateCharacterDungeonRunCheckpoint, updateCharacterProgression, @@ -39,7 +40,7 @@ import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from import type { DungeonId } from "../game/dungeonRegistry"; import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults"; import type { InventoryItem } from "../game/lootTypes"; -import type { BossCardCollection } from "../game/bossCards"; +import { emptyBossCardCollection, type BossCardCollection } from "../game/bossCards"; import type { EquipmentAssignments } from "../game/equipment"; import type { GameplaySettings } from "../game/combatStore"; import { @@ -70,6 +71,7 @@ export interface ShellState { phase: ShellPhase; session: PlayerSession | null; characters: CharacterProfile[]; + bossCards: BossCardCollection; selectedCharacterId: string | null; activeCharacter: CharacterProfile | null; draft: CharacterDraft; @@ -111,7 +113,7 @@ export interface ShellState { saveActiveProgression: (level: number, experience: number, talentRanks: Record) => void; saveActiveActionBindings: (actionBindings: Readonly>) => void; saveActiveInventory: (inventory: readonly InventoryItem[]) => void; - saveActiveBossCards: (bossCards: BossCardCollection) => void; + saveAccountBossCards: (bossCards: BossCardCollection) => void; saveActiveEquipment: (equipment: EquipmentAssignments) => void; saveActiveSettings: (settings: GameplaySettings) => void; updateCombatPresentationSettings: (patch: Partial) => void; @@ -138,12 +140,13 @@ function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft { }; } -function restoredState(): Pick { +function restoredState(): Pick { const session = restoreStoredSession(); if (!session) return { phase: "login", session: null, characters: [], + bossCards: emptyBossCardCollection(), selectedCharacterId: null, combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS, }; @@ -152,6 +155,7 @@ function restoredState(): Pick((set, get) => ({ phase: "characters", session, characters, + bossCards: loadAccountBossCards(session.ownerId), selectedCharacterId: characters[0]?.id ?? null, activeCharacter: null, combatPresentationSettings: loadCombatPresentationSettings(session.ownerId), @@ -189,6 +194,7 @@ export const useShellStore = create((set, get) => ({ phase: "login", session: null, characters: [], + bossCards: emptyBossCardCollection(), selectedCharacterId: null, activeCharacter: null, draft: defaultDraft(), @@ -276,6 +282,7 @@ export const useShellStore = create((set, get) => ({ set({ phase: "characters", characters, + bossCards: loadAccountBossCards(session.ownerId), selectedCharacterId: result.character.id, draft: defaultDraft(), notice: `${result.character.name} is ready for the expedition.`, @@ -439,7 +446,7 @@ export const useShellStore = create((set, get) => ({ return true; }, returnToMainMenu: () => { - useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false }); + useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null }); if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame(); const { session, characters, activeCharacter, selectedCharacterId } = get(); if (!session) { @@ -477,7 +484,7 @@ export const useShellStore = create((set, get) => ({ }, enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID), returnToCharacters: () => { - useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false }); + useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null }); const session = get().session; if (!session) { set({ phase: "login", activeCharacter: null }); @@ -488,6 +495,7 @@ export const useShellStore = create((set, get) => ({ set({ phase: "characters", characters, + bossCards: loadAccountBossCards(session.ownerId), selectedCharacterId: characters.some((character) => character.id === currentId) ? currentId : characters[0]?.id ?? null, @@ -538,15 +546,10 @@ export const useShellStore = create((set, get) => ({ characters: state.characters.map((character) => character.id === updated.id ? updated : character), })); }, - saveActiveBossCards: (bossCards) => { - const { session, activeCharacter } = get(); - if (!session || !activeCharacter) return; - const updated = updateCharacterBossCards(session.ownerId, activeCharacter.id, bossCards); - if (!updated) return; - set((state) => ({ - activeCharacter: updated, - characters: state.characters.map((character) => character.id === updated.id ? updated : character), - })); + saveAccountBossCards: (bossCards) => { + const { session } = get(); + if (!session) return; + set({ bossCards: persistAccountBossCards(session.ownerId, bossCards) }); }, saveActiveEquipment: (equipment) => { const { session, activeCharacter } = get(); @@ -651,6 +654,7 @@ if (restored.session?.accessToken) { useShellStore.setState({ session: refreshedSession, characters, + bossCards: loadAccountBossCards(refreshedSession.ownerId), selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId) ? useShellStore.getState().selectedCharacterId : characters[0]?.id ?? null, diff --git a/src/app/types.ts b/src/app/types.ts index 393a8704..39007e5d 100644 --- a/src/app/types.ts +++ b/src/app/types.ts @@ -42,6 +42,7 @@ export interface CharacterProfile { actionLoadouts?: Record>; inventory: InventoryItem[]; equipment: EquipmentAssignments; + /** Legacy character log, read only during migration to the account collection. */ bossCards?: BossCardCollection; /** Present on normalized and newly created profiles; optional for legacy callers. */ manastormProgress?: ManastormProgress; diff --git a/src/components/DualDisplayFrame.tsx b/src/components/DualDisplayFrame.tsx index 8135d01d..4ba6e866 100644 --- a/src/components/DualDisplayFrame.tsx +++ b/src/components/DualDisplayFrame.tsx @@ -5,6 +5,7 @@ import { controllerBindingMatches, inputKeyForEvent } from "../input/keybinds"; import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime"; import { useInputBindingLabels } from "../input/useInputBindingLabels"; import { resolveDisplayLayout } from "../platform/displayLayout"; +import { THOR_DISPLAYS } from "../platform/thorDisplayProfile"; import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting"; interface DualDisplayFrameProps { @@ -136,12 +137,12 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc } return ( -
-
Main display960 x 540 CSS / 1920 x 1080
-
{top}
+
+
Top · 6 inches1920 × 1080 · {THOR_DISPLAYS.top.widthMm.toFixed(1)} × {THOR_DISPLAYS.top.heightMm.toFixed(1)} mm
+
{top}
AYN THOR
-
Companion display620 x 540 CSS / 1240 x 1080
-
{bottom}
+
Bottom · 3.92 inches1240 × 1080 · {THOR_DISPLAYS.bottom.widthMm.toFixed(1)} × {THOR_DISPLAYS.bottom.heightMm.toFixed(1)} mm
+
{bottom}
); } diff --git a/src/game/BossTestingBridge.tsx b/src/game/BossTestingBridge.tsx new file mode 100644 index 00000000..ada1cdc5 --- /dev/null +++ b/src/game/BossTestingBridge.tsx @@ -0,0 +1,27 @@ +import { useEffect } from "react"; +import { useCombatStore } from "./combatStore"; +import { useGameStore } from "./store"; + +export function bossTestIsDefeated(): boolean { + const test = useGameStore.getState().bossTest; + const mobs = useCombatStore.getState().mobs; + return Boolean(test?.runtimeIds.length && test.runtimeIds.every((id) => mobs[id]?.dead)); +} + +export function BossTestingBridge() { + useEffect(() => { + let defeated = false; + const observe = () => { + const next = bossTestIsDefeated(); + if (next && !defeated) useGameStore.getState().openOverlay("boss-test-review"); + defeated = next; + }; + const unsubscribeCombat = useCombatStore.subscribe(observe); + const unsubscribeGame = useGameStore.subscribe((state, previous) => { + if (state.encounterResetRevision !== previous.encounterResetRevision || state.bossTest !== previous.bossTest) defeated = false; + }); + observe(); + return () => { unsubscribeCombat(); unsubscribeGame(); }; + }, []); + return null; +} diff --git a/src/game/CombatBridge.tsx b/src/game/CombatBridge.tsx index 9575ff56..99436111 100644 --- a/src/game/CombatBridge.tsx +++ b/src/game/CombatBridge.tsx @@ -40,7 +40,7 @@ export function CombatBridge() { useEffect(() => { if (!character) return; - useCombatStore.getState().initializeCharacter({ ...character, role: activeRole }); + useCombatStore.getState().initializeCharacter({ ...character, bossCards: useShellStore.getState().bossCards, role: activeRole }); useCombatStore.getState().synchronizeActors(); if (gameMode === "manastorm") { activateManastormActiveSpellLoadout( @@ -66,12 +66,14 @@ export function CombatBridge() { useEffect(() => { if (!character) return; const characterId = character.id; + const ownerId = useShellStore.getState().session?.ownerId; const unsubscribe = useCombatStore.subscribe((state, previousState) => { // Save APIs intentionally target the active character. Avoid persisting a // final combat notification during the render-to-effect-cleanup window // after Character Select has already replaced the active character. const shell = useShellStore.getState(); - if (shell.activeCharacter?.id !== characterId) return; + if (shell.activeCharacter?.id !== characterId || shell.session?.ownerId !== ownerId) return; + if (useGameStore.getState().bossTest) return; // Persisted collections are replaced immutably by combat actions. A // reference/primitive comparison therefore detects the same changes as @@ -101,7 +103,7 @@ export function CombatBridge() { shell.saveActiveInventory(state.inventory); } if (state.bossCards !== previousState.bossCards) { - shell.saveActiveBossCards(state.bossCards); + shell.saveAccountBossCards(state.bossCards); } if (state.equipment !== previousState.equipment) { shell.saveActiveEquipment(state.equipment); diff --git a/src/game/OnlineSessionBridge.tsx b/src/game/OnlineSessionBridge.tsx index 751596be..6c714711 100644 --- a/src/game/OnlineSessionBridge.tsx +++ b/src/game/OnlineSessionBridge.tsx @@ -246,6 +246,7 @@ function applyRelayEvent( } export function OnlineSessionBridge() { + const bossTest = useGameStore((state) => state.bossTest); const session = useShellStore((state) => state.session); const activeActivity = useOnlineGroupStore((state) => state.activeActivity); const group = useOnlineGroupStore((state) => state.snapshot.group); @@ -256,7 +257,7 @@ export function OnlineSessionBridge() { const applyingRef = useRef(false); useEffect(() => { - if (!session?.accessToken || !activeActivity || !group) { + if (bossTest || !session?.accessToken || !activeActivity || !group) { configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null }); reset(); return undefined; @@ -362,7 +363,7 @@ export function OnlineSessionBridge() { configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null }); reset(); }; - }, [accept, activeActivity?.id, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]); + }, [accept, activeActivity?.id, bossTest, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]); return null; } diff --git a/src/game/bossCards.ts b/src/game/bossCards.ts index cb19ce93..a5a7db99 100644 --- a/src/game/bossCards.ts +++ b/src/game/bossCards.ts @@ -49,6 +49,35 @@ export function normalizeBossCardCollection(value: unknown): BossCardCollection return { counts, lastClaims }; } +/** Sum independent legacy character logs; max reconciles copies of the same account log. */ +export function mergeBossCardCollections(collections: readonly unknown[], mode: "sum" | "max" = "max"): BossCardCollection { + const counts: Record = Object.create(null); + const lastClaims: Record = Object.create(null); + for (const value of collections) { + const collection = normalizeBossCardCollection(value); + for (const [bossKey, entry] of Object.entries(collection.counts)) { + const merged = counts[bossKey] ??= {}; + for (const rarity of BOSS_CARD_RARITIES) { + const count = entry[rarity]; + if (count === undefined) continue; + merged[rarity] = mode === "sum" + ? Math.min(Number.MAX_SAFE_INTEGER, (merged[rarity] ?? 0) + count) + : Math.max(merged[rarity] ?? 0, count); + } + } + for (const [spawnId, claim] of Object.entries(collection.lastClaims)) { + const previous = lastClaims[spawnId]; + if (!previous) { lastClaims[spawnId] = claim; continue; } + if (/^\d+:/.test(previous) && /^\d+:/.test(claim)) { + const [previousTime, previousRevision] = previous.split(":").map(Number); + const [time, revision] = claim.split(":").map(Number); + if (time > previousTime || (time === previousTime && revision > previousRevision)) lastClaims[spawnId] = claim; + } else if (claim > previous) lastClaims[spawnId] = claim; + } + } + return { counts, lastClaims }; +} + /** Independent bonus rolls; Normal is always awarded, including with all three bonuses. */ export function rollBossCards(random: () => number = Math.random): readonly BossCardRarity[] { return BOSS_CARD_RARITIES.filter((rarity) => { diff --git a/src/game/bossTesting.test.ts b/src/game/bossTesting.test.ts new file mode 100644 index 00000000..bc026378 --- /dev/null +++ b/src/game/bossTesting.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { buildBossTestingCatalog, BOSS_TESTING_CATALOG, createBossTestingRoom, randomTestingBoss } from "./bossTesting"; +import { clearBossTestingDungeons, DUNGEON_DEFINITIONS, registerBossTestingDungeon, requireDungeonDefinition } from "./dungeonRegistry"; +import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession"; +import { useGameStore } from "./store"; +import { useCombatStore } from "./combatStore"; +import { bossTestIsDefeated } from "./BossTestingBridge"; +import type { DungeonDefinition } from "./dungeonTypes"; + +afterEach(() => { + useGameStore.getState().activateDungeon("wailing-caverns"); + clearBossTestingDungeons(); +}); + +describe("Boss Testing", () => { + const boss = BOSS_TESTING_CATALOG.find((entry) => entry.dungeonId === "wailing-caverns" && entry.name === "Lady Anacondra")!; + const source = requireDungeonDefinition(boss.dungeonId); + + it("includes unready and event-only bosses and isolates catalog changes from shipped content", () => { + const blocked: DungeonDefinition = { ...source, availability: "blocked", staticSpawns: [], roamingPacks: [], + encounterSpawnGroups: [{ id: "event", spawns: [boss.spawn] }], entities: { [boss.entityId]: source.entities[boss.entityId] } }; + expect(buildBossTestingCatalog([blocked])).toHaveLength(1); + const count = DUNGEON_DEFINITIONS.length; + const room = createBossTestingRoom(source, boss); + registerBossTestingDungeon(room.definition, source.id); + expect(requireDungeonDefinition(source.id)).toBe(source); + expect(DUNGEON_DEFINITIONS).toHaveLength(count); + expect(room.definition.staticSpawns).toHaveLength(1); + expect(room.definition.roamingPacks).toEqual([]); + expect(room.definition.entities[boss.entityId].combat).toEqual(source.entities[boss.entityId].combat); + expect(room.definition.staticSpawns[0].position).toEqual(boss.spawn.position); + expect(room.definition.assets).toEqual({ visual: [], collision: [] }); + }); + + it("preserves boss phases and scripted summons without spawning unrelated dungeon packs", () => { + const fixture: DungeonDefinition = { ...source, encounters: [ + { id: "boss-mechanics", dungeonId: source.id, initialPhase: "idle", rules: [{ id: "adds", trigger: { kind: "entity-health", entityId: boss.spawn.id, atOrBelowPercent: 50 }, + actions: [{ id: "summon", kind: "activate-spawn-group", spawnGroupId: "boss-adds" }, { id: "loot", kind: "grant-loot", lootTableId: "test" }] }] }, + { id: "other-boss", dungeonId: source.id, initialPhase: "idle", rules: [{ id: "other", trigger: { kind: "entity-engaged", entityId: "unrelated" }, actions: [] }] }, + ], encounterSpawnGroups: [{ id: "boss-adds", spawns: [{ ...boss.spawn, id: "add", entityId: boss.entityId }] }, { id: "trash", spawns: [] }] }; + const room = createBossTestingRoom(fixture, boss); + expect(room.definition.encounters?.map((entry) => entry.id)).toEqual([`${room.definition.id}:boss-mechanics`]); + expect(room.definition.encounters?.[0].dungeonId).toBe(room.definition.id); + expect(room.definition.encounters?.[0].rules[0].actions).toEqual([{ id: "summon", kind: "activate-spawn-group", spawnGroupId: "boss-adds" }]); + expect(room.definition.encounterSpawnGroups?.map((entry) => entry.id)).toEqual(["boss-adds"]); + expect(room.definition.staticSpawns).toHaveLength(1); + }); + + it("starts an unready boss room, survives scene retries, and retains testing mode when restarted", () => { + const room = createBossTestingRoom(source, boss); + registerBossTestingDungeon(room.definition, source.id); + expect(activateDungeonSession(room.definition.id)).toBe(false); + expect(activateDungeonSession(room.definition.id, undefined, { gmTestMode: true, bossTest: room.session })).toBe(true); + expect(restartActiveDungeonSession()).toBe(true); + useGameStore.getState().resetAtEntrance(); + expect(useGameStore.getState()).toMatchObject({ gmTestMode: true, bossTest: room.session, overlay: null }); + }); + + it("recognizes actual boss deaths and awards no progression or collectible rewards", () => { + const room = createBossTestingRoom(source, boss); + registerBossTestingDungeon(room.definition, source.id); + useGameStore.getState().activateDungeon(room.definition.id, undefined, { bossTest: room.session, gmTestMode: true }); + useCombatStore.getState().initializeCharacter({ classId: "mage", level: 20 }); + expect(bossTestIsDefeated()).toBe(false); + useCombatStore.getState().registerMob(boss.spawn.id, { name: boss.name, boss: true, maxHealth: 10, xpReward: 500, hasLoot: true, lootSourceId: boss.entityId }); + const xp = useCombatStore.getState().xp; + useCombatStore.getState().damageMob(boss.spawn.id, 100000, Date.now()); + expect(bossTestIsDefeated()).toBe(true); + const combat = useCombatStore.getState(); + expect(combat.xp).toBe(xp); + expect(combat.mobs[boss.spawn.id]).toMatchObject({ hasLoot: false, pendingLoot: [] }); + expect(combat.mobs[boss.spawn.id].bossCardDrop).toBeFalsy(); + }); + + it("draws a boss only from the selected filters and handles empty queues", () => { + expect(randomTestingBoss([], () => 0.5)).toBeNull(); + const candidates = BOSS_TESTING_CATALOG.slice(0, 2); + expect(randomTestingBoss(candidates, () => 0)).toBe(candidates[0]); + expect(randomTestingBoss(candidates, () => 1)).toBe(candidates[1]); + expect(randomTestingBoss(candidates, () => Number.NaN)).toBe(candidates[0]); + }); +}); diff --git a/src/game/bossTesting.ts b/src/game/bossTesting.ts new file mode 100644 index 00000000..6e2dfcb1 --- /dev/null +++ b/src/game/bossTesting.ts @@ -0,0 +1,123 @@ +import { DUNGEON_DEFINITIONS } from "./dungeonRegistry"; +import type { DungeonDefinition, Vector3Tuple } from "./dungeonTypes"; +import type { StaticMobSpawnDefinition } from "./mobPopulation"; +import type { BossTestSession } from "./store"; + +export interface TestableBoss { + readonly key: string; + readonly name: string; + readonly dungeonId: string; + readonly dungeonTitle: string; + readonly entityId: string; + readonly category: "wow" | "rom"; + readonly spawn: StaticMobSpawnDefinition; +} + +export function buildBossTestingCatalog(definitions: readonly DungeonDefinition[]): readonly TestableBoss[] { + return definitions.flatMap((dungeon) => Object.values(dungeon.entities).flatMap((entity): TestableBoss[] => { + if (entity.kind !== "boss") return []; + const spawn = dungeon.staticSpawns.find((candidate) => candidate.entityId === entity.id) + ?? dungeon.encounterSpawnGroups?.flatMap((group) => group.spawns).find((candidate) => candidate.entityId === entity.id); + const objective = dungeon.bosses.find((boss) => boss.id === spawn?.id || boss.name === entity.name); + const pack = dungeon.roamingPacks.find((candidate) => candidate.members.some((member) => member.entityId === entity.id)); + const member = pack?.members.find((candidate) => candidate.entityId === entity.id); + const position = spawn?.position ?? objective?.position ?? pack?.waypoints[0]; + if (!position) return []; + return [{ + key: `${dungeon.id}/${entity.id}`, name: entity.name, dungeonId: dungeon.id, + dungeonTitle: dungeon.title, entityId: entity.id, + category: dungeon.expansion === "runewaker" ? "rom" : "wow", + spawn: { ...spawn, id: spawn?.id ?? (pack && member ? `${pack.id}:${member.id}` : objective?.id ?? entity.id), + entityId: entity.id, position, spawnChance: 1 }, + }]; + })).sort((a, b) => a.name.localeCompare(b.name) || a.dungeonTitle.localeCompare(b.dungeonTitle)); +} + +export const BOSS_TESTING_CATALOG = buildBossTestingCatalog(DUNGEON_DEFINITIONS); + +export function randomTestingBoss(bosses: readonly TestableBoss[], random: () => number = Math.random): TestableBoss | null { + if (!bosses.length) return null; + const draw = random(); + const index = Math.floor((Number.isFinite(draw) ? Math.max(0, Math.min(0.999999999, draw)) : 0) * bosses.length); + return bosses[index]; +} + +function references(value: unknown, ids: ReadonlySet): boolean { + if (typeof value === "string") return ids.has(value); + if (Array.isArray(value)) return value.some((entry) => references(entry, ids)); + return Boolean(value && typeof value === "object" && Object.values(value).some((entry) => references(entry, ids))); +} + +function remapEncounterReferences(value: T, ids: ReadonlyMap): T { + if (typeof value === "string") return (ids.get(value) ?? value) as T; + if (Array.isArray(value)) return value.map((entry) => remapEncounterReferences(entry, ids)) as T; + if (value && typeof value === "object") return Object.fromEntries(Object.entries(value) + .map(([key, entry]) => [key, remapEncounterReferences(entry, ids)])) as T; + return value; +} + +/** Keep original mechanic coordinates and spawn IDs while replacing the environment with a clear floor. */ +export function createBossTestingRoom(source: DungeonDefinition, boss: TestableBoss): { + readonly definition: DungeonDefinition; + readonly session: BossTestSession; +} { + const entity = source.entities[boss.entityId]; + if (source.id !== boss.dungeonId || entity?.kind !== "boss") throw new Error("That boss is unavailable."); + const id = `boss-testing:${source.id}:${boss.entityId}`; + const ids = new Set([boss.entityId, boss.spawn.id]); + const sourceEncounters = (source.encounters ?? []).filter((encounter) => + references(encounter.rules.map((rule) => rule.trigger), ids) + || references(entity.combat, new Set([encounter.id])) + ); + const encounterIds = new Map(sourceEncounters.map((encounter) => [encounter.id, `${id}:${encounter.id}`])); + const encounters = sourceEncounters.map((encounter) => { + const isolated = remapEncounterReferences(encounter, encounterIds); + return { ...isolated, id: encounterIds.get(encounter.id)!, dungeonId: id, + rules: isolated.rules.map((rule) => ({ ...rule, actions: rule.actions.filter((action) => + action.kind !== "grant-loot" && action.kind !== "set-checkpoint" + ) })), + }; + }); + const groups = (source.encounterSpawnGroups ?? []).filter((group) => references(encounters, new Set([group.id]))); + const objects = source.serverGameObjects.filter((object) => references(encounters, new Set([object.id, object.interaction?.signalId ?? ""]))); + const entityIds = new Set([boss.entityId, ...groups.flatMap((group) => group.spawns.map((spawn) => spawn.entityId))]); + // Summoned creature definitions are needed even though they are absent from the initial room. + for (const entityId of entityIds) { + for (const attack of source.entities[entityId]?.combat?.attacks ?? []) { + for (const effect of attack.effect ? [attack.effect] : []) { + if (effect.kind !== "summon") continue; + for (const candidate of Object.values(source.entities)) { + if (candidate.id === effect.entityId || candidate.combat?.serverEntry === effect.creatureEntry) entityIds.add(candidate.id); + } + } + } + } + const center = boss.spawn.position; + const radius = Math.max(40, ...groups.flatMap((group) => group.spawns.map((spawn) => + Math.hypot(spawn.position[0] - center[0], spawn.position[2] - center[2]) + 8 + )), ...objects.map((object) => Math.hypot(object.position[0] - center[0], object.position[2] - center[2]) + 8)); + const spawnDistance = Math.max(14, (entity.combat?.aggroRange ?? 12) + 5); + const entrance: Vector3Tuple = [center[0], center[1], center[2] + Math.min(radius - 5, spawnDistance)]; + const spawn = { ...boss.spawn, spawnMask: undefined, spawnChance: 1 }; + const objective = { id: spawn.id, name: boss.name, position: center }; + return { + session: { key: boss.key, name: boss.name, sourceDungeonId: source.id, entityId: boss.entityId, runtimeIds: [spawn.id] }, + definition: { + ...source, id, title: `${boss.name} — Boss Testing`, availability: "available", + assets: { visual: [], collision: [] }, + bounds: { min: [center[0] - radius, center[1] - 2, center[2] - radius], max: [center[0] + radius, center[1] + 24, center[2] + radius] }, + entrance: { name: "Testing room", footPosition: entrance, yaw: Math.PI, forward: [0, 0, -1] }, + areas: [{ id: "testing-room", name: "Boss Testing", center, radius: radius * 2 }], + entities: Object.fromEntries([...entityIds].filter((entityId) => source.entities[entityId]) + .map((entityId) => [entityId, remapEncounterReferences(source.entities[entityId], encounterIds)])), + staticSpawns: [spawn], roamingPacks: [], bosses: [objective], objectiveOrder: [objective], + navigationLinks: [], traversalRamps: [], encounters, encounterSpawnGroups: groups, serverGameObjects: objects, + presentation: { ...source.presentation, location: "Boss Testing", summary: "An isolated room for testing boss mechanics.", + loadingMessage: `Preparing ${boss.name}…`, background: "#111923", + fog: { color: "#111923", near: radius, far: radius * 3 }, + ambientLight: { color: "#ffffff", intensity: 1.2 }, + hemisphereLight: { skyColor: "#c8ddf0", groundColor: "#444c57", intensity: 1.4 }, + map: { width: 600, height: 600, padding: 20, contours: [] } }, + }, + }; +} diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index d70d1113..1f577459 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -2631,15 +2631,16 @@ function damageMobInWork( const health = mob.health - applied; const killed = health <= 0; const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt; - const canAward = killed && !mob.experienceGranted; + const testing = Boolean(useGameStore.getState().bossTest); + const canAward = killed && !mob.experienceGranted && !testing; const beforeLevel = work.progression.level; - const pendingLoot = killed && mob.lootSourceId + const pendingLoot = testing ? [] : killed && mob.lootSourceId ? rollBossLoot(mob.lootSourceId, beforeLevel, { now, bonusItemChance: mob.bonusLootChance, }) : mob.pendingLoot; - const card = killed && mob.boss + const card = killed && mob.boss && !testing ? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name) : null; const bossCardDrop = card ? { @@ -2648,7 +2649,7 @@ function damageMobInWork( claimId: `${now}:${mob.deathRevision + 1}:${mob.id}`, rarities: rollBossCards(), } satisfies BossCardDrop : mob.bossCardDrop; - const hasLoot = killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot; + const hasLoot = testing ? false : killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot; const xpAwarded = canAward ? grantMobExperience(classId, work, mob.xpReward) : 0; engageMobGroupInWork(work, id, now, source.actorId); const engagedMob = work.mobs[id] ?? mob; diff --git a/src/game/dungeonRegistry.ts b/src/game/dungeonRegistry.ts index 36c0b9cd..7382200f 100644 --- a/src/game/dungeonRegistry.ts +++ b/src/game/dungeonRegistry.ts @@ -199,8 +199,27 @@ export type DungeonId = string; export const DUNGEON_DEFINITIONS = Object.freeze(Object.values(DUNGEON_REGISTRY)); +// Test rooms are session-only definitions; the shipped dungeon catalog stays immutable. +const bossTestingDefinitions = new Map(); + +export function registerBossTestingDungeon(definition: DungeonDefinition, sourceDungeonId: string): void { + if (!definition.id.startsWith("boss-testing:") || !DUNGEON_REGISTRY[sourceDungeonId]) { + throw new Error("Invalid boss testing dungeon."); + } + bossTestingDefinitions.clear(); + bossTestingDefinitions.set(definition.id, { definition, sourceDungeonId }); +} + +export function bossTestingSourceDungeonId(id: string): string | null { + return bossTestingDefinitions.get(id)?.sourceDungeonId ?? null; +} + +export function clearBossTestingDungeons(): void { + bossTestingDefinitions.clear(); +} + export function dungeonDefinitionById(id: string): DungeonDefinition | null { - return DUNGEON_REGISTRY[id] ?? null; + return bossTestingDefinitions.get(id)?.definition ?? DUNGEON_REGISTRY[id] ?? null; } export function requireDungeonDefinition(id: string): DungeonDefinition { diff --git a/src/game/dungeonSession.ts b/src/game/dungeonSession.ts index 9b60fda9..2ca1cafb 100644 --- a/src/game/dungeonSession.ts +++ b/src/game/dungeonSession.ts @@ -5,8 +5,8 @@ import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { resetPartyRuntimeClock } from "./partyRuntime"; import { usePartyStore } from "./partyStore"; -import { useGameStore, type GameMode } from "./store"; -import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry"; +import { useGameStore, type BossTestSession, type GameMode } from "./store"; +import { bossTestingSourceDungeonId, dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry"; import type { WorldSpawn } from "./dungeonTypes"; import type { GmReviewTarget } from "./gmContentReview"; import { shelvedDungeonSpawn } from "@shelvedDungeonSession"; @@ -27,6 +27,7 @@ export interface DungeonSessionOptions { readonly gameMode?: GameMode; readonly spawn?: WorldSpawn; readonly gmTestMode?: boolean; + readonly bossTest?: BossTestSession | null; readonly gmReviewTarget?: GmReviewTarget | null; /** Used by in-game encounter transitions so character progression and inventory survive. */ readonly preserveCharacter?: boolean; @@ -47,7 +48,10 @@ export function activateDungeonSession( options: DungeonSessionOptions = {}, ): boolean { const definition = dungeonDefinitionById(dungeonId); - if (!definition || !dungeonCanEnter(dungeonId) || !dungeonContentIsUsable(dungeonId)) { + const testingSource = bossTestingSourceDungeonId(dungeonId); + const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource); + if (!definition || (!testing && !dungeonCanEnter(dungeonId)) + || !dungeonContentIsUsable(testing ? testingSource! : dungeonId)) { return false; } if ((difficultyId !== undefined && !definition.difficultyVariants.some((variant) => variant.id === difficultyId)) @@ -107,6 +111,7 @@ export function activateDungeonSession( gameMode: options.gameMode, spawn: shelvedDungeonSpawn(dungeonId, options.spawn), gmTestMode: options.gmTestMode, + bossTest: options.bossTest, gmReviewTarget: options.gmReviewTarget, bossDifficulty, }); @@ -128,6 +133,7 @@ export function restartActiveDungeonSession(): boolean { gameMode: game.gameMode, spawn: game.activeSpawn, gmTestMode: game.gmTestMode, + bossTest: game.bossTest, gmReviewTarget: game.activeGmReviewTarget, preserveCharacter: true, bossDifficulty: game.activeBossDifficulty, diff --git a/src/game/store.ts b/src/game/store.ts index a4200b1b..d41b56eb 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -9,7 +9,15 @@ import { validBossDifficulty } from "./dungeonDifficulty"; export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error"; export type InputMode = "keyboard" | "gamepad"; export type GameMode = "dungeon" | "manastorm"; -export type GameOverlay = "pause" | "report-issue" | "party" | "inventory" | "boss-cards" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | "dungeon-results" | null; +export type GameOverlay = "pause" | "boss-test-review" | "report-issue" | "party" | "inventory" | "boss-cards" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | "dungeon-results" | null; + +export interface BossTestSession { + readonly key: string; + readonly name: string; + readonly sourceDungeonId: string; + readonly entityId: string; + readonly runtimeIds: readonly string[]; +} interface PlayerSnapshot { position: Vector3Tuple; @@ -24,6 +32,7 @@ export interface GameState { activeBossDifficulty: number; gameMode: GameMode; gmTestMode: boolean; + bossTest: BossTestSession | null; activeGmReviewTarget: GmReviewTarget | null; activeSpawn: WorldSpawn; sessionRevision: number; @@ -55,6 +64,7 @@ export interface GameState { readonly gameMode?: GameMode; readonly spawn?: WorldSpawn; readonly gmTestMode?: boolean; + readonly bossTest?: BossTestSession | null; readonly gmReviewTarget?: GmReviewTarget | null; readonly bossDifficulty?: number; }, @@ -88,6 +98,7 @@ export const useGameStore = create((set) => ({ activeBossDifficulty: 1, gameMode: "dungeon", gmTestMode: false, + bossTest: null, activeGmReviewTarget: null, activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw }, sessionRevision: 0, @@ -125,6 +136,7 @@ export const useGameStore = create((set) => ({ activeBossDifficulty: validBossDifficulty(options?.bossDifficulty) ? options.bossDifficulty : 1, gameMode: options?.gameMode ?? "dungeon", gmTestMode: options?.gmTestMode ?? false, + bossTest: options?.bossTest ?? null, activeGmReviewTarget: options?.gmReviewTarget ?? null, activeSpawn, sessionRevision: state.sessionRevision + 1, @@ -279,7 +291,7 @@ export const useGameStore = create((set) => ({ const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw }; return { gameMode: "dungeon", - gmTestMode: false, + gmTestMode: Boolean(state.bossTest), activeGmReviewTarget: null, activeSpawn, overlay: null, diff --git a/src/game/uiLayout.test.ts b/src/game/uiLayout.test.ts index acec0c15..34e95d68 100644 --- a/src/game/uiLayout.test.ts +++ b/src/game/uiLayout.test.ts @@ -2,9 +2,30 @@ import { describe, expect, it } from "vitest"; import { actionKeyForEvent, assignDesktopAbility, bindDesktopSlot, defaultUiLayout, desktopSlotForKey, exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resetDesktopAbilities, resolveDpsMeterDisplay, + HUD_ELEMENTS, resolveHudScreen, } from "./uiLayout"; describe("personal UI layouts", () => { + it("assigns every element one display, saves routing, and keeps single-display widgets available", () => { + const preferences = normalizeUiPreferences(null); + const layout = preferences.current.mobile; + expect(resolveHudScreen("party", layout, true)).toBe("top"); + for (const id of ["actions", "player", "cast", "experience"] as const) expect(resolveHudScreen(id, layout, true)).toBe("bottom"); + for (const id of Object.keys(HUD_ELEMENTS) as (keyof typeof HUD_ELEMENTS)[]) { + layout.elements[id].screen = "bottom"; + expect(resolveHudScreen(id, layout, true)).toBe("bottom"); + expect(resolveHudScreen(id, layout, false)).toBe("top"); + if (id === "dps") { + expect(resolveDpsMeterDisplay(layout, false)).toBe("main"); + expect(resolveDpsMeterDisplay(layout, true)).toBe("companion"); + } + layout.elements[id].screen = "top"; + expect(resolveHudScreen(id, layout, true)).toBe("top"); + layout.elements[id].screen = "hidden"; + expect(resolveHudScreen(id, layout, true)).toBe("hidden"); + } + expect(importUiPreferences(exportUiPreferences(preferences))).toEqual(preferences); + }); it("saves and clears abilities on inactive WoW bars without changing keys, templates, or mobile controls", () => { const ui = normalizeUiPreferences(null); ui.slots.pc[0] = { name: "Original", layout: ui.current.pc }; diff --git a/src/game/uiLayout.ts b/src/game/uiLayout.ts index 498ce23e..dd8feb53 100644 --- a/src/game/uiLayout.ts +++ b/src/game/uiLayout.ts @@ -6,7 +6,7 @@ export const UI_DEVICES: readonly UiDevice[] = ["mobile", "pc"]; export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const; export const HUD_ELEMENTS = { party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap", - player: "Player status", timeline: "Encounter timeline", target: "Enemy target", + player: "Buffs and debuffs", timeline: "Encounter timeline", target: "Enemy target", friendly: "Friendly target", threat: "Threat meter", dps: "DPS meter", cast: "Cast bar", hints: "Control hints", actions: "Controller action bar", experience: "Experience bar", reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt", @@ -16,8 +16,10 @@ export const HUD_ELEMENTS = { "bar-4": "Action bar 4", "bar-5": "Action bar 5", } as const; export type HudElementId = keyof typeof HUD_ELEMENTS; +export type HudScreen = "auto" | "top" | "bottom" | "hidden"; export interface HudElementLayout { visible: boolean; + screen?: HudScreen; position: NormalizedHudPosition | null; width: number | null; } @@ -87,6 +89,7 @@ export function normalizeUiLayout(value: unknown): UiLayout { const element = record(elements[id]); defaults.elements[id] = { visible: element.visible !== false, + ...(["auto", "top", "bottom", "hidden"].includes(String(element.screen)) ? { screen: element.screen as HudScreen } : {}), position: element.position ? normalizeHudPosition(element.position, { x: 0.5, y: 0.5 }) : null, width: typeof element.width === "number" ? bounded(element.width, 200, 20, 2000) : null, }; @@ -114,8 +117,21 @@ export function normalizeUiLayout(value: unknown): UiLayout { } export function resolveDpsMeterDisplay(layout: UiLayout, dualScreen: boolean): "main" | "companion" { + const screen = layout.elements.dps.screen; + if (screen === "top" || screen === "bottom") return !dualScreen || screen === "top" ? "main" : "companion"; return layout.dpsMeterDisplay === "auto" ? dualScreen ? "companion" : "main" : layout.dpsMeterDisplay; } +/** One owner per widget; single-display layouts keep all visible widgets available. */ +export function resolveHudScreen(id: HudElementId, layout: UiLayout, dualScreen: boolean): "top" | "bottom" | "hidden" { + const element = layout.elements[id]; + if (!element.visible || element.screen === "hidden") return "hidden"; + if (!dualScreen) return "top"; + if (element.screen === "top" || element.screen === "bottom") return element.screen; + if (id === "dps") return resolveDpsMeterDisplay(layout, true) === "main" ? "top" : "bottom"; + if (["player", "actions", "cast", "experience", "bar-1", "bar-2", "bar-3", "bar-4", "bar-5"].includes(id)) return "bottom"; + if (id === "hints") return "hidden"; + return "top"; +} export function normalizeUiPreferences(value: unknown): UiPreferences { const source = record(value); const current = record(source.current); @@ -174,7 +190,7 @@ export function importUiPreferences(text: string): UiPreferences { if (id === "dps" && record(layout.elements)[id] === undefined) continue; const element = record(record(layout.elements)[id]); const position = record(element.position); - if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1)) + if ((element.screen !== undefined && !["auto", "top", "bottom", "hidden"].includes(String(element.screen))) || typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1)) || (element.width !== null && (typeof element.width !== "number" || !Number.isFinite(element.width) || element.width < 20 || element.width > 2000))) throw new Error("Invalid HUD position or visibility."); } const keys = new Set(); diff --git a/src/game/uiLayoutStore.ts b/src/game/uiLayoutStore.ts index c859c151..5a3ca7ad 100644 --- a/src/game/uiLayoutStore.ts +++ b/src/game/uiLayoutStore.ts @@ -6,7 +6,8 @@ import { normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreference import { publishInputPreferences } from "../input/keybindRuntime"; export function currentUiDevice(): UiDevice { - return Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) + return (typeof window !== "undefined" && /(?:[?&]layout=(?:thor-preview|dual)|[?&]display=(?:top|bottom))\b/.test(window.location.search)) + || Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) || (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches && window.innerWidth < 1100) ? "mobile" : "pc"; } diff --git a/src/main.tsx b/src/main.tsx index e6a21d67..f4daae3b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,15 +4,21 @@ import { Capacitor } from "@capacitor/core"; import App from "./App"; import { startControllerInput } from "./input/controller"; import { currentDisplayLayout } from "./platform/displayLayout"; +import { THOR_DISPLAYS } from "./platform/thorDisplayProfile"; import { initializeContentSystem } from "./content/contentManager"; import "./styles.css"; import "./ui/dungeonReports.css"; +import "./ui/thorUi.css"; const BottomDisplayApp = lazy(() => import("./platform/BottomDisplayApp").then((module) => ({ default: module.BottomDisplayApp }))); const params = new URLSearchParams(window.location.search); const displayMode = params.get("display"); const nativeLayoutRequested = params.has("nativeLayout"); +if (Capacitor.isNativePlatform() && (displayMode === "top" || displayMode === "bottom")) { + // Let each WebView fit the same physical UI density, independently of Android's shared display density. + document.querySelector('meta[name="viewport"]')?.setAttribute("content", `width=${THOR_DISPLAYS[displayMode].cssWidth}, viewport-fit=cover`); +} if (Capacitor.isNativePlatform() || nativeLayoutRequested) document.documentElement.classList.add("native-platform"); if (displayMode === "top" || displayMode === "bottom") document.documentElement.dataset.displaySurface = displayMode; document.documentElement.dataset.displayLayout = currentDisplayLayout(); diff --git a/src/platform/BottomDisplayApp.tsx b/src/platform/BottomDisplayApp.tsx index dea88934..ddf37ee8 100644 --- a/src/platform/BottomDisplayApp.tsx +++ b/src/platform/BottomDisplayApp.tsx @@ -15,6 +15,7 @@ import { import { normalizeInputPreferences } from "../input/keybinds"; import { publishInputPreferences } from "../input/keybindRuntime"; import { useForcedThorDisplays } from "./useThorDualScreen"; +import { RemoteDungeonHud } from "./RemoteDungeonHud"; export function BottomDisplayApp() { useForcedThorDisplays(); @@ -78,5 +79,7 @@ export function BottomDisplayApp() { }; }, []); - return
; + return
{snapshot.phase === "game" + ? + : }
; } diff --git a/src/platform/RemoteDungeonHud.tsx b/src/platform/RemoteDungeonHud.tsx new file mode 100644 index 00000000..778ab128 --- /dev/null +++ b/src/platform/RemoteDungeonHud.tsx @@ -0,0 +1,53 @@ +import { useLayoutEffect } from "react"; +import { useCombatStore } from "../game/combatStore"; +import { useGameStore } from "../game/store"; +import { clearBossTestingDungeons, registerBossTestingDungeon } from "../game/dungeonRegistry"; +import { usePartyStore } from "../game/partyStore"; +import { useManastormStore } from "../game/manastormStore"; +import { useDungeonRunStore } from "../game/dungeonRunStore"; +import { useOnlineSessionStore } from "../app/onlineSessionStore"; +import { useOnlineGroupStore } from "../app/onlineGroupStore"; +import { useShellStore } from "../app/shellStore"; +import { useUiLayoutStore } from "../game/uiLayoutStore"; +import { useDpsMeterStore } from "../game/dpsMeterStore"; +import { DungeonCompanionHud } from "../ui/DungeonCompanionHud"; +import type { CompanionCommand, CompanionSnapshot } from "./dualScreenSync"; + +export function RemoteDungeonHud({ snapshot, send }: { + snapshot: CompanionSnapshot; + send: (command: CompanionCommand) => void; +}) { + const hud = snapshot.game.hud; + useLayoutEffect(() => { + if (hud.bossTestingDungeon) registerBossTestingDungeon(hud.bossTestingDungeon.definition, hud.bossTestingDungeon.sourceDungeonId); + const session = useShellStore.getState().session; + useShellStore.setState({ ...hud.shell, + session: session?.ownerId === snapshot.session?.ownerId ? session : snapshot.session, + }); + useUiLayoutStore.setState(hud.ui); + useGameStore.setState({ ...hud.game, + toggleMap: () => send({ name: "toggleMap" }), + openOverlay: (overlay) => send({ name: "openOverlay", overlay }), + }); + if (!hud.bossTestingDungeon) clearBossTestingDungeons(); + useCombatStore.setState({ ...hud.combat, + castActionBinding: (layer, control) => { send({ name: "castActionBinding", layer, control }); return { ok: false, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 }; }, + selectFriendlyActor: (id) => { send({ name: "selectFriendlyActor", id }); return true; }, + clearTarget: () => send({ name: "clearTarget" }), + updateSettings: (patch) => { if (patch.threatMeterPosition) send({ name: "moveThreatMeter", position: patch.threatMeterPosition }); }, + }); + usePartyStore.setState({ ...hud.party, + selectMember: (memberId) => { if (memberId) send({ name: "selectPartyMember", memberId }); else send({ name: "clearPartySelection" }); return true; }, + selectSelf: () => send({ name: "selectPlayer" }), + clearSelection: () => send({ name: "clearPartySelection" }), + setCommand: (command) => send({ name: "setPartyCommand", command }), + recallParty: () => send({ name: "recallParty" }), + }); + useManastormStore.setState(hud.manastorm); + useDungeonRunStore.setState({ progress: hud.run }); + useOnlineSessionStore.setState({ snapshot: hud.onlineSession }); + useOnlineGroupStore.setState(hud.onlineGroup); + useDpsMeterStore.setState(hud.dps); + }, [hud, send, snapshot.session]); + return ; +} diff --git a/src/platform/authoritativeDualScreenRuntime.ts b/src/platform/authoritativeDualScreenRuntime.ts index 7ba33920..bbda1da1 100644 --- a/src/platform/authoritativeDualScreenRuntime.ts +++ b/src/platform/authoritativeDualScreenRuntime.ts @@ -7,6 +7,10 @@ import { currentCompanionSnapshot, executeCompanionCommand, type DualScreenMessa import { createRateLimitedPublisher } from "./rateLimitedPublisher"; import { useUiLayoutStore } from "../game/uiLayoutStore"; import { useDpsMeterStore } from "../game/dpsMeterStore"; +import { useOnlineSessionStore } from "../app/onlineSessionStore"; +import { useOnlineGroupStore } from "../app/onlineGroupStore"; +import { useDungeonRunStore } from "../game/dungeonRunStore"; +import { subscribeDungeonEncounterSnapshots } from "../game/dungeonEncounterRuntime"; export function connectAuthoritativeDualScreen( channel: BroadcastChannel, @@ -38,6 +42,11 @@ export function connectAuthoritativeDualScreen( const unsubscribeDps = useDpsMeterStore.subscribe(() => { if (companionReady) gamePublisher.request(); }); + const request = () => { if (companionReady) gamePublisher.request(); }; + const unsubscribeOnline = useOnlineSessionStore.subscribe(request); + const unsubscribeGroup = useOnlineGroupStore.subscribe(request); + const unsubscribeRun = useDungeonRunStore.subscribe(request); + const unsubscribeEncounters = subscribeDungeonEncounterSnapshots(request); channel.onmessage = (event: MessageEvent) => { if (event.data.type === "companion-ready") { companionReady = true; @@ -62,6 +71,10 @@ export function connectAuthoritativeDualScreen( unsubscribeParty(); unsubscribeUi(); unsubscribeDps(); + unsubscribeOnline(); + unsubscribeGroup(); + unsubscribeRun(); + unsubscribeEncounters(); gamePublisher.dispose(); }; } diff --git a/src/platform/dualScreenSync.ts b/src/platform/dualScreenSync.ts index f3b84f8c..d7c6cd8e 100644 --- a/src/platform/dualScreenSync.ts +++ b/src/platform/dualScreenSync.ts @@ -32,6 +32,10 @@ import { activeUiLayout, useUiLayoutStore } from "../game/uiLayoutStore"; import { normalizeHudPosition } from "../game/hudLayout"; import { resolveDpsMeterDisplay, type HudElementLayout } from "../game/uiLayout"; import { currentDisplayLayout } from "./displayLayout"; +import { currentHudSnapshot, type HudSnapshot } from "./hudSnapshot"; +import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout"; +import { interactWithActiveDungeonObject } from "../game/dungeonWorldInteraction"; +import { resurrectDeadPartyMembers } from "../game/partyResurrection"; export { createDualScreenChannel } from "./dualScreenChannel"; @@ -95,6 +99,7 @@ export interface CompanionPartyObjectiveSnapshot { } export interface CompanionGameSnapshot { + hud: HudSnapshot; dpsMeter: { values: DpsMeterSnapshot; layout: HudElementLayout; @@ -172,7 +177,15 @@ export type CompanionCommand = | { name: "setPartyCommand"; command: PartyCommand } | { name: "recallParty" } | { name: "resetAtEntrance" } - | { name: "editDpsMeter"; update: Partial }; + | { name: "editDpsMeter"; update: Partial } + | { name: "editHudElement"; id: HudElementId; update: Partial } + | { name: "selectFriendlyActor"; id: string | null } + | { name: "selectPlayer" } + | { name: "clearPartySelection" } + | { name: "resurrectParty"; checkpointId: string } + | { name: "interactDungeonObject" } + | { name: "finishDungeon" } + | { name: "moveThreatMeter"; position: { x: number; y: number } }; export type DualScreenMessage = | { type: "companion-ready" } @@ -265,6 +278,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot { draft: shell.draft, notice: shell.notice, game: { + hud: currentHudSnapshot(), dpsMeter: { values: currentDpsMeterSnapshot(), layout: uiLayout.elements.dps, @@ -342,6 +356,24 @@ export function executeCompanionCommand(command: CompanionCommand): void { const game = useGameStore.getState(); const combat = useCombatStore.getState(); switch (command.name) { + case "editHudElement": { + if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft || !(command.id in HUD_ELEMENTS)) break; + const update: Partial = {}; + if (command.update.position) update.position = normalizeHudPosition(command.update.position, { x: .5, y: .5 }); + if (typeof command.update.width === "number" && Number.isFinite(command.update.width)) update.width = Math.max(20, Math.min(2000, command.update.width)); + useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, + [command.id]: { ...layout.elements[command.id], ...update }, + } })); + useUiLayoutStore.setState({ selectedElement: command.id }); + break; + } + case "selectFriendlyActor": combat.selectFriendlyActor(command.id); break; + case "selectPlayer": combat.clearTarget(); usePartyStore.getState().selectSelf(); break; + case "clearPartySelection": usePartyStore.getState().clearSelection(); break; + case "resurrectParty": resurrectDeadPartyMembers(command.checkpointId); break; + case "interactDungeonObject": if (!game.overlay) interactWithActiveDungeonObject(); break; + case "finishDungeon": if (currentHudSnapshot().completion) { shell.saveActiveDungeonRunCheckpoint(null); shell.returnToMainMenu(); } break; + case "moveThreatMeter": combat.updateSettings({ threatMeterPosition: normalizeHudPosition(command.position, { x: .8, y: .3 }) }); break; case "editDpsMeter": { if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft) break; const update: Partial = {}; diff --git a/src/platform/hudSnapshot.test.ts b/src/platform/hudSnapshot.test.ts new file mode 100644 index 00000000..0c283287 --- /dev/null +++ b/src/platform/hudSnapshot.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { currentHudSnapshot } from "./hudSnapshot"; +import { executeCompanionCommand } from "./dualScreenSync"; +import { useGameStore } from "../game/store"; +import { useUiLayoutStore } from "../game/uiLayoutStore"; +import { normalizeUiPreferences } from "../game/uiLayout"; +import { BOSS_TESTING_CATALOG, createBossTestingRoom } from "../game/bossTesting"; +import { clearBossTestingDungeons, registerBossTestingDungeon, requireDungeonDefinition } from "../game/dungeonRegistry"; + +describe("remote HUD presentation", () => { + it("carries the isolated boss room to the lower display without simulation methods", () => { + const original = useGameStore.getState(); + const boss = BOSS_TESTING_CATALOG.find((entry) => entry.dungeonId === "wailing-caverns")!; + const room = createBossTestingRoom(requireDungeonDefinition(boss.dungeonId), boss); + try { + registerBossTestingDungeon(room.definition, boss.dungeonId); + useGameStore.getState().activateDungeon(room.definition.id, undefined, { bossTest: room.session, gmTestMode: true }); + const snapshot = structuredClone(currentHudSnapshot()); + expect(snapshot.bossTestingDungeon?.definition).toEqual(room.definition); + expect(snapshot.game.bossTest).toEqual(room.session); + clearBossTestingDungeons(); + registerBossTestingDungeon(snapshot.bossTestingDungeon!.definition, snapshot.bossTestingDungeon!.sourceDungeonId); + expect(requireDungeonDefinition(snapshot.game.activeDungeonId).staticSpawns).toHaveLength(1); + } finally { + useGameStore.setState(original); + clearBossTestingDungeons(); + } + }); + it("contains cloneable presentation data without simulation methods", () => { + const snapshot = currentHudSnapshot(); + expect(() => structuredClone(snapshot)).not.toThrow(); + expect(snapshot.combat).not.toHaveProperty("tick"); + expect(snapshot.game).not.toHaveProperty("activateDungeon"); + expect(snapshot.party).not.toHaveProperty("recallParty"); + }); + it("accepts bounded moves only while the authoritative editor is open", () => { + const originalUi = useUiLayoutStore.getState(); + const originalGame = useGameStore.getState(); + try { + const draft = normalizeUiPreferences(null).current.mobile; + useUiLayoutStore.setState({ draft }); + useGameStore.setState({ overlay: null }); + executeCompanionCommand({ name: "editHudElement", id: "party", update: { position: { x: .8, y: .7 } } }); + expect(useUiLayoutStore.getState().draft).toBe(draft); + useGameStore.setState({ overlay: "ui-layout" }); + executeCompanionCommand({ name: "editHudElement", id: "party", update: { position: { x: 5, y: -1 }, width: 99999, screen: "hidden" } }); + expect(useUiLayoutStore.getState().draft?.elements.party).toMatchObject({ position: { x: 1, y: 0 }, width: 2000, visible: true }); + expect(useUiLayoutStore.getState().draft?.elements.party.screen).toBeUndefined(); + } finally { + useUiLayoutStore.setState(originalUi); + useGameStore.setState(originalGame); + } + }); +}); diff --git a/src/platform/hudSnapshot.ts b/src/platform/hudSnapshot.ts new file mode 100644 index 00000000..2d020a7a --- /dev/null +++ b/src/platform/hudSnapshot.ts @@ -0,0 +1,40 @@ +import { useCombatStore } from "../game/combatStore"; +import { useGameStore } from "../game/store"; +import { usePartyStore } from "../game/partyStore"; +import { useManastormStore } from "../game/manastormStore"; +import { useDungeonRunStore } from "../game/dungeonRunStore"; +import { useOnlineSessionStore } from "../app/onlineSessionStore"; +import { useOnlineGroupStore } from "../app/onlineGroupStore"; +import { useShellStore } from "../app/shellStore"; +import { useUiLayoutStore } from "../game/uiLayoutStore"; +import { useDpsMeterStore } from "../game/dpsMeterStore"; +import { activeDungeonRunIsComplete } from "../game/dungeonEncounterRuntime"; +import { activeDungeonInteractionCandidate } from "../game/dungeonWorldInteraction"; +import { requireDungeonDefinition } from "../game/dungeonRegistry"; + +function pick(state: T, keys: readonly K[]): Pick { + return Object.fromEntries(keys.map((key) => [key, state[key]])) as Pick; +} + +/** Presentation data only. The lower WebView never starts another simulation. */ +export function currentHudSnapshot() { + const game = useGameStore.getState(); + const run = useDungeonRunStore.getState().progress; + const candidate = game.overlay ? null : activeDungeonInteractionCandidate(); + return { + game: pick(game, ["activeDungeonId", "gameMode", "gmTestMode", "bossTest", "areaName", "distanceTravelled", "playerPosition", "cameraYaw", "hasMoved", "cameraLookActive", "inputMode", "actionLayer", "assetStatus", "assetMessage", "overlay", "paused", "companionOpen"]), + bossTestingDungeon: game.bossTest ? { definition: requireDungeonDefinition(game.activeDungeonId), sourceDungeonId: game.bossTest.sourceDungeonId } : null, + combat: pick(useCombatStore.getState(), ["classId", "secondaryClassId", "specializationId", "level", "xp", "xpToNext", "health", "maxHealth", "shield", "shieldExpiresAt", "resource", "maxResource", "resourcePools", "maxResourcePools", "resourceName", "abilities", "talentRanks", "talentModifiers", "auras", "effects", "summons", "actors", "actionBindings", "cooldowns", "globalCooldownEndsAt", "activeCast", "pendingGroundTarget", "feedback", "selectedTargetId", "selectedFriendlyActorId", "mobs", "settings"]), + party: pick(usePartyStore.getState(), ["members", "selectedMemberId", "selfSelected", "playerRole", "command", "routeBlocked"]), + manastorm: pick(useManastormStore.getState(), ["currentEncounter", "resurrectionCharges"]), + shell: pick(useShellStore.getState(), ["activeCharacter", "activeDungeonRole"]), + onlineSession: useOnlineSessionStore.getState().snapshot, + onlineGroup: pick(useOnlineGroupStore.getState(), ["snapshot", "activeActivity"]), + ui: pick(useUiLayoutStore.getState(), ["preferences", "draft", "device", "selectedElement"]), + run, + dps: useDpsMeterStore.getState(), + completion: game.gameMode === "dungeon" && (activeDungeonRunIsComplete(game.activeDungeonId) || (run?.dungeonId === game.activeDungeonId && run.finishedAt !== null)), + interaction: candidate ? { prompt: candidate.object.interaction!.prompt, name: candidate.object.name, label: candidate.state.label } : null, + }; +} +export type HudSnapshot = ReturnType; diff --git a/src/platform/thorDisplayProfile.test.ts b/src/platform/thorDisplayProfile.test.ts new file mode 100644 index 00000000..2de92125 --- /dev/null +++ b/src/platform/thorDisplayProfile.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { THOR_DISPLAYS, THOR_UI_PIXELS_PER_INCH } from "./thorDisplayProfile"; + +describe("Thor physical display sizing", () => { + it("uses millimetres for relative panel size, with equal physical touch targets", () => { + const { top, bottom } = THOR_DISPLAYS; + expect(top.widthMm).toBeCloseTo(132.83, 1); + expect(bottom.widthMm).toBeCloseTo(75.12, 1); + expect(bottom.cssWidth / top.cssWidth).toBeCloseTo(bottom.widthMm / top.widthMm, 2); + expect(bottom.deviceScaleFactor).toBeGreaterThan(top.deviceScaleFactor); + for (const panel of [top, bottom]) { + expect(44 * panel.deviceScaleFactor / panel.pixelsPerInch * 25.4).toBeCloseTo(44 / THOR_UI_PIXELS_PER_INCH * 25.4); + } + }); +}); diff --git a/src/platform/thorDisplayProfile.ts b/src/platform/thorDisplayProfile.ts new file mode 100644 index 00000000..cd376ca0 --- /dev/null +++ b/src/platform/thorDisplayProfile.ts @@ -0,0 +1,17 @@ +/** AYN Thor panel dimensions, at a common physical UI density (160 CSS px/in). */ +export const THOR_UI_PIXELS_PER_INCH = 160; +export function physicalDisplayProfile(width: number, height: number, diagonalInches: number) { + const pixelsPerInch = Math.hypot(width, height) / diagonalInches; + return { + width, height, diagonalInches, pixelsPerInch, + widthMm: width / pixelsPerInch * 25.4, + heightMm: height / pixelsPerInch * 25.4, + cssWidth: Math.round(width / pixelsPerInch * THOR_UI_PIXELS_PER_INCH), + cssHeight: Math.round(height / pixelsPerInch * THOR_UI_PIXELS_PER_INCH), + deviceScaleFactor: pixelsPerInch / THOR_UI_PIXELS_PER_INCH, + }; +} +export const THOR_DISPLAYS = { + top: physicalDisplayProfile(1920, 1080, 6), + bottom: physicalDisplayProfile(1240, 1080, 3.92), +}; diff --git a/src/scene/BossTestingRoom.tsx b/src/scene/BossTestingRoom.tsx new file mode 100644 index 00000000..78450e3d --- /dev/null +++ b/src/scene/BossTestingRoom.tsx @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { RigidBody } from "@react-three/rapier"; +import type { DungeonDefinition } from "../game/dungeonTypes"; +import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_WALL_COLLISION_GROUPS } from "./dungeonLineOfSight"; + +export function BossTestingRoom({ definition, onReady }: { readonly definition: DungeonDefinition; readonly onReady?: () => void }) { + const { min, max } = definition.bounds; + const x = (min[0] + max[0]) / 2; + const z = (min[2] + max[2]) / 2; + const y = definition.bosses[0].position[1]; + const width = max[0] - min[0]; + const depth = max[2] - min[2]; + useEffect(() => { + onReady?.(); + }, [onReady]); + return + + + + {([ + [min[0] - 0.5, y + 4, z, 1, 8, depth + 2], [max[0] + 0.5, y + 4, z, 1, 8, depth + 2], + [x, y + 4, min[2] - 0.5, width, 8, 1], [x, y + 4, max[2] + 0.5, width, 8, 1], + ] as const).map(([px, py, pz, w, h, d], index) => + + + + )} + + ; +} diff --git a/src/scene/DungeonEnvironment.tsx b/src/scene/DungeonEnvironment.tsx index 7b9cb4b3..5abd7034 100644 --- a/src/scene/DungeonEnvironment.tsx +++ b/src/scene/DungeonEnvironment.tsx @@ -5,10 +5,12 @@ import { activeManastormStageAssetPackage } from "../game/manastormStageLoader"; import { useGameStore } from "../game/store"; import { ProductionDungeon } from "./ProductionDungeon"; import { PrototypeDungeon } from "./PrototypeDungeon"; +import { BossTestingRoom } from "./BossTestingRoom"; export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void }) { const activeDungeonId = useGameStore((state) => state.activeDungeonId); const gameMode = useGameStore((state) => state.gameMode); + const bossTest = useGameStore((state) => state.bossTest); const sessionRevision = useGameStore((state) => state.sessionRevision); const definition = requireDungeonDefinition(activeDungeonId); const manastormPackage = gameMode === "manastorm" @@ -31,6 +33,7 @@ export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void && assets.collision.length > 0; useEffect(() => { + if (bossTest) return; if (hasProductionAssets) return; const missing = [ ...definition.assets.visual.filter((_, index) => !assets.visual[index]).map((asset) => asset.fileName), @@ -38,8 +41,9 @@ export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void ]; useGameStore.getState().setAssetStatus("proxy", `Waiting for ${missing.join(" and ") || "generated dungeon assets"}.`); onReady?.(); - }, [assets.collision, assets.visual, definition, hasProductionAssets, onReady]); + }, [assets.collision, assets.visual, bossTest, definition, hasProductionAssets, onReady]); + if (bossTest) return ; if (!hasProductionAssets) return ; return ( diff --git a/src/styles.css b/src/styles.css index f60b9fad..20505667 100644 --- a/src/styles.css +++ b/src/styles.css @@ -28,6 +28,30 @@ html, body, #root { body { color: var(--ink); } button { font: inherit; } +.ability-tooltip { + position: fixed; + z-index: 10000; + width: min(340px, calc(100vw - 24px)); + max-height: calc(100dvh - 24px); + overflow: hidden; + padding: 12px; + border: 1px solid var(--gold); + border-radius: 8px 3px; + background: var(--panel-solid); + box-shadow: var(--shadow); + color: var(--ink); + font-size: 12px; + line-height: 1.5; + text-align: left; + pointer-events: none; +} +.ability-tooltip header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } +.ability-tooltip header strong { color: var(--gold); font: 600 16px Georgia, serif; } +.ability-tooltip kbd { color: var(--muted); font-size: 10px; } +.ability-tooltip p { margin: 8px 0; white-space: pre-line; overflow-wrap: anywhere; } +.ability-tooltip footer { display: flex; flex-wrap: wrap; gap: 4px 12px; padding-top: 8px; border-top: 1px solid var(--edge); color: var(--moss-bright); font-size: 11px; } +.ability-tooltip .ability-tooltip__lock { margin-bottom: 0; color: var(--gold); } + .game-shell, .scene-shell { position: relative; width: 100%; @@ -2233,7 +2257,7 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview .manastorm-spell-option b { overflow: hidden; font: 9px Georgia, serif; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; } .manastorm-spell-option small { color: var(--front-dim); font-size: 6px; } .manastorm-spell-option.is-active { border-color: rgba(142,231,239,.72); background: rgba(81,126,143,.28); color: #efffff; box-shadow: inset 0 0 12px rgba(100,210,225,.11); } -.manastorm-spell-option:disabled { opacity: .42; } +.manastorm-spell-option:is(:disabled, [aria-disabled="true"]) { opacity: .42; } .manastorm-role-label { display: block; margin-top: 11px; } .manastorm-role-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin-top: 5px; } .manastorm-role-grid .dungeon-role-option { min-height: 47px; padding: 6px; } @@ -3772,7 +3796,7 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); } display: grid; justify-items: center; gap: .1rem; padding: .2rem; border: 1px solid #9dad8277; background: #08130feb; color: #fff; } -.arena-personal-bars button:disabled { opacity: .55; } +.arena-personal-bars button:is(:disabled, [aria-disabled="true"]) { opacity: .55; } .arena-personal-bars button kbd { position: absolute; top: .1rem; right: .2rem; z-index: 1; color: #fff5aa; font-size: .7rem; text-shadow: 1px 1px #000; } .arena-personal-bars button .action-slot__icon { width: 1.9rem; height: 1.9rem; } .arena-personal-bars button > span:last-of-type { font-size: .58rem; line-height: 1.15; overflow: hidden; } diff --git a/src/ui/AbilityBindingsPanel.tsx b/src/ui/AbilityBindingsPanel.tsx index a1860e8d..1546bab3 100644 --- a/src/ui/AbilityBindingsPanel.tsx +++ b/src/ui/AbilityBindingsPanel.tsx @@ -10,7 +10,7 @@ import { type ActionBindingLayer, } from "../game/actionBindings"; import { useCombatStore } from "../game/combatStore"; -import { defaultActionBarForCharacter } from "../game/abilityCatalog"; +import { abilityAtLevel, defaultActionBarForCharacter, type AbilityDefinition } from "../game/abilityCatalog"; import { useGameStore } from "../game/store"; import { actionKeyLabel, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout"; import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore"; @@ -20,6 +20,7 @@ import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime"; import { useInputBindingLabels } from "../input/useInputBindingLabels"; import { useMenuController, type MenuAction } from "../input/useMenuController"; import { ControllerButton } from "./ControllerButton"; +import { AbilityTooltipTarget, type AbilityTooltipData } from "./AbilityTooltip"; function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: string; color: string }) { const [failed, setFailed] = useState(false); @@ -97,7 +98,7 @@ const MAIN_ACTION_NEIGHBORS: Readonly void }) { const panel = useRef(null); const keyLabel = useInputBindingLabels(); const bindingChord = (layer: ActionBindingLayer, control: ActionBindingControl) => `${keyLabel("controller", `ability:${layer}:${control}`)} / ${keyLabel("pc", `ability:${layer}:${control}`)}`; @@ -106,6 +107,19 @@ function AbilityBindingsDialog() { const secondaryClassId = useCombatStore((state) => state.secondaryClassId); const level = useCombatStore((state) => state.level); const abilities = useCombatStore((state) => state.abilities); + const talentRanks = useCombatStore((state) => state.talentRanks); + const tooltipData = (definition: AbilityDefinition | null | undefined): AbilityTooltipData | null => { + if (!definition) return null; + const talentRank = definition.talentEntryId === undefined ? undefined : talentRanks[`coa-entry-${definition.talentEntryId}`] ?? 0; + const ability = abilityAtLevel(definition, level, talentRank); + return { + name: ability.name, description: ability.description, target: ability.target, + resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "), + cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000, + range: ability.range.max, lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined, + talentLocked: ability.talentEntryId !== undefined && !talentRank, + }; + }; const bindings = useCombatStore((state) => state.actionBindings); const pcLayout = useUiLayoutStore((state) => state.preferences.current.pc); const [layout, setLayout] = useState(() => currentUiDevice() === "pc" && pcLayout.wowBars ? "wow" : "controller"); @@ -133,7 +147,7 @@ function AbilityBindingsDialog() { ] : ordered; }, [abilities, editingAbilityId]); - const close = () => openOverlay("pause"); + const close = onClose ?? (() => openOverlay("pause")); const saveWowLayout = (update: (current: UiLayout) => UiLayout) => { try { const ui = useUiLayoutStore.getState(); @@ -335,6 +349,7 @@ function AbilityBindingsDialog() { const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null; const locked = Boolean(ability && ability.unlockLevel > level); return ( + + ); }; @@ -363,7 +379,7 @@ function AbilityBindingsDialog() {

{isWow ? "WoW loadout · 5 bars · 60 customizable slots" : "Controller loadout · 24 customizable slots"}

Ability controls

- + diff --git a/src/ui/BossCardCollection.test.ts b/src/ui/BossCardCollection.test.ts index 1441b507..7d813630 100644 --- a/src/ui/BossCardCollection.test.ts +++ b/src/ui/BossCardCollection.test.ts @@ -8,12 +8,13 @@ describe("boss card display", () => { it("shows per-rarity copy totals and every dungeon for the visible boss", () => { const boss = BOSS_CARD_CATALOG[0]; const markup = renderToStaticMarkup(createElement(BossCardCollection, { - onClose: () => {}, characterName: "Collector", + onClose: () => {}, collection: { counts: { [boss.key]: { normal: 7, rare: 1 } }, lastClaims: {} }, })); expect(markup).toContain("7 copies"); expect(markup).toContain("1 copy"); expect(markup).toContain("8 total copies"); + expect(markup).toContain("Account-wide collection log"); expect(markup).toContain("Group by dungeon"); expect(markup).toContain("Group by rarity"); for (const dungeon of boss.dungeons) expect(markup).toContain(dungeon.title); diff --git a/src/ui/BossCardCollection.tsx b/src/ui/BossCardCollection.tsx index 7ac7b12d..024952f4 100644 --- a/src/ui/BossCardCollection.tsx +++ b/src/ui/BossCardCollection.tsx @@ -1,9 +1,8 @@ import { lazy, Suspense, useMemo, useState, type CSSProperties } from "react"; -import { selectedCharacter, useShellStore } from "../app/shellStore"; -import { useCombatStore } from "../game/combatStore"; +import { useShellStore } from "../app/shellStore"; import { useGameStore } from "../game/store"; import { BOSS_CARD_CATALOG, groupBossCards, type BossCardGrouping } from "../game/bossCardCatalog"; -import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, emptyBossCardCollection, type BossCardCollection as Collection } from "../game/bossCards"; +import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, type BossCardCollection as Collection } from "../game/bossCards"; import { useMenuController, type MenuAction } from "../input/useMenuController"; import { ControllerButton } from "./ControllerButton"; import { dungeonContentIsUsable } from "../content/contentManager"; @@ -11,13 +10,11 @@ import { useContentManagerSnapshot } from "../content/useContentManager"; import "./bossCards.css"; const BossCardPortrait = lazy(() => import("./BossCardPortrait")); -const EMPTY_COLLECTION = emptyBossCardCollection(); const PAGE_SIZE = 8; -export function BossCardCollection({ onClose, collection, characterName }: { +export function BossCardCollection({ onClose, collection }: { readonly onClose: () => void; readonly collection: Collection; - readonly characterName: string; }) { useContentManagerSnapshot(); const [earnedOnly, setEarnedOnly] = useState(false); @@ -52,7 +49,7 @@ export function BossCardCollection({ onClose, collection, characterName }: { return
event.stopPropagation()}>
-

{characterName}’s collection log

Boss Cards

+

Account-wide collection log

Boss Cards

×
@@ -100,11 +97,7 @@ export function BossCardCollection({ onClose, collection, characterName }: { } export default function BossCardCollectionScreen({ onClose }: { readonly onClose?: () => void }) { - const savedCharacter = useShellStore(selectedCharacter); - const activeCharacter = useShellStore((state) => state.activeCharacter); - const phase = useShellStore((state) => state.phase); - const liveCollection = useCombatStore((state) => state.bossCards); - const character = activeCharacter ?? savedCharacter; + const collection = useShellStore((state) => state.bossCards); return useGameStore.getState().closeOverlay())} - collection={phase === "game" ? liveCollection : character?.bossCards ?? EMPTY_COLLECTION} characterName={character?.name ?? "Adventurer"} />; + collection={collection} />; } diff --git a/src/ui/BossGuidePanel.tsx b/src/ui/BossGuidePanel.tsx index 60cda85e..adb716cc 100644 --- a/src/ui/BossGuidePanel.tsx +++ b/src/ui/BossGuidePanel.tsx @@ -10,11 +10,12 @@ const GUIDE_CATALOG = buildBossGuideCatalog(DUNGEON_DEFINITIONS, BOSS_GUIDE_OVER function BossGuideDialog() { const activeDungeonId = useGameStore((state) => state.activeDungeonId); + const bossTest = useGameStore((state) => state.bossTest); const openOverlay = useGameStore((state) => state.openOverlay); - const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === activeDungeonId) ?? GUIDE_CATALOG[0]; + const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === (bossTest?.sourceDungeonId ?? activeDungeonId)) ?? GUIDE_CATALOG[0]; const [selectedDungeonId, setSelectedDungeonId] = useState(initialDungeon?.id ?? ""); const selectedDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon; - const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses[0]?.id ?? ""); + const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses.find((boss) => boss.entityId === bossTest?.entityId)?.id ?? selectedDungeon?.bosses[0]?.id ?? ""); const selectedBoss = selectedDungeon?.bosses.find((boss) => boss.id === selectedBossId) ?? selectedDungeon?.bosses[0]; const close = () => openOverlay("pause"); diff --git a/src/ui/BossTestingReviewPanel.tsx b/src/ui/BossTestingReviewPanel.tsx new file mode 100644 index 00000000..781dbdc8 --- /dev/null +++ b/src/ui/BossTestingReviewPanel.tsx @@ -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) => + {label}; + return
+
+

{defeated ? "Boss defeated" : "Boss Testing paused"}

{boss.name}

+

Current readiness: {BOSS_TESTING_STATUS_LABELS[review?.status ?? "needs-testing"]}

+
{BOSS_TESTING_STATUSES.map((status) => + {button(`boss-review-${status}`, `Mark ${BOSS_TESTING_STATUS_LABELS[status].toLowerCase()}`, () => void mark(status), review?.status === status)} + )}
+ {message &&

{message}

} +
+ {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)} +
+
+
; +} diff --git a/src/ui/BossTestingScreen.tsx b/src/ui/BossTestingScreen.tsx new file mode 100644 index 00000000..a8328138 --- /dev/null +++ b/src/ui/BossTestingScreen.tsx @@ -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(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) => + {label}; + return +
Main menu / Mechanics laboratory

Boss Testing

+ {button("boss-testing-back", "Back to main menu", onClose)}
+
+

Choose a boss to test with your party in an empty room. Missing content downloads automatically when you select a boss.

+
+ + + +
+ + {selected &&

Selected encounter

{selected.name}

{selected.dungeonTitle}

+ {BOSS_TESTING_STATUS_LABELS[reviews[selected.key]?.status ?? "needs-testing"]} + {reviews[selected.key]?.pending && Readiness saved on this device; waiting to sync.} +
} + {roles.length > 1 &&
{roles.map((value) => + setRole(value)} disabled={preparing}>{PARTY_ROLE_LABELS[value]} + )}
} + {preparing &&

Preparing {selected?.name}… {content.installingPackId ? "Downloading content" : "Checking assets"}

+ {content.installingPackId && <>{Math.round(content.downloadedBytes / 1024 / 1024)} / {Math.round(content.totalBytes / 1024 / 1024)} MB} +
} + {error &&

{error}

} + {!bosses.length &&

No bosses match these filters.

} +
{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)}
+

Mark readiness and submit a boss report from pause or after victory. Test fights do not award loot, XP, or boss cards.

+
+ + } bottom={} />; +} diff --git a/src/ui/CompanionPanel.tsx b/src/ui/CompanionPanel.tsx index f115e45b..613bebca 100644 --- a/src/ui/CompanionPanel.tsx +++ b/src/ui/CompanionPanel.tsx @@ -34,6 +34,8 @@ import { EditableHudElement } from "./EditableHudElement"; import { DpsMeterWidget } from "./DpsMeterWidget"; import { useUiLayoutStore } from "../game/uiLayoutStore"; import { useDpsMeterStore } from "../game/dpsMeterStore"; +import { currentDisplayLayout } from "../platform/displayLayout"; +import { DungeonCompanionHud } from "./DungeonCompanionHud"; function safePercent(current: number, maximum: number): number { if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0; @@ -166,12 +168,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }

Ready on both screens

One roster. Two ways to play.

-
01Local accountSalted password verifier on this device
+
01Your rosterChoose your adventurer after signing in
02Offline playNo sign-in or network required
03PC fallbackThis panel opens as an overlay
-
Top screen owns account entryTHOR / 620 x 540
+
Sign in on the top screenTHOR / 3.92 INCHES
); } @@ -297,6 +299,19 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false } return ; } + if (snapshot.phase === "class-hall") { + const definition = selected ? classById(selected.classId) : null; + return +
CLASS HALL
+

{selected?.name ?? "Your adventurer"}

+

{definition?.name ?? "Choose your path"}

+

Choose your specialization on the top screen.

+

Your specialization determines your role and starting equipment.

+
+
+
; + } + if (snapshot.phase === "manastorms") { return ; } @@ -510,11 +525,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false } } export function LocalCompanionPanel() { - useShellStore(); + const shell = useShellStore(); useGameStore(); useCombatStore(); usePartyStore(); useUiLayoutStore(); useDpsMeterStore(); + if (shell.phase === "game" && currentDisplayLayout() !== "single") return ; return ; } diff --git a/src/ui/DesktopActionBars.tsx b/src/ui/DesktopActionBars.tsx index 8d0b4a27..b71231f9 100644 --- a/src/ui/DesktopActionBars.tsx +++ b/src/ui/DesktopActionBars.tsx @@ -5,6 +5,7 @@ import { actionKeyLabel, type HudElementId } from "../game/uiLayout"; import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore"; import { EditableHudElement } from "./EditableHudElement"; import { ActionIcon, type ActionBarItem } from "./ActionBar"; +import { AbilityTooltipTarget } from "./AbilityTooltip"; export function DesktopActionBars({ items }: { items: Readonly> }) { const ui = useUiLayoutStore(); @@ -19,7 +20,8 @@ export function DesktopActionBars({ items }: { items: Readonly { const item = button.abilityId ? items[button.abilityId] : null; const selected = editing && ui.selectedButton?.bar === b && ui.selectedButton.slot === s; - return ; + ; })}
; })}; diff --git a/src/ui/DpsMeter.tsx b/src/ui/DpsMeter.tsx index d5baaad9..f521dc1b 100644 --- a/src/ui/DpsMeter.tsx +++ b/src/ui/DpsMeter.tsx @@ -4,11 +4,13 @@ import { resolveDpsMeterDisplay } from "../game/uiLayout"; import { currentDpsMeterSnapshot, useDpsMeterStore } from "../game/dpsMeterStore"; import { EditableHudElement } from "./EditableHudElement"; import { DpsMeterWidget } from "./DpsMeterWidget"; +import { useHudSurface } from "./HudSurface"; export function DpsMeter() { + const { surface } = useHudSurface(); useDpsMeterStore(); const ui = useUiLayoutStore(); const layout = ui.draft ?? ui.preferences.current[currentUiDevice()]; - if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== "main") return null; + if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== (surface === "top" ? "main" : "companion")) return null; return ; } diff --git a/src/ui/DungeonCompanionHud.tsx b/src/ui/DungeonCompanionHud.tsx new file mode 100644 index 00000000..87195802 --- /dev/null +++ b/src/ui/DungeonCompanionHud.tsx @@ -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
+ 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} /> + +
; +} diff --git a/src/ui/DungeonInteractionPrompt.tsx b/src/ui/DungeonInteractionPrompt.tsx index ff022482..1959290c 100644 --- a/src/ui/DungeonInteractionPrompt.tsx +++ b/src/ui/DungeonInteractionPrompt.tsx @@ -10,7 +10,10 @@ import { import { useGameStore } from "../game/store"; import { useInputBindingLabels } from "../input/useInputBindingLabels"; -export function DungeonInteractionPrompt() { +export function DungeonInteractionPrompt({ interaction, onInteract }: { + interaction?: { prompt: string; name: string; label: string } | null; + onInteract?: () => void; +} = {}) { const label = useInputBindingLabels(); const playerPosition = useGameStore((state) => state.playerPosition); const inputMode = useGameStore((state) => state.inputMode); @@ -21,19 +24,20 @@ export function DungeonInteractionPrompt() { activeDungeonEncounterRevision, ); void playerPosition; - const candidate = overlay ? null : activeDungeonInteractionCandidate(); + const local = interaction === undefined && !overlay ? activeDungeonInteractionCandidate() : null; + const candidate = interaction ?? (local ? { prompt: local.object.interaction!.prompt, name: local.object.name, label: local.state.label } : null); if (!candidate) return null; return ( ); diff --git a/src/ui/DungeonIssueReportPanel.tsx b/src/ui/DungeonIssueReportPanel.tsx index 27d1265e..c9e3bf0f 100644 --- a/src/ui/DungeonIssueReportPanel.tsx +++ b/src/ui/DungeonIssueReportPanel.tsx @@ -8,6 +8,7 @@ import { ControllerButton } from "./ControllerButton"; export function DungeonIssueReportPanel() { const dungeonId = useGameStore((state) => state.activeDungeonId); + const bossTest = useGameStore((state) => state.bossTest); const openOverlay = useGameStore((state) => state.openOverlay); const session = useShellStore((state) => state.session); const [comment, setComment] = useState(""); @@ -16,12 +17,12 @@ export function DungeonIssueReportPanel() { const [error, setError] = useState(""); const busy = useRef(false); const form = useRef(null); - const back = () => { if (!busy.current) openOverlay("pause"); }; + const back = () => { if (!busy.current) openOverlay(bossTest ? "boss-test-review" : "pause"); }; const canSubmit = Boolean(comment.trim()) && comment.length <= DUNGEON_REPORT_COMMENT_MAX_LENGTH && !submitting; const actions = useMemo(() => [ { id: "report-back", run: back, enabled: !submitting }, { id: "report-submit", run: () => form.current?.requestSubmit(), enabled: canSubmit && !submitted }, - ], [canSubmit, openOverlay, submitted, submitting]); + ], [bossTest, canSubmit, openOverlay, submitted, submitting]); const controller = useMenuController(actions, { initialId: "report-back", onBack: back }); const submit = async (event: FormEvent) => { @@ -31,7 +32,8 @@ export function DungeonIssueReportPanel() { setSubmitting(true); setError(""); try { - await submitDungeonReport(session, dungeonId, comment); + await submitDungeonReport(session, bossTest?.sourceDungeonId ?? dungeonId, comment, + bossTest ? { bossKey: bossTest.key, bossName: bossTest.name } : undefined); setSubmitted(true); } catch (failure) { setError(failure instanceof Error ? failure.message : "Your report could not be submitted. Please try again."); @@ -44,9 +46,9 @@ export function DungeonIssueReportPanel() { return (
-

Dungeon feedback

-

Report an issue

-

{dungeonById(dungeonId)?.title ?? dungeonId}

+

{bossTest ? "Boss feedback" : "Dungeon feedback"}

+

{bossTest ? "Submit a boss report" : "Report an issue"}

+

{bossTest ? `${bossTest.name} · ${dungeonById(bossTest.sourceDungeonId)?.title ?? bossTest.sourceDungeonId}` : dungeonById(dungeonId)?.title ?? dungeonId}

{submitted ?

Report submitted. You can follow its status in View Reports on the main menu.

: (
void submit(event)}> @@ -62,7 +64,7 @@ export function DungeonIssueReportPanel() {
)} Back to pause menu + className="button" type="button" onClick={back} disabled={submitting}>{bossTest ? "Back to boss review" : "Back to pause menu"}
); diff --git a/src/ui/DungeonReportsScreen.tsx b/src/ui/DungeonReportsScreen.tsx index 64218af1..41995063 100644 --- a/src/ui/DungeonReportsScreen.tsx +++ b/src/ui/DungeonReportsScreen.tsx @@ -23,6 +23,7 @@ function ReportCard({ report, canManage, saving, onUpdate, selectedId, select }: return (

{dungeonById(report.dungeonId)?.title ?? report.dungeonId}

{report.status}
+ {report.bossName && Boss: {report.bossName}}

{report.comment}

Submitted by {report.submittedBy} · {report.updatedBy && Updated by {report.updatedBy} · {new Date(report.updatedAt).toLocaleString()}} diff --git a/src/ui/EditableHudElement.tsx b/src/ui/EditableHudElement.tsx index 29441d71..59352827 100644 --- a/src/ui/EditableHudElement.tsx +++ b/src/ui/EditableHudElement.tsx @@ -1,6 +1,8 @@ import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { createPortal } from "react-dom"; -import { HUD_ELEMENTS, type HudElementId, type HudElementLayout } from "../game/uiLayout"; +import { HUD_ELEMENTS, resolveHudScreen, type HudElementId, type HudElementLayout } from "../game/uiLayout"; +import { currentDisplayLayout } from "../platform/displayLayout"; +import { useHudSurface } from "./HudSurface"; import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore"; import { useGameStore } from "../game/store"; import "./uiLayout.css"; @@ -22,23 +24,26 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor, onChange?: (update: Partial) => void; }) { const overlay = useGameStore((state) => state.overlay); + const surface = useHudSurface(); + const dualScreen = currentDisplayLayout() !== "single"; const editing = editingOverride ?? overlay === "ui-layout"; const state = useUiLayoutStore(); const layout = state.draft ?? state.preferences.current[currentUiDevice()]; const element = elementLayout ?? layout.elements[id]; + const screen = resolveHudScreen(id, layout, dualScreen); const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null); const ref = useRef(null); const [host, setHost] = useState(null); const drag = useRef<{ pointerId: number; dx: number; dy: number } | null>(null); const [bounds, setBounds] = useState({ width: 0, height: 0, panelWidth: 0, panelHeight: 0 }); - const positioned = editing || element.position !== null; + const positioned = editing || element.position !== null || (dualScreen && surface.surface === "bottom" && !["player", "actions", "cast", "experience", "dps"].includes(id)); const fallback = FALLBACKS[id] ?? [.5, .35, 250]; const position = element.position ?? (bottomAnchor !== undefined ? { x: .5, y: 1 - bottomAnchor / Math.max(1, bounds.height) } : origin ?? { x: fallback[0], y: fallback[1] }); const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2]; - useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector]); + useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector, screen]); useLayoutEffect(() => { if (positioned || !ref.current) return; @@ -66,18 +71,19 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor, const observer = new ResizeObserver(measure); observer.observe(node); observer.observe(host); measure(); return () => observer.disconnect(); - }, [positioned, host, hostSelector]); + }, [positioned, host, hostSelector, screen]); const updatePosition = (x: number, y: number) => { const update = { position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width }; if (onChange) onChange(update); + else if (surface.onChange) surface.onChange(id, update); else state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, [id]: { ...draft.elements[id], ...update } } })); }; const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2)); const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2)); const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined; const content =
{ if (editing && !id.startsWith("bar-")) { event.preventDefault(); event.stopPropagation(); useUiLayoutStore.setState({ selectedElement: id }); } }}> {children} {editing && }
; + if (dualScreen && screen !== surface.surface) return null; return positioned && host ? createPortal(content, host) : content; } diff --git a/src/ui/Hud.tsx b/src/ui/Hud.tsx index 81c74418..bb066bc2 100644 --- a/src/ui/Hud.tsx +++ b/src/ui/Hud.tsx @@ -1,4 +1,7 @@ import { useInputBindingLabels } from "../input/useInputBindingLabels"; +import { HudSurfaceContext } from "./HudSurface"; +import type { HudElementId, HudElementLayout } from "../game/uiLayout"; +import { DungeonInteractionPrompt } from "./DungeonInteractionPrompt"; import { useCallback, useEffect, @@ -168,7 +171,15 @@ export function DungeonCompletePrompt({ ); } -export function Hud() { +export function Hud({ surface = "top", onLayoutChange, completion, onFinish, onResurrect, interaction, onInteract }: { + surface?: "top" | "bottom"; + onLayoutChange?: (id: HudElementId, update: Partial) => void; + completion?: boolean; + onFinish?: () => void; + onResurrect?: (checkpointId: string) => void; + interaction?: { prompt: string; name: string; label: string } | null; + onInteract?: () => void; +} = {}) { const bindingLabel = useInputBindingLabels(); const ui = useUiLayoutStore(); const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars; @@ -230,7 +241,7 @@ export function Hud() { ); const runProgress = useDungeonRunStore((state) => state.progress); const finalBossDefeated = runProgress?.dungeonId === activeDungeonId && runProgress.finishedAt !== null; - const dungeonComplete = gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId)); + const dungeonComplete = completion ?? (gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId))); useEffect(() => { const timer = window.setInterval(() => setClock(Date.now()), 100); @@ -282,6 +293,10 @@ export function Hud() { disabled: talentLocked || level < ability.unlockLevel || health <= 0 || remaining > 0 || Boolean(activeCast) || cannotPay, lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined, target: ability.target, + cooldownSeconds: ability.cooldownMs / 1_000, + castSeconds: ability.castTimeMs / 1_000, + range: ability.range.max, + talentLocked, }; return [catalogAbility.id, item]; })), [assignedAbilities, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]); @@ -391,12 +406,14 @@ export function Hud() { }; return ( -
+ +
- + {surface === "top" && } + {dungeonComplete ? ( - useGameStore.getState().openOverlay("dungeon-results") : undefined} /> ) : null} @@ -409,7 +426,7 @@ export function Hud() { - {pendingGroundTarget ? ( + {surface === "top" && pendingGroundTarget ? (
Place ground effect @@ -507,7 +524,7 @@ export function Hud() { items={actionItems} activeLayer={actionLayer} onActivate={castBinding} - inputMode={inputMode} + inputMode={surface === "bottom" ? "gamepad" : inputMode} feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null} />}
@@ -527,8 +544,8 @@ export function Hud() { ].map((asset) => asset.fileName).join(" and ")}.`}
)} - - {health <= 0 ? ( + {surface === "top" && } + {surface === "top" && health <= 0 ? ( ) : null}
+ ); } diff --git a/src/ui/HudSurface.tsx b/src/ui/HudSurface.tsx new file mode 100644 index 00000000..9544069b --- /dev/null +++ b/src/ui/HudSurface.tsx @@ -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) => void; + onResurrect?: (checkpointId: string) => void; +}>({ surface: "top" }); +export const useHudSurface = () => useContext(HudSurfaceContext); diff --git a/src/ui/KeybindsPanel.tsx b/src/ui/KeybindsPanel.tsx index 2acc9d8d..1c561a7e 100644 --- a/src/ui/KeybindsPanel.tsx +++ b/src/ui/KeybindsPanel.tsx @@ -26,7 +26,7 @@ const PC_KEYS = [ "Insert", "Home", "End", "PageUp", "PageDown", ...Array.from({ length: 10 }, (_, i) => `Numpad${i}`), "NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide", "NumpadDecimal", "NumpadEnter", ]; -function KeybindsDialog() { +function KeybindsDialog({ onClose }: { onClose?: () => void }) { const [device, setDevice] = useState(() => defaultInputDevice(Capacitor.isNativePlatform(), currentUiDevice() === "mobile")); const [draft, setDraft] = useState(() => useUiLayoutStore.getState().preferences); const [search, setSearch] = useState(""); @@ -57,7 +57,7 @@ function KeybindsDialog() { const shown = INPUT_ACTIONS.filter((entry) => (group === "All actions" || entry.group === group) && (!conflictsOnly || conflicts.some((conflict) => conflict.id === entry.id)) && `${entry.label} ${detail(entry.id)} ${inputBindingLabel(device, bindings[entry.id])}`.toLowerCase().includes(search.toLowerCase())); - const close = () => useGameStore.getState().openOverlay("options"); + const close = onClose ?? (() => useGameStore.getState().openOverlay("options")); const endCapture = () => { setListening(false); setCapturingInput(false); }; const cancelEdit = () => { endCapture(); setEditing(null); setPending(null); setResetPending(false); }; const apply = (id: string, key: string | null) => { @@ -211,7 +211,7 @@ function KeybindsDialog() { ; } -export function KeybindsPanel() { - const open = useGameStore((state) => state.overlay === "keybinds"); - return open ? : null; +export function KeybindsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) { + const storeOpen = useGameStore((state) => state.overlay === "keybinds"); + return (open ?? storeOpen) ? : null; } diff --git a/src/ui/MainMenuCharacterEditor.tsx b/src/ui/MainMenuCharacterEditor.tsx new file mode 100644 index 00000000..80d97836 --- /dev/null +++ b/src/ui/MainMenuCharacterEditor.tsx @@ -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(null); + + useEffect(() => { + const stop = beginMainMenuCharacterEditing(); + setReadyKey(stop ? characterKey : null); + return () => stop?.(); + }, [characterKey]); + + const ready = readyKey === characterKey; + return +
+ +
{character?.name ?? "Character setup"} + {character ? `Level ${character.level} ${classById(character.classId).name}` : "Select a character to edit."}
+ +
+ {notice &&

{notice}

} +
+ {!ready ?

Preparing character…

: Opening talents…

}> + {editor === "talents" && (character?.categoryId === "rom" + ? + : )} + {editor === "keybinds" && } + {editor === "bindings" && } +
} +
+ + } />; +} diff --git a/src/ui/MainMenuScreen.tsx b/src/ui/MainMenuScreen.tsx index c468a708..247aedd9 100644 --- a/src/ui/MainMenuScreen.tsx +++ b/src/ui/MainMenuScreen.tsx @@ -11,7 +11,9 @@ import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface"; import { OnlineGroupPanel } from "./OnlineGroupPanel"; import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures"; import { DungeonReportsScreen } from "./DungeonReportsScreen"; +import { MainMenuCharacterEditor, type MainMenuEditor } from "./MainMenuCharacterEditor"; const BossCardCollectionScreen = lazy(() => import("./BossCardCollection")); +const BossTestingScreen = lazy(() => import("./BossTestingScreen").then((module) => ({ default: module.BossTestingScreen }))); const GAME_MODES = [ { @@ -68,16 +70,22 @@ export function MainMenuScreen() { const [contentManagerOpen, setContentManagerOpen] = useState(false); const [reportsOpen, setReportsOpen] = useState(false); const [cardsOpen, setCardsOpen] = useState(false); + const [bossTestingOpen, setBossTestingOpen] = useState(false); + const [editor, setEditor] = useState(null); + if (editor) return setEditor(null)} />; + if (bossTestingOpen) return Opening Boss Testing…

}> setBossTestingOpen(false)} />
; if (cardsOpen) return Opening collection log…

}> setCardsOpen(false)} />
; if (contentManagerOpen) return setContentManagerOpen(false)} />; if (reportsOpen) return setReportsOpen(false)} />; - return setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} />; + return setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenEditor={setEditor} />; } -function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: { +function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenEditor }: { readonly onOpenDownloads: () => void; readonly onOpenReports: () => void; readonly onOpenCards: () => void; + readonly onOpenBossTesting: () => void; + readonly onOpenEditor: (editor: MainMenuEditor) => void; }) { const activeCharacter = useShellStore((state) => state.activeCharacter); const rosterCharacter = useShellStore(selectedCharacter); @@ -118,11 +126,17 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: { run: returnToCharacters, neighbors: { up: "mode-dungeons", left: "mode-dungeons" }, }, - ...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []), + ...(character ? [ + { id: "mode-class-hall", run: openClassHall }, + { id: "mode-talents", run: () => onOpenEditor("talents") }, + { id: "mode-keybinds", run: () => onOpenEditor("keybinds") }, + { id: "mode-bindings", run: () => onOpenEditor("bindings") }, + ] : []), ...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []), { id: "mode-reports", run: onOpenReports }, { id: "mode-cards", run: onOpenCards }, - ], [character, isGm, onOpenCards, onOpenDownloads, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]); + { id: "mode-boss-testing", run: onOpenBossTesting }, + ], [character, isGm, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]); const controller = useMenuController(actions, { initialId: "mode-dungeons", onBack: returnToCharacters, @@ -177,7 +191,25 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {

Select a destination. More adventures will unlock here as they are built.

-