Compare commits

...
2 Commits
Author SHA1 Message Date
phenom 756ee4f64a Release Healer Man 0.1.44 2026-09-14 22:12:49 -04:00
phenom ccc01396cd Release Healer Man 0.1.43 2026-09-14 13:20:20 -04:00
95 changed files with 3512 additions and 230 deletions

No files matched your search

+225
View File
@@ -4,6 +4,48 @@ 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 New-character PC bars and missing-spell borders
- A temporary browser fixture used the real desktop bars, ability controls,
spellbook, combat store, and character UI activation with memory-only saves.
Fresh Priest and Mage profiles populated their PC bars automatically. The
Mage started with Frostbolt, Fire Blast, Frost Nova, and Arcane Intellect.
- Clearing Lesser Heal gave its spellbook entry and ability-picker entry a
visible light blue border, including the icon border. Both computed border
colors were `rgb(146, 213, 255)`. Assigned and locked spells kept their usual
borders. Reassigning Lesser Heal removed the missing-spell border.
- Switching to the fresh Mage and back retained the Priest's cleared slot.
Screenshots of both missing-spell menus were inspected during the playtest.
The temporary fixture was removed afterward.
- Validation: 62 focused tests, TypeScript, production build including bundle
verification, and the release-candidate check passed. Full-game verification
was limited by a persistent opening-expedition loading screen in both local
development and production; menu visuals were verified in the fixture.
## 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 +3197,186 @@ 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`.
### Boss Testing health correction
- Testing rooms now use the boss's authored health at the scaled enemy level,
without the campaign durability floor or final-boss bonus. A lone testing
objective previously made every selected boss qualify as a campaign finale.
At character level 1 this produced a 10,987 HP minimum instead of Celebras
the Cursed's 575 HP from his level-2 native stats and authored multiplier.
- Regression checks passed: 24 tests covering testing, campaign boss balance,
level scaling, asset preparation, and display snapshots. A full production
build passed its asset audits and release bundle validation.
- Production Edge playtest confirmed a level-1 WoW Priest fighting a level-2
Celebras with 575 maximum health. The boss model and active mechanics were
visible, and readiness labels, reports, retries, victory review, random
selection, mobile layout, automatic downloads, and lower-display review
passed without uncaught errors. Victory still uses deterministic damage;
natural time to defeat was not measured.
- Reproduce in PowerShell with `$env:BOSS_TEST_BOSS = 'Celebras the Cursed'`
followed by `node scripts/playtest-boss-testing.cjs`. Evidence is in
`playtest-artifacts/boss-testing-scaling/results.json` and paired screenshots.
### Regular dungeon health correction
- Regular dungeon scaling now selects the durability minimum using the
character's game. WoW uses an interpolated level curve, RoM uses 1.15 times
the expanded-class baseline, and CoA uses 1.75 times that baseline. These
independently calibrated floors keep every canonical class-role build above
the same 25-second ordinary and 45-second final-boss mechanic windows. Native
authored health can still exceed the floor. Final-boss and difficulty bonuses
remain part of campaign scaling. Boss Testing continues using scaled authored
health without a campaign minimum.
- On Easy, a level-3 WoW Priest now encounters a level-4 Targorr the Dread with
1,300 HP in regular Stockades, compared with the prior shared 10,766 HP
minimum. The WoW final boss has a 2,340 HP minimum at that level; equivalent
RoM and CoA parties receive 7,074.8 and 10,766 HP ordinary-boss floors.
- Level-3 WoW Priest simulations with three companion seeds cleared the opening
boss in 25.7-43.6 seconds and the final boss in 48.2-79.6 seconds. These timing
samples assume companions are in range and omit boss attacks; a separate
level-3 test verifies real boss attacks execute during the shortened fight.
- Validation passed for all 271 application test files: 1,759 tests passed and
six were skipped. The shared balance cohort covers every WoW, RoM, and CoA
class-role build at levels 8, 20, 40, and 80 with three deterministic seeds.
- The earlier production Edge capture recorded the intermediate 1,200 HP WoW
floor and is superseded by the final 1,300 HP calibration. The browser script
now checks the final value; a new live screenshot has not been recorded.
- Reproduce with `node scripts/playtest-dungeon-health.cjs`. Browser evidence is
in `playtest-artifacts/dungeon-health-scaling/results.json` and
`stockades-level-three.png`. Run the scaling tests with `BOSS_BALANCE_REPORT=1`
to record companion timing samples in `artifacts/boss-difficulty/ragefire-balance.json`;
the all-class report is `artifacts/boss-difficulty/wow-rom-coa-balance.json`.
@@ -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);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.42",
"version": "0.1.44",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.42",
"version": "0.1.44",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.42",
"version": "0.1.44",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.42",
"versionCode": 1042
"versionName": "0.1.44",
"versionCode": 1044
}
+179
View File
@@ -0,0 +1,179 @@
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 selectedBossName = process.env.BOSS_TEST_BOSS || 'Lady Anacondra';
const directory = path.resolve('playtest-artifacts', selectedBossName === 'Celebras the Cursed' ? 'boss-testing-scaling' : '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, name) => options.find((entry) => entry.textContent.startsWith(`${name} ·`))?.value, selectedBossName);
assert(option, `${selectedBossName} 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,
classId: combat.classId, level: combat.level, bossLevel: combat.mobs[boss.runtimeIds[0]].level,
bossMaxHealth: combat.mobs[boss.runtimeIds[0]].maxHealth };
});
assert.equal(before.count, 1, 'Only the selected boss spawns initially');
if (selectedBossName === 'Celebras the Cursed') {
assert.equal(before.classId, 'priest');
assert.equal(before.level, 1);
assert.equal(before.bossLevel, 2);
assert.equal(before.bossMaxHealth, 575, 'Level-one Celebras uses scaled native health without campaign floors');
}
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(selectedBossName);
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, classId: before.classId,
characterLevel: before.level, bossLevel: before.bossLevel, bossMaxHealth: before.bossMaxHealth,
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; });
+110
View File
@@ -0,0 +1,110 @@
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 zlib = require('node:zlib');
const assert = require('node:assert/strict');
const directory = path.resolve('playtest-artifacts/dungeon-health-scaling');
fs.mkdirSync(directory, { recursive: true });
const catalogSource = fs.readFileSync('src/game/generated/dungeonCampaignCatalog.compressed.ts', 'utf8');
const catalog = JSON.parse(zlib.gunzipSync(Buffer.from(catalogSource.match(/"([A-Za-z0-9+/=]+)"/)[1], 'base64')));
const dungeon = catalog.definitions.find((entry) => entry.id === 'stormwind-stockade');
const boss = dungeon.bosses[0];
(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(4190, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(90000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const files = fs.readdirSync('dist/assets');
const mainModule = `/assets/${files.find((file) => /^index-.*\.js$/.test(file))}`;
const combatModule = `/assets/${files.find((file) => /^combatStore-.*\.js$/.test(file))}`;
await page.addInitScript(({ mainModule, combatModule }) => {
window.healthTestStores = async () => {
const exports = [...Object.values(await import(mainModule)), ...Object.values(await import(combatModule))];
return {
game: exports.find((value) => value?.getState && 'bossTest' in value.getState()),
shell: exports.find((value) => value?.getState && 'characters' in value.getState()),
combat: exports.find((value) => value?.getState && 'damageMob' in value.getState()),
party: exports.find((value) => value?.getState && 'categoryId' in value.getState() && 'members' in value.getState()),
};
};
}, { mainModule, combatModule });
try {
await page.goto('http://127.0.0.1:4190', { waitUntil: 'domcontentloaded' });
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('Healthtester');
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();
// Isolated level-three profile fixture; dungeon entry and registration use the normal UI.
await page.evaluate(async () => {
const { shell } = await window.healthTestStores();
const state = shell.getState();
const character = state.characters.find((entry) => entry.id === state.selectedCharacterId);
shell.setState({ activeCharacter: character });
shell.getState().saveActiveProgression(3, 0, character.talentRanks);
shell.setState({ activeCharacter: null });
});
await page.getByRole('button', { name: /^Dungeons/ }).click();
await page.locator('.dungeon-select-row').filter({ hasText: /Stockade/i }).click();
await page.getByRole('button', { name: 'Download and continue', exact: true }).click();
await page.locator('.dungeon-role-option--healer').click();
await page.evaluate(async () => {
const { game } = await window.healthTestStores();
game.getState().openOverlay('pause');
});
await page.waitForFunction(async () => {
const { game, combat } = await window.healthTestStores();
return game.getState().assetStatus === 'ready' && combat.getState().level === 3;
});
await page.waitForTimeout(10000);
await page.evaluate(async (boss) => {
const { game, combat, party } = await window.healthTestStores();
party?.getState().setCommand('stop');
game.getState().relocatePlayer({ footPosition: [boss.position[0], boss.position[1], boss.position[2] + 5], yaw: Math.PI });
combat.getState().setPlayerPosition(game.getState().playerPosition);
}, boss);
await page.waitForFunction(async (id) => {
const { combat } = await window.healthTestStores();
return combat.getState().level === 3 && combat.getState().mobs[id]?.maxHealth === 1300;
}, boss.id);
const result = await page.evaluate(async (id) => {
const { game, combat } = await window.healthTestStores();
const state = combat.getState();
state.selectMob(id);
game.getState().closeOverlay();
return { dungeonId: game.getState().activeDungeonId, gameMode: game.getState().gameMode,
bossTest: game.getState().bossTest, bossDifficulty: game.getState().activeBossDifficulty,
classId: state.classId, characterLevel: state.level, boss: state.mobs[id].name,
bossLevel: state.mobs[id].level, bossMaxHealth: state.mobs[id].maxHealth };
}, boss.id);
assert.equal(result.dungeonId, 'stormwind-stockade');
assert.equal(result.gameMode, 'dungeon');
assert.equal(result.bossTest, null);
assert.equal(result.bossDifficulty, 1);
assert.equal(result.classId, 'priest');
assert.equal(result.characterLevel, 3);
assert.equal(result.bossLevel, 4);
assert.equal(result.bossMaxHealth, 1300);
await page.waitForTimeout(1500);
await page.screenshot({ path: path.join(directory, 'stockades-level-three.png') });
assert.deepEqual(errors, []);
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ passed: true, ...result, errors }, null, 2)}\n`);
console.log(JSON.stringify(result, null, 2));
console.log('Regular dungeon health playtest passed');
} catch (error) {
await page.screenshot({ path: path.join(directory, 'failure.png') });
console.log((await page.locator('body').innerText()).slice(-2000));
console.log('Browser errors:', errors);
throw error;
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+249
View File
@@ -0,0 +1,249 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const directory = path.resolve('playtest-artifacts/thor-ui');
fs.mkdirSync(directory, { recursive: true });
const base = process.env.THOR_TEST_URL || 'http://127.0.0.1:5176';
const results = [];
(async () => {
console.log('Launching Thor UI browser');
const browser = await chromium.launch({ channel: 'msedge', headless: true });
console.log('Browser launched');
const context = await browser.newContext({ hasTouch: true, userAgent: 'Mozilla/5.0 (Linux; Android 13; AYN Thor) AppleWebKit/537.36 Chrome/130.0.0.0 Mobile Safari/537.36' });
await context.addInitScript(() => performance.setResourceTimingBufferSize(20000));
const top = await context.newPage();
const bottom = await context.newPage();
console.log('Two pages ready');
const errors = [];
for (const [page, width, height, diagonal] of [[top, 1920, 1080, 6], [bottom, 1240, 1080, 3.92]]) {
const ppi = Math.hypot(width, height) / diagonal;
const viewport = { width: Math.round(width / ppi * 160), height: Math.round(height / ppi * 160) };
await page.setViewportSize(viewport);
const cdp = await context.newCDPSession(page);
await cdp.send('Emulation.setDeviceMetricsOverride', { ...viewport, deviceScaleFactor: ppi / 160, mobile: false });
console.log('Configured panel', viewport);
page.setDefaultTimeout(60000);
page.on('pageerror', (error) => errors.push(error.message));
}
const capture = async (name) => {
for (const [surface, page] of [['top', top], ['bottom', bottom]]) {
await page.screenshot({ path: path.join(directory, `${name}-${surface}.png`) });
const overflow = await page.evaluate(() => {
const root = document.querySelector('#root');
const buttons = [...document.querySelectorAll('button')].filter((node) => node.getBoundingClientRect().width && getComputedStyle(node).visibility !== 'hidden');
const clipped = buttons.filter((node) => {
const rect = node.getBoundingClientRect();
if (rect.right <= innerWidth + 1 && rect.left >= -1 && rect.bottom <= innerHeight + 1 && rect.top >= -1) return false;
for (let parent = node.parentElement; parent && parent !== root; parent = parent.parentElement) {
const css = getComputedStyle(parent);
if (/(auto|scroll)/.test(css.overflow + css.overflowY + css.overflowX)) return false;
}
return true;
});
return { width: innerWidth, height: innerHeight, clipped: clipped.map((node) => node.textContent.trim().slice(0, 60)) };
});
results.push({ name, surface, ...overflow });
}
console.log(`Captured ${name}`);
};
try {
console.log('Opening top');
await top.goto(`${base}/?display=top&nativeLayout=1`, { waitUntil: 'domcontentloaded' });
console.log('Top DOM ready');
await top.getByLabel('Username', { exact: true }).waitFor();
await bottom.goto(`${base}/?display=bottom&nativeLayout=1`, { waitUntil: 'domcontentloaded' });
await bottom.locator('.companion-panel').waitFor();
await capture('01-login');
await top.getByRole('button', { name: /Play offline/ }).click();
await top.locator('.character-select-screen').waitFor();
await capture('02-roster-empty');
await top.getByRole('button', { name: /Create character/ }).click();
await top.locator('.character-create-screen').waitFor();
await capture('03-character-create');
await top.getByRole('button', { name: /Customize & name/ }).tap();
await top.getByLabel('Character name').fill('Thortester');
await capture('03b-appearance');
await top.getByRole('button', { name: /Create character/ }).tap();
await top.locator('.character-select-screen').waitFor();
await capture('04-roster');
await top.bringToFront();
await top.waitForTimeout(2000);
await capture('04b-roster-rendered');
await top.getByRole('button', { name: /Continue|Enter world/ }).first().click();
await top.locator('.main-menu-screen').waitFor();
await capture('05-main-menu');
await top.getByRole('button', { name: /Class Hall/ }).tap();
await top.locator('.class-hall-screen').waitFor();
await capture('05b-class-hall');
await top.locator('.class-hall-header button').tap();
for (const name of ['Talents', 'Keybinds', 'Button assignments']) {
await top.getByRole('button', { name: new RegExp(name) }).tap();
await top.locator('.main-menu-character-editor').waitFor();
await top.locator('.main-menu-character-editor .game-panel').waitFor();
await capture(`05-editor-${name.toLowerCase().replaceAll(' ', '-')}`);
await top.getByRole('button', { name: 'Back to main menu', exact: true }).tap();
}
await top.getByRole('button', { name: /View Reports/ }).tap();
await top.getByRole('heading', { name: 'View Reports' }).waitFor();
await capture('05c-reports');
await top.getByRole('button', { name: /Back/ }).first().tap();
await top.getByRole('button', { name: /Downloads/ }).tap();
await top.locator('.content-manager-screen').waitFor();
await capture('05d-downloads');
await top.getByRole('button', { name: 'Back', exact: true }).tap();
await top.getByRole('button', { name: /^Dungeons/ }).click();
await top.locator('.dungeon-select-screen').waitFor();
await capture('06-dungeon-picker');
await top.getByRole('button', { name: /Choose role/ }).click();
await capture('07-role-picker');
await top.locator('.dungeon-role-option').first().click();
await top.locator('.game-shell').waitFor();
await bottom.locator('.companion-hud-shell').waitFor();
await top.waitForFunction(async () => { const { useGameStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/store.ts').name); return ['ready', 'proxy', 'error'].includes(useGameStore.getState().assetStatus); }, null, { timeout: 120000 });
await top.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
await top.waitForTimeout(1500);
await capture('08-dungeon');
assert.equal(await bottom.locator('[data-hud-element="party"]').count(), 0);
assert.equal(await top.locator('[data-hud-element="actions"]').count(), 0);
assert.equal(await top.locator('[data-hud-element="player"]').count(), 0);
assert.equal(await bottom.locator('.action-bar').count(), 1);
assert.equal(await bottom.locator('.player-status-panel').count(), 1);
await bottom.locator('.aura--inspectable').first().tap();
await bottom.getByRole('tooltip').waitFor();
await capture('08b-buff-inspection');
await bottom.getByRole('button', { name: /^Fade,/ }).tap();
await top.waitForFunction(async () => {
const { useCombatStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/combatStore.ts').name);
return Object.values(useCombatStore.getState().cooldowns).some((endsAt) => endsAt > Date.now());
});
for (const overlay of ['pause', 'map', 'party', 'boss-guide', 'spellbook', 'talents', 'bindings', 'inventory', 'boss-cards', 'options', 'keybinds', 'report-issue']) {
await top.evaluate(async (overlay) => { const { useGameStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/store.ts').name); useGameStore.getState().openOverlay(overlay); }, overlay);
await top.waitForTimeout(500);
await capture(`09-${overlay}`);
}
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
await top.locator('.ui-layout-editor').waitFor();
assert.equal(await top.getByLabel('Party frames screen').inputValue(), 'top');
assert.equal(await top.getByLabel('Buffs and debuffs screen').inputValue(), 'bottom');
await top.getByLabel('Party frames screen').selectOption('bottom');
await bottom.getByRole('button', { name: 'Move Party frames', exact: true }).waitFor();
assert.equal(await top.locator('[data-hud-element="party"]').count(), 0);
await capture('10-editor-party-bottom');
const handle = bottom.getByRole('button', { name: 'Move Party frames', exact: true });
const rect = await handle.boundingBox();
const cdp = await context.newCDPSession(bottom);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: rect.x + 10, y: rect.y + 10 }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: rect.x + 90, y: rect.y + 60 }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await top.waitForFunction(async () => {
const { useUiLayoutStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/uiLayoutStore.ts').name);
return useUiLayoutStore.getState().draft.elements.party.position !== null;
});
await top.getByLabel('Party frames screen').selectOption('hidden');
await bottom.waitForFunction(() => !document.querySelector('[data-hud-element="party"]'));
await top.getByLabel('Party frames screen').selectOption('bottom');
await top.getByRole('button', { name: 'Cancel edits', exact: true }).tap();
await top.locator('.ui-layout-editor').waitFor({ state: 'hidden' });
await bottom.waitForFunction(() => !document.querySelector('[data-hud-element="party"]'));
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
await top.getByLabel('Party frames screen').selectOption('bottom');
await capture('11-editor');
await top.getByRole('button', { name: 'Save UI', exact: true }).tap();
await top.waitForFunction(() => !document.querySelector('.ui-layout-editor'));
await capture('12-saved');
await top.reload({ waitUntil: 'domcontentloaded' });
await top.locator('.character-select-screen').waitFor();
await top.getByRole('button', { name: /Continue/ }).tap();
await top.getByRole('button', { name: /^Dungeons/ }).tap();
await top.getByRole('button', { name: /Choose role/ }).tap();
await top.locator('.dungeon-role-option').first().tap();
await bottom.locator('[data-hud-element="party"] .party-frame').first().waitFor();
assert.equal(await top.locator('[data-hud-element="party"]').count(), 0);
await bottom.locator('.party-frame--player').tap();
await top.waitForFunction(async () => {
const { usePartyStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/partyStore.ts').name);
return usePartyStore.getState().selfSelected;
});
const partyBounds = await bottom.locator('.party-frames').boundingBox();
const menuBounds = await bottom.locator('.companion-hud-menus').boundingBox();
assert.ok(partyBounds.y + partyBounds.height <= menuBounds.y, 'Relocated party frames must fit above the bottom navigation');
await capture('13-persisted-party-bottom');
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
await top.getByRole('button', { name: 'Reset all elements', exact: true }).tap();
await top.getByRole('button', { name: 'Save UI', exact: true }).tap();
await capture('14-final-defaults');
await top.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
await top.evaluate(async () => {
const load = (pathname) => import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === pathname).name);
const { useGameStore } = await load('/src/game/store.ts');
const { useCombatStore } = await load('/src/game/combatStore.ts');
useGameStore.setState({ paused: true, overlay: null });
useCombatStore.setState({ health: 0 });
});
await top.locator('.defeated-prompt').waitFor();
assert.equal(await bottom.locator('.defeated-prompt').count(), 0);
await capture('14b-defeated');
await top.getByRole('button', { name: 'Respawn at entrance', exact: true }).tap();
await top.locator('.defeated-prompt').waitFor({ state: 'hidden' });
// Isolated offline presentation fixture: exercise the completed-run UI without a live boss clear.
await top.evaluate(async () => {
const load = (pathname) => import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === pathname).name);
const { useGameStore } = await load('/src/game/store.ts');
const { useDungeonRunStore } = await load('/src/game/dungeonRunStore.ts');
const { usePartyStore } = await load('/src/game/partyStore.ts');
const { useCombatStore } = await load('/src/game/combatStore.ts');
const progress = useDungeonRunStore.getState().progress;
const now = performance.now();
const party = [{ name: 'Thortester', classId: useCombatStore.getState().classId, level: 1, role: 'healer', npc: false },
...usePartyStore.getState().members.map((member) => ({ name: member.name, classId: member.classId, level: member.level, role: member.role, npc: true }))];
useGameStore.setState({ paused: true, overlay: null });
useDungeonRunStore.setState({ progress: { ...progress, startedAt: now - 60000, finishedAt: now, durationMs: 60000, party } });
});
await top.locator('.dungeon-complete-banner').waitFor();
assert.equal(await bottom.locator('.dungeon-complete-banner').count(), 0);
await capture('14c-complete');
await top.locator('.dungeon-results').waitFor();
await capture('14d-results');
await top.getByRole('button', { name: /Stay and loot/ }).tap();
await top.getByRole('button', { name: 'Run results', exact: true }).tap();
await top.locator('.dungeon-results').waitFor();
await top.getByRole('button', { name: /Stay and loot/ }).tap();
await bottom.getByRole('button', { name: 'Edit UI', exact: true }).tap();
await top.getByLabel('Dungeon completion screen').selectOption('bottom');
await top.getByRole('button', { name: 'Save UI', exact: true }).tap();
await bottom.locator('.dungeon-complete-banner').waitFor();
assert.equal(await top.locator('.dungeon-complete-banner').count(), 0);
await capture('14e-complete-bottom');
await bottom.getByRole('button', { name: 'Finish dungeon', exact: true }).tap();
await top.locator('.main-menu-screen').waitFor();
await top.evaluate(async () => {
const { useUiLayoutStore } = await import(performance.getEntriesByType('resource').findLast((entry) => new URL(entry.name).pathname === '/src/game/uiLayoutStore.ts').name);
const state = useUiLayoutStore.getState(); state.begin(); state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, completion: { visible: true, position: null, width: null } } })); state.finish(true);
});
const previewCdp = await context.newCDPSession(top);
await top.setViewportSize({ width: 1200, height: 1100 });
await previewCdp.send('Emulation.setDeviceMetricsOverride', { width: 1200, height: 1100, deviceScaleFactor: 1, mobile: false });
await top.goto(`${base}/?layout=thor-preview`, { waitUntil: 'domcontentloaded' });
await top.getByRole('button', { name: /Continue/ }).first().tap();
await top.locator('.thor-preview-top').getByRole('button', { name: /^Dungeons/ }).tap();
await top.locator('.thor-preview-top').getByRole('button', { name: /Choose role/ }).tap();
await top.locator('.thor-preview-top .dungeon-role-option').first().tap();
await top.locator('.thor-preview-top .loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
await top.waitForTimeout(1000);
assert.equal((await top.locator('.thor-preview-top').boundingBox()).width, 837);
assert.equal((await top.locator('.thor-preview-bottom').boundingBox()).width, 473);
assert.equal(await top.locator('.thor-preview-bottom [data-hud-element="party"]').count(), 0);
assert.equal(await top.locator('.thor-preview-top .action-bar').count(), 0);
await top.screenshot({ path: path.join(directory, '15-combined-preview.png'), fullPage: true });
results.push({ name: '15-combined-preview', surface: 'both', clipped: [] });
assert.deepEqual(results.filter((result) => result.clipped.length), [], 'Controls must fit each screen or an intentional scroll region');
assert.equal(errors.length, 0, errors.join('\n'));
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ results, errors }, null, 2)}\n`);
} finally {
console.log('Browser errors', errors);
await top.screenshot({ path: path.join(directory, 'last-top.png') });
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ results, errors }, null, 2)}\n`);
await browser.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+43 -3
View File
@@ -19,11 +19,22 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
revision INTEGER NOT NULL DEFAULT 0
) 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) {
+29
View File
@@ -69,6 +69,30 @@ test("persists reports and status changes across database restarts", async () =>
} finally { database?.close(); rmSync(directory, { recursive: true, force: true }); }
});
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);
+13
View File
@@ -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"),
+11 -5
View File
@@ -1,4 +1,5 @@
import { useEffect } from "react";
import { useShellStore } from "./app/shellStore";
import "./ui/uiLayout.css";
import { DualDisplayFrame } from "./components/DualDisplayFrame";
import { installInput, releasePointerLock } from "./game/inputManager";
@@ -7,7 +8,8 @@ import { connectDpsMeter } from "./game/dpsMeterStore";
import { useCombatStore } from "./game/combatStore";
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
import { requireDungeonDefinition } from "./game/dungeonRegistry";
import { bossTestingSourceDungeonId, clearBossTestingDungeons, requireDungeonDefinition } from "./game/dungeonRegistry";
import { BossTestingBridge } from "./game/BossTestingBridge";
import { PartyBridge } from "./game/PartyBridge";
import { usePartyStore } from "./game/partyStore";
import { useGameStore } from "./game/store";
@@ -24,9 +26,7 @@ import { DungeonRunBridge } from "./game/DungeonRunBridge";
import { DungeonRunResults } from "./ui/DungeonRunResults";
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
import { UiLayoutEditor } from "./ui/UiLayoutEditor";
import { EditableHudElement } from "./ui/EditableHudElement";
import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore";
import { useUiLayoutStore } from "./game/uiLayoutStore";
import { profileInputBindings } from "./game/keybindPreferences";
@@ -39,7 +39,7 @@ function InputBridge() {
useEffect(() => installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results"].includes(useGameStore.getState().overlay ?? ""),
inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
getBindings: (device) => {
const preferences = useUiLayoutStore.getState().preferences;
const bindings = profileInputBindings(preferences, device);
@@ -103,6 +103,12 @@ export default function GameRuntime() {
clearDungeonRuntimeCaches();
releaseDungeonGltfCache(activeDungeon);
releaseShelvedGameRuntime();
const sourceDungeonId = bossTestingSourceDungeonId(activeDungeon.id);
if (sourceDungeonId) {
useGameStore.setState({ activeDungeonId: sourceDungeonId, bossTest: null, gmTestMode: false });
useShellStore.setState({ activeDungeonId: sourceDungeonId });
}
clearBossTestingDungeons();
}, []);
return (
<DualDisplayFrame
@@ -118,10 +124,10 @@ export default function GameRuntime() {
<CombatBridge />
<PartyBridge />
<DungeonRunBridge />
<BossTestingBridge />
<ShelvedGameBridges />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
<Hud />
<EditableHudElement id="interaction"><DungeonInteractionPrompt /></EditableHudElement>
<MapPanel />
<GameplayPanels />
<PauseMenu />
+49 -6
View File
@@ -5,6 +5,10 @@ import {
createLocalAccount,
createOfflineSession,
listCharacters,
loadAccountBossCards,
saveAccountBossCards,
replaceCharactersForOwner,
deleteCharacter,
loadCombatPresentationSettings,
loginLocalAccount,
restoreSession,
@@ -12,7 +16,6 @@ import {
updateCharacterEquipment,
updateCharacterDungeonRunCheckpoint,
updateCharacterInventory,
updateCharacterBossCards,
updateCharacterManastormProgress,
updateCharacterProgression,
updateCharacterSettings,
@@ -213,7 +216,7 @@ describe("local profile repository", () => {
}));
expect(loadCombatPresentationSettings("offline-roster", storage))
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
}
const legacyV3Storage = new MemoryStorage();
@@ -382,18 +385,58 @@ describe("local profile repository", () => {
});
});
it("persists card copies and claim receipts per character across roster reloads", () => {
it("persists account card copies independently of character creation and deletion", () => {
const storage = new MemoryStorage();
const offline = createOfflineSession();
const draft = { ownerId: offline.ownerId, name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } };
const character = createCharacter(draft, storage).character!;
const other = createCharacter({ ...draft, name: "Other" }, storage).character!;
const bossCards = { counts: { "wow:lady anacondra": { normal: 7, epic: 1 } }, lastClaims: { spawn: "kill:7" } };
expect(updateCharacterBossCards(offline.ownerId, character.id, bossCards, storage)?.bossCards).toEqual(bossCards);
expect(saveAccountBossCards(offline.ownerId, bossCards, storage)).toEqual(bossCards);
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
expect(loadAccountBossCards("different-account", storage)).toEqual({ counts: {}, lastClaims: {} });
const roster = listCharacters(offline.ownerId, storage);
expect(roster.find((entry) => entry.id === character.id)?.bossCards).toEqual(bossCards);
expect(roster.find((entry) => entry.id === other.id)?.bossCards).toEqual({ counts: {}, lastClaims: {} });
expect(roster.find((entry) => entry.id === character.id)?.bossCards).toBeUndefined();
expect(roster.find((entry) => entry.id === other.id)?.bossCards).toBeUndefined();
expect(roster.find((entry) => entry.id === character.id)?.inventory).toEqual([]);
deleteCharacter(offline.ownerId, character.id, storage);
deleteCharacter(offline.ownerId, other.id, storage);
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
createCharacter({ ...draft, name: "Newcomer" }, storage);
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
});
it("migrates and sums all legacy character logs once while retaining the newest receipts", () => {
const storage = new MemoryStorage();
const draft = { ownerId: "one", name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } };
const first = createCharacter(draft, storage).character!;
const second = createCharacter({ ...draft, name: "Other" }, storage).character!;
const separate = createCharacter({ ...draft, ownerId: "two" }, storage).character!;
const legacyCharacters = [
{ ...first, bossCards: { counts: { boss: { normal: 7, epic: 1 } }, lastClaims: { spawn: "100:1:spawn" } } },
{ ...second, bossCards: { counts: { boss: { normal: 3, rare: 2 } }, lastClaims: { spawn: "200:2:spawn" } } },
];
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
version: 6, accounts: [], rosters: { one: legacyCharacters, two: [{ ...separate, bossCards: { counts: { boss: { normal: 4 } }, lastClaims: {} } }] },
}));
const expected = { counts: { boss: { normal: 10, rare: 2, epic: 1 } }, lastClaims: { spawn: "200:2:spawn" } };
expect(loadAccountBossCards("one", storage)).toEqual(expected);
expect(loadAccountBossCards("one", storage)).toEqual(expected);
expect(loadAccountBossCards("two", storage).counts.boss?.normal).toBe(4);
const database = JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!);
expect(database.version).toBe(7);
expect(database.rosters.one.every((entry: { bossCards?: unknown }) => entry.bossCards === undefined)).toBe(true);
// An old cloud roster can be downloaded again without repeating the migration.
replaceCharactersForOwner("one", legacyCharacters, storage);
replaceCharactersForOwner("one", legacyCharacters, storage);
expect(loadAccountBossCards("one", storage)).toEqual(expected);
});
it("restores account logs from cloud without overwriting higher cached counts", () => {
const storage = new MemoryStorage();
saveAccountBossCards("one", { counts: { boss: { normal: 8 } }, lastClaims: { spawn: "200:2:spawn" } }, storage);
replaceCharactersForOwner("one", [], storage, { counts: { boss: { normal: 7, rare: 1 } }, lastClaims: { spawn: "100:1:spawn" } });
expect(loadAccountBossCards("one", storage)).toEqual({ counts: { boss: { normal: 8, rare: 1 } }, lastClaims: { spawn: "200:2:spawn" } });
});
it("persists threat-meter visibility and position per character", () => {
+48 -31
View File
@@ -1,5 +1,5 @@
import type { CharacterProfile, PlayerSession } from "./types";
import { normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
import { mergeBossCardCollections, normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
import {
contentCategoryForClass,
@@ -62,9 +62,10 @@ interface LocalAccount {
}
interface ProfileDatabase {
version: 6;
version: 7;
accounts: LocalAccount[];
rosters: Record<string, CharacterProfile[]>;
bossCardsByOwner: Record<string, BossCardCollection>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
uiPreferencesByOwner?: Record<string, UiPreferences>;
}
@@ -105,7 +106,7 @@ function browserSessionStorage(): StorageLike | null {
}
function emptyDatabase(): ProfileDatabase {
return { version: 6, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
return { version: 7, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} };
}
function isCharacterProfile(value: unknown): value is CharacterProfile {
@@ -121,6 +122,9 @@ function isCharacterProfile(value: unknown): value is CharacterProfile {
}
function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
const profile = { ...value };
// Cards now belong to the owner; legacy fields are consumed before normalization.
delete profile.bossCards;
const legacy = value as CharacterProfile & {
actionBindings?: unknown;
experience?: unknown;
@@ -180,7 +184,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
: undefined;
const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint);
return {
...value,
...profile,
level,
experience,
talentRanks,
@@ -191,7 +195,6 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
actionLoadouts,
inventory,
equipment,
bossCards: normalizeBossCardCollection(value.bossCards),
manastormProgress,
dungeonRunCheckpoint,
settings,
@@ -205,10 +208,11 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
version?: number;
accounts?: unknown;
rosters?: unknown;
bossCardsByOwner?: unknown;
presentationSettingsByOwner?: unknown;
uiPreferencesByOwner?: unknown;
}) | null;
if (!parsed || ![1, 2, 3, 4, 5, 6].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
if (!parsed || ![1, 2, 3, 4, 5, 6, 7].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
return emptyDatabase();
}
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
@@ -220,12 +224,19 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
&& typeof account.verifier.salt === "string"
&& typeof account.verifier.digest === "string",
));
const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record<string, unknown>).map(([ownerId, roster]) => [
ownerId,
Array.isArray(roster)
? roster.filter(isCharacterProfile).map(normalizeCharacterProfile).slice(0, MAX_CHARACTERS)
: [],
]));
const rawCards = parsed.bossCardsByOwner && typeof parsed.bossCardsByOwner === "object" && !Array.isArray(parsed.bossCardsByOwner)
? parsed.bossCardsByOwner : {};
const bossCardsByOwner = Object.fromEntries(Object.entries(rawCards).map(([owner, cards]) => [owner, normalizeBossCardCollection(cards)]));
let cardsMigrated = false;
const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record<string, unknown>).map(([ownerId, roster]) => {
const characters = Array.isArray(roster) ? roster.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : [];
if (characters.some((character) => character.bossCards !== undefined)) {
const legacyCards = mergeBossCardCollections(characters.map((character) => character.bossCards), "sum");
bossCardsByOwner[ownerId] = mergeBossCardCollections([bossCardsByOwner[ownerId], legacyCards]);
cardsMigrated = true;
}
return [ownerId, characters.map(normalizeCharacterProfile)];
}));
const rawPresentationSettings = parsed.presentationSettingsByOwner
&& typeof parsed.presentationSettingsByOwner === "object"
&& !Array.isArray(parsed.presentationSettingsByOwner)
@@ -259,8 +270,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
);
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
const database: ProfileDatabase = { version: 6, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner };
if (parsed.version !== 6 || presentationSettingsMigrated) writeDatabase(database, storage);
const database: ProfileDatabase = { version: 7, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated) writeDatabase(database, storage);
return database;
} catch {
return emptyDatabase();
@@ -270,7 +281,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void {
storage?.setItem(DATABASE_KEY, JSON.stringify(database));
if (!storage || !rosterChangeListener) return;
for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.bossCardsByOwner), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
rosterChangeListener(ownerId, database.rosters[ownerId] ?? []);
}
}
@@ -452,15 +463,19 @@ export function replaceCharactersForOwner(
ownerId: string,
characters: unknown,
storage: StorageLike | null = browserLocalStorage(),
accountBossCards?: unknown,
): CharacterProfile[] {
if (!storage) return [];
const database = readDatabase(storage);
database.rosters[ownerId] = Array.isArray(characters)
? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS).map((character) => normalizeCharacterProfile({
const roster = Array.isArray(characters) ? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : [];
const importedCards = accountBossCards === undefined
? mergeBossCardCollections(roster.map((character) => character.bossCards), "sum")
: normalizeBossCardCollection(accountBossCards);
database.bossCardsByOwner[ownerId] = mergeBossCardCollections([database.bossCardsByOwner[ownerId], importedCards]);
database.rosters[ownerId] = roster.map((character) => normalizeCharacterProfile({
...character,
ownerId,
}))
: [];
}));
writeDatabase(database, storage);
return listCharacters(ownerId, storage);
}
@@ -730,22 +745,24 @@ export function updateCharacterEquipment(
return updated;
}
export function updateCharacterBossCards(
export function loadAccountBossCards(
ownerId: string,
storage: StorageLike | null = browserLocalStorage(),
): BossCardCollection {
return normalizeBossCardCollection(readDatabase(storage).bossCardsByOwner[ownerId]);
}
export function saveAccountBossCards(
ownerId: string,
characterId: string,
bossCards: BossCardCollection,
storage: StorageLike | null = browserLocalStorage(),
): CharacterProfile | null {
if (!storage) return null;
): BossCardCollection {
const normalized = normalizeBossCardCollection(bossCards);
if (!storage) return normalized;
const database = readDatabase(storage);
let updated: CharacterProfile | null = null;
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
if (character.id !== characterId) return character;
updated = normalizeCharacterProfile({ ...character, bossCards });
return updated;
});
if (updated) writeDatabase(database, storage);
return updated;
database.bossCardsByOwner[ownerId] = normalized;
writeDatabase(database, storage);
return normalized;
}
export function updateCharacterSettings(
+104
View File
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PlayerSession } from "./types";
function storage() {
const values = new Map<string, string>();
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value); },
removeItem: (key: string) => { values.delete(key); },
};
}
const draft = {
ownerId: "account-one", name: "Collector", raceId: "human" as const,
classId: "priest" as const, specializationId: "priest:holy", gender: "female" as const,
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
};
const session: PlayerSession = { kind: "account", ownerId: draft.ownerId, displayName: "Collector", accessToken: "test-token" };
const cards = { counts: { "wow:lady anacondra": { normal: 7, rare: 1 } }, lastClaims: { spawn: "200:1:spawn" } };
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
describe("account boss card integration", () => {
it("keeps earned cards when switching characters and isolates account sign-in", async () => {
vi.stubGlobal("window", { localStorage: storage(), sessionStorage: storage() });
const repository = await import("./accountRepository");
const { useShellStore } = await import("./shellStore");
const { useCombatStore } = await import("../game/combatStore");
const first = repository.createCharacter(draft).character!;
const second = repository.createCharacter({ ...draft, name: "Other" }).character!;
const offlineSession: PlayerSession = { ...session, accessToken: undefined };
const shell = useShellStore.getState();
shell.beginSession(offlineSession);
useShellStore.setState({ phase: "game", activeCharacter: first });
shell.saveAccountBossCards(cards);
shell.returnToCharacters();
shell.selectCharacter(second.id);
expect(useShellStore.getState().bossCards).toEqual(cards);
useCombatStore.getState().initializeCharacter({ ...second, bossCards: useShellStore.getState().bossCards });
useCombatStore.getState().collectSharedBossCards({ bossKey: "wow:lady anacondra", spawnId: "spawn", claimId: "300:1:spawn", rarities: ["normal"] }, "Lady Anacondra", 300);
shell.saveAccountBossCards(useCombatStore.getState().bossCards);
shell.selectCharacter(first.id);
expect(useShellStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(8);
expect(repository.listCharacters(session.ownerId).every((character) => character.bossCards === undefined)).toBe(true);
shell.signOut();
expect(useShellStore.getState().bossCards.counts).toEqual({});
shell.beginSession({ ...offlineSession, ownerId: "account-two" });
expect(useShellStore.getState().bossCards.counts).toEqual({});
shell.beginSession(offlineSession);
expect(useShellStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(8);
});
it("uploads the account collection without characters and restores it on another device", async () => {
const firstDevice = storage();
const secondDevice = storage();
vi.stubGlobal("window", { localStorage: firstDevice });
let cloudData: unknown = { version: 1, characters: [] };
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const repository = await import("./accountRepository");
const client = await import("./onlineAccountClient");
client.activateOnlineSession(session);
repository.saveAccountBossCards(session.ownerId, cards);
await vi.advanceTimersByTimeAsync(501);
expect(cloudData).toMatchObject({ version: 2, characters: [], bossCards: cards });
expect(client.useCloudSaveStatus.getState().state).toBe("synced");
vi.stubGlobal("window", { localStorage: secondDevice });
await client.hydrateOnlineSession(session);
expect(repository.loadAccountBossCards(session.ownerId)).toEqual(cards);
expect(repository.loadAccountBossCards("different-account").counts).toEqual({});
const { collectBossCardDrop } = await import("../game/bossCards");
const restored = repository.loadAccountBossCards(session.ownerId);
expect(collectBossCardDrop(restored, { bossKey: "wow:lady anacondra", spawnId: "spawn", claimId: "200:1:spawn", rarities: ["normal", "rare"] })).toBe(restored);
await client.logoutOnlineSession(session);
});
it("publishes legacy cloud migration once and preserves it through repeated downloads", async () => {
vi.stubGlobal("window", { localStorage: storage() });
const repository = await import("./accountRepository");
const first = repository.createCharacter(draft).character!;
const second = repository.createCharacter({ ...draft, name: "Other" }).character!;
let cloudData: unknown = { version: 1, characters: [
{ ...first, bossCards: cards },
{ ...second, bossCards: { counts: { "wow:lady anacondra": { normal: 3 } }, lastClaims: {} } },
] };
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200 });
}));
const client = await import("./onlineAccountClient");
await client.hydrateOnlineSession(session);
expect(repository.loadAccountBossCards(session.ownerId).counts["wow:lady anacondra"]?.normal).toBe(10);
await vi.advanceTimersByTimeAsync(501);
expect(cloudData).toMatchObject({ version: 2, bossCards: { counts: { "wow:lady anacondra": { normal: 10, rare: 1 } } } });
expect((cloudData as { characters: { bossCards?: unknown }[] }).characters.every((character) => character.bossCards === undefined)).toBe(true);
await client.hydrateOnlineSession(session);
expect(repository.loadAccountBossCards(session.ownerId).counts["wow:lady anacondra"]?.normal).toBe(10);
await client.logoutOnlineSession(session);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { useBossTestingReviews } from "./bossTestingReviews";
import { submitDungeonReport } from "./dungeonReports";
afterEach(() => { vi.unstubAllGlobals(); useBossTestingReviews.setState({ reviews: {} }); });
describe("boss feedback", () => {
const boss = { key: "wailing-caverns/lady-anacondra", sourceDungeonId: "wailing-caverns", name: "Lady Anacondra" };
it("retains offline readiness and syncs it before refreshing server labels", async () => {
const storage = { setItem: vi.fn() };
vi.stubGlobal("window", { localStorage: storage });
const fetch = vi.fn().mockRejectedValueOnce(new Error("offline"));
vi.stubGlobal("fetch", fetch);
expect(await useBossTestingReviews.getState().save(boss, "not-ready", null)).toBe(false);
expect(useBossTestingReviews.getState().reviews[boss.key]).toMatchObject({ status: "not-ready", pending: true });
expect(storage.setItem).toHaveBeenCalled();
const review = { bossKey: boss.key, dungeonId: boss.sourceDungeonId, bossName: boss.name, status: "not-ready", reviewedAt: 1, reviewedBy: "Guest player" };
fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ review }) }).mockResolvedValueOnce({ ok: true, json: async () => ({ reviews: [review] }) });
await useBossTestingReviews.getState().refresh(null);
expect(useBossTestingReviews.getState().reviews[boss.key]).toEqual(review);
expect(fetch.mock.calls[1][1].method).toBe("POST");
expect(fetch.mock.calls[2][0]).toBe("/api/boss-testing/reviews");
});
it("submits boss identity as structured metadata without consuming report text", async () => {
const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ report: {} }) });
vi.stubGlobal("fetch", fetch);
await submitDungeonReport(null, boss.sourceDungeonId, "x".repeat(500), { bossKey: boss.key, bossName: boss.name });
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ dungeonId: boss.sourceDungeonId, comment: "x".repeat(500), bossKey: boss.key, bossName: boss.name });
});
});
+76
View File
@@ -0,0 +1,76 @@
import { create } from "zustand";
import type { PlayerSession } from "./types";
import { requestOnlineJson } from "./onlineAccountClient";
export const BOSS_TESTING_STATUSES = ["ready", "needs-testing", "not-ready"] as const;
export type BossTestingStatus = (typeof BOSS_TESTING_STATUSES)[number];
export const BOSS_TESTING_STATUS_LABELS: Record<BossTestingStatus, string> = {
ready: "Ready", "needs-testing": "Needs testing", "not-ready": "Not ready",
};
export interface BossTestingReview {
readonly bossKey: string;
readonly dungeonId: string;
readonly bossName: string;
readonly status: BossTestingStatus;
readonly reviewedBy: string;
readonly reviewedAt: number;
readonly pending?: boolean;
}
const CACHE_KEY = "healer-man.boss-testing-reviews.v1";
function readReviews(): Record<string, BossTestingReview> {
try {
const reviews = JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? "{}");
if (!reviews || typeof reviews !== "object" || Array.isArray(reviews)) return {};
return Object.fromEntries(Object.entries(reviews).filter(([key, value]) => {
const review = value as BossTestingReview;
return review?.bossKey === key && typeof review.dungeonId === "string" && typeof review.bossName === "string"
&& BOSS_TESTING_STATUSES.includes(review.status) && Number.isFinite(review.reviewedAt);
})) as Record<string, BossTestingReview>;
} catch { return {}; }
}
function persist(reviews: Record<string, BossTestingReview>) {
try { window.localStorage.setItem(CACHE_KEY, JSON.stringify(reviews)); } catch { /* Storage may be disabled. */ }
}
function sendReview(review: BossTestingReview, session: PlayerSession | null) {
return requestOnlineJson<{ review: BossTestingReview }>("/api/boss-testing/reviews", {
method: "POST", body: JSON.stringify({ bossKey: review.bossKey, dungeonId: review.dungeonId, bossName: review.bossName, status: review.status }),
}, session?.kind === "account" ? session.accessToken : undefined);
}
interface ReviewState {
reviews: Record<string, BossTestingReview>;
refresh: (session: PlayerSession | null) => Promise<void>;
save: (boss: { readonly key: string; readonly sourceDungeonId: string; readonly name: string }, status: BossTestingStatus,
session: PlayerSession | null) => Promise<boolean>;
}
export const useBossTestingReviews = create<ReviewState>((set, get) => ({
reviews: readReviews(),
refresh: async (session) => {
try {
for (const pending of Object.values(get().reviews).filter((review) => review.pending)) {
const { review } = await sendReview(pending, session);
if (get().reviews[pending.bossKey] !== pending) continue;
const reviews = { ...get().reviews, [review.bossKey]: review };
persist(reviews); set({ reviews });
}
const payload = await requestOnlineJson<{ reviews: BossTestingReview[] }>("/api/boss-testing/reviews");
const reviews = { ...get().reviews, ...Object.fromEntries(payload.reviews
.filter((review) => !get().reviews[review.bossKey]?.pending
&& review.reviewedAt >= (get().reviews[review.bossKey]?.reviewedAt ?? 0)).map((review) => [review.bossKey, review])) };
persist(reviews); set({ reviews });
} catch { /* Keep cached labels and sync pending reviews when the connection returns. */ }
},
save: async (boss, status, session) => {
const pending: BossTestingReview = { bossKey: boss.key, dungeonId: boss.sourceDungeonId, bossName: boss.name, status,
reviewedAt: Date.now(), reviewedBy: session?.displayName ?? "Guest player", pending: true };
let reviews = { ...get().reviews, [boss.key]: pending };
persist(reviews); set({ reviews });
try {
const { review } = await sendReview(pending, session);
if (get().reviews[boss.key] === pending) {
reviews = { ...get().reviews, [boss.key]: review };
persist(reviews); set({ reviews });
}
return true;
} catch { return false; }
},
}));
+27
View File
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { prepareBossTesting } from "./bossTestingRuntime";
import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
import * as content from "../content/contentManager";
afterEach(() => vi.restoreAllMocks());
describe("boss asset preparation", () => {
const boss = BOSS_TESTING_CATALOG[0];
it("automatically downloads and verifies only the selected boss's dungeon pack", async () => {
const usable = vi.spyOn(content, "dungeonContentIsUsable").mockReturnValueOnce(false).mockReturnValueOnce(false).mockReturnValue(true);
vi.spyOn(content, "initializeContentSystem").mockResolvedValue();
const install = vi.spyOn(content, "installContentPack").mockResolvedValue();
await prepareBossTesting(boss);
expect(install).toHaveBeenCalledExactlyOnceWith(`dungeon:${boss.dungeonId}`);
expect(usable).toHaveBeenCalledWith(boss.dungeonId);
});
it("reuses installed content and stops entry after failed verification", async () => {
const usable = vi.spyOn(content, "dungeonContentIsUsable").mockReturnValue(true);
vi.spyOn(content, "initializeContentSystem").mockResolvedValue();
const install = vi.spyOn(content, "installContentPack").mockResolvedValue();
await prepareBossTesting(boss);
expect(install).not.toHaveBeenCalled();
usable.mockReturnValue(false);
await expect(prepareBossTesting(boss)).rejects.toThrow(/verified/);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { useShellStore } from "./shellStore";
import { specializationById } from "../game/specializationCatalog";
import { defaultPartyRoleForClass, type PartyRole } from "../game/partyRoles";
import { createBossTestingRoom, type TestableBoss } from "../game/bossTesting";
import { clearBossTestingDungeons, registerBossTestingDungeon, requireDungeonDefinition } from "../game/dungeonRegistry";
import { activateDungeonSession } from "../game/dungeonSession";
import { dungeonContentIsUsable, initializeContentSystem, installContentPack } from "../content/contentManager";
let preparationQueue: Promise<void> = Promise.resolve();
export async function prepareBossTesting(boss: TestableBoss): Promise<void> {
if (dungeonContentIsUsable(boss.dungeonId)) return;
const preparation = preparationQueue.then(async () => {
await initializeContentSystem();
if (!dungeonContentIsUsable(boss.dungeonId)) await installContentPack(`dungeon:${boss.dungeonId}`);
if (!dungeonContentIsUsable(boss.dungeonId)) throw new Error("The boss assets could not be verified. Please retry the download.");
});
preparationQueue = preparation.catch(() => undefined);
await preparation;
}
export function enterBossTesting(boss: TestableBoss, requestedRole?: PartyRole): boolean {
const shell = useShellStore.getState();
const character = shell.characters.find((entry) => entry.id === shell.selectedCharacterId);
if (!shell.session || !character) throw new Error("Select a character first.");
const specialization = specializationById(character.specializationId);
if (character.categoryId !== "rom" && !specialization) throw new Error("Choose a specialization in the Class Hall first.");
const { definition, session } = createBossTestingRoom(requireDungeonDefinition(boss.dungeonId), boss);
registerBossTestingDungeon(definition, boss.dungeonId);
if (!activateDungeonSession(definition.id, undefined, { gmTestMode: true, bossTest: session })) {
clearBossTestingDungeons();
throw new Error("The boss testing room could not be started. Check the downloaded assets and retry.");
}
useShellStore.setState({ phase: "game", activeCharacter: character, activeDungeonId: definition.id,
activeDungeonRole: specialization?.role ?? requestedRole ?? defaultPartyRoleForClass(character.classId), notice: "" });
return true;
}
+5 -2
View File
@@ -16,6 +16,8 @@ export interface DungeonReport {
readonly updatedAt: number;
readonly updatedBy: string | null;
readonly revision: number;
readonly bossKey?: string;
readonly bossName?: string;
}
export interface DungeonReportSnapshot {
@@ -40,10 +42,11 @@ export function fetchDungeonReports(): Promise<DungeonReportSnapshot> {
return requestOnlineJson<DungeonReportSnapshot>("/api/dungeon-reports");
}
export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string): Promise<DungeonReport> {
export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string,
boss?: { readonly bossKey: string; readonly bossName: string }): Promise<DungeonReport> {
const response = await requestOnlineJson<{ report: DungeonReport }>("/api/dungeon-reports", {
method: "POST",
body: JSON.stringify({ dungeonId, comment }),
body: JSON.stringify({ dungeonId, comment, ...boss }),
}, session?.kind === "account" ? session.accessToken : undefined);
return response.report;
}
+113
View File
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createDefaultAppearance } from "./characterCatalog";
import {
createCharacter,
listCharacters,
updateCharacterProgression,
type StorageLike,
} from "./accountRepository";
import { beginMainMenuCharacterEditing } from "./mainMenuCharacterEditing";
import { selectedCharacter, useShellStore } from "./shellStore";
import { useCombatStore } from "../game/combatStore";
import { talentNodesForTree } from "../game/talentCatalog";
class MemoryStorage implements StorageLike {
values = new Map<string, string>();
getItem(key: string) { return this.values.get(key) ?? null; }
setItem(key: string, value: string) { this.values.set(key, value); }
removeItem(key: string) { this.values.delete(key); }
}
describe("main-menu character editing", () => {
let storage: MemoryStorage;
let stop: (() => void) | null;
const holyTalent = talentNodesForTree("priest-holy")[0];
beforeEach(() => {
storage = new MemoryStorage();
stop = null;
const created = createCharacter({
ownerId: "offline-roster", name: "Menuhealer", classId: "priest",
raceId: "human", gender: "female", appearance: createDefaultAppearance(),
specializationId: "priest:holy",
}, storage).character!;
const character = updateCharacterProgression(created.ownerId, created.id, {
level: 17, experience: 123, talentRanks: {},
}, storage)!;
useShellStore.setState({
phase: "main-menu", activeCharacter: null, selectedCharacterId: character.id,
session: { kind: "offline", ownerId: character.ownerId, displayName: "Offline" },
characters: [character], notice: "",
});
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
});
afterEach(() => { stop?.(); });
it("loads the selected character without rewriting their saved profile or starting a dungeon", () => {
const before = [...storage.values];
stop = beginMainMenuCharacterEditing(storage);
expect(stop).not.toBeNull();
expect(useCombatStore.getState()).toMatchObject({ classId: "priest", level: 17, xp: 123 });
expect([...storage.values]).toEqual(before);
expect(useShellStore.getState()).toMatchObject({ phase: "main-menu", activeCharacter: null });
});
it("persists talent allocation and reset, and reloads the saved build when reopened", () => {
stop = beginMainMenuCharacterEditing(storage);
expect(useCombatStore.getState().allocateTalent(holyTalent.id)).toBe(true);
expect(listCharacters("offline-roster", storage)[0].talentRanks[holyTalent.id]).toBe(1);
expect(selectedCharacter(useShellStore.getState())?.talentRanks[holyTalent.id]).toBe(1);
stop!();
stop = beginMainMenuCharacterEditing(storage);
expect(useCombatStore.getState().talentRanks[holyTalent.id]).toBe(1);
useCombatStore.getState().resetTalents();
expect(listCharacters("offline-roster", storage)[0].talentRanks).toEqual({});
expect(listCharacters("offline-roster", storage)[0]).toMatchObject({ level: 17, experience: 123 });
});
it("persists controller assignments and reloads them without touching progression or equipment", () => {
const profile = selectedCharacter(useShellStore.getState())!;
stop = beginMainMenuCharacterEditing(storage);
expect(useCombatStore.getState().setActionBinding("primary", "face-bottom", "priest-flash-heal")).toBe(true);
const saved = listCharacters("offline-roster", storage)[0];
expect(saved.actionBindings["primary:face-bottom"]).toBe("priest-flash-heal");
expect(saved).toMatchObject({ level: profile.level, experience: profile.experience,
talentRanks: profile.talentRanks, inventory: profile.inventory, equipment: profile.equipment });
stop!();
stop = beginMainMenuCharacterEditing(storage);
expect(useCombatStore.getState().actionBindings["primary:face-bottom"]).toBe("priest-flash-heal");
});
it.each(["phase", "owner", "selection", "closed"])("stops saving after the editing context changes: %s", (change) => {
stop = beginMainMenuCharacterEditing(storage);
const before = [...storage.values];
if (change === "phase") useShellStore.setState({ phase: "game" });
if (change === "owner") useShellStore.setState({ session: { kind: "offline", ownerId: "another-owner", displayName: "Other" } });
if (change === "selection") useShellStore.setState({ selectedCharacterId: null });
if (change === "closed") stop!();
useCombatStore.getState().allocateTalent(holyTalent.id);
useCombatStore.getState().setActionBinding("primary", "face-bottom", "priest-flash-heal");
expect([...storage.values]).toEqual(before);
});
it("never saves combat XP as a side effect of a talent edit", () => {
stop = beginMainMenuCharacterEditing(storage);
useCombatStore.setState({ xp: 999 });
useCombatStore.getState().allocateTalent(holyTalent.id);
expect(listCharacters("offline-roster", storage)[0].experience).toBe(123);
});
it("rejects editing without a selected, owned main-menu character", () => {
useShellStore.setState({ selectedCharacterId: null });
expect(beginMainMenuCharacterEditing(storage)).toBeNull();
expect(useCombatStore.getState().classId).toBe("mage");
});
it("reports storage failures instead of updating the roster as though the save succeeded", () => {
stop = beginMainMenuCharacterEditing(storage);
storage.setItem = () => { throw new Error("Storage full"); };
useCombatStore.getState().allocateTalent(holyTalent.id);
expect(useShellStore.getState().notice).toMatch(/could not save/i);
expect(selectedCharacter(useShellStore.getState())?.talentRanks).toEqual({});
});
});
+54
View File
@@ -0,0 +1,54 @@
import {
updateCharacterActionBindings,
updateCharacterProgression,
type StorageLike,
} from "./accountRepository";
import { selectedCharacter, useShellStore } from "./shellStore";
import { useCombatStore } from "../game/combatStore";
import { activateCharacterUi } from "../game/uiLayoutStore";
// 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);
activateCharacterUi(character);
return useCombatStore.subscribe((combat, previous) => {
const current = useShellStore.getState();
const profile = selectedCharacter(current);
if (current.phase !== "main-menu" || current.session?.ownerId !== ownerId
|| profile?.id !== characterId || profile.ownerId !== ownerId) return;
if (combat.talentRanks === previous.talentRanks && combat.actionBindings === previous.actionBindings) return;
try {
let updated = profile;
if (combat.talentRanks !== previous.talentRanks) {
const saved = updateCharacterProgression(ownerId, characterId, {
// Menu edits cannot award experience or alter the character's level.
level: profile.level,
experience: profile.experience,
talentRanks: combat.talentRanks,
}, storage);
if (!saved) throw new Error("Character storage is unavailable.");
updated = saved;
}
if (combat.actionBindings !== previous.actionBindings) {
const saved = updateCharacterActionBindings(ownerId, characterId, combat.actionBindings, storage);
if (!saved) throw new Error("Character storage is unavailable.");
updated = saved;
}
useShellStore.setState((state) => ({
characters: state.characters.map((entry) => entry.id === characterId ? updated : entry),
notice: "",
}));
} catch {
useShellStore.getState().setNotice("Could not save character changes. Check that profile storage is available and try again.");
}
});
}
+11 -3
View File
@@ -5,6 +5,7 @@ import {
replaceCharactersForOwner,
setRosterChangeListener,
loadUiPreferences,
loadAccountBossCards,
saveUiPreferences,
type AccountResult,
} from "./accountRepository";
@@ -23,7 +24,7 @@ interface AuthResponse {
interface CloudSaveResponse {
revision: number;
updatedAt: number | null;
data: { version?: number; characters?: unknown; uiPreferences?: unknown };
data: { version?: number; characters?: unknown; uiPreferences?: unknown; bossCards?: unknown };
}
interface MeResponse {
@@ -106,11 +107,12 @@ function flushCloudSave(): void {
pendingCharacters = null;
if (!session?.accessToken || !characters) return;
const uiPreferences = loadUiPreferences(session.ownerId);
const bossCards = loadAccountBossCards(session.ownerId);
const sequence = saveSequence;
saveQueue = saveQueue.then(async () => {
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
method: "PUT",
body: JSON.stringify({ data: { version: 1, characters, uiPreferences } }),
body: JSON.stringify({ data: { version: 2, characters, uiPreferences, bossCards } }),
}, session.accessToken);
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." });
}).catch((error) => {
@@ -149,12 +151,18 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise<Char
activateOnlineSession(session);
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
suppressUpload = true;
let characters: CharacterProfile[];
try {
saveUiPreferences(session.ownerId, cloud.data?.uiPreferences);
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
characters = replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? [], undefined, cloud.data?.bossCards);
} finally {
suppressUpload = false;
}
// Publish migrated logs even if the player has not earned another card yet.
if (JSON.stringify(cloud.data?.bossCards) !== JSON.stringify(loadAccountBossCards(session.ownerId))) {
queueCloudSave(session.ownerId, characters);
}
return characters;
}
export async function refreshOnlineSession(session: PlayerSession): Promise<PlayerSession> {
+1 -1
View File
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
},
});
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
});
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
+19 -15
View File
@@ -5,13 +5,14 @@ import {
createCharacter as persistCharacter,
deleteCharacter as removeCharacter,
listCharacters,
loadAccountBossCards,
loadCombatPresentationSettings,
restoreSession as restoreStoredSession,
saveSession,
updateCharacterActionBindings,
updateCharacterEquipment,
updateCharacterInventory,
updateCharacterBossCards,
saveAccountBossCards as persistAccountBossCards,
updateCharacterManastormProgress,
updateCharacterDungeonRunCheckpoint,
updateCharacterProgression,
@@ -39,7 +40,7 @@ import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from
import type { DungeonId } from "../game/dungeonRegistry";
import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults";
import type { InventoryItem } from "../game/lootTypes";
import type { BossCardCollection } from "../game/bossCards";
import { emptyBossCardCollection, type BossCardCollection } from "../game/bossCards";
import type { EquipmentAssignments } from "../game/equipment";
import type { GameplaySettings } from "../game/combatStore";
import {
@@ -70,6 +71,7 @@ export interface ShellState {
phase: ShellPhase;
session: PlayerSession | null;
characters: CharacterProfile[];
bossCards: BossCardCollection;
selectedCharacterId: string | null;
activeCharacter: CharacterProfile | null;
draft: CharacterDraft;
@@ -111,7 +113,7 @@ export interface ShellState {
saveActiveProgression: (level: number, experience: number, talentRanks: Record<string, number>) => void;
saveActiveActionBindings: (actionBindings: Readonly<Record<string, string | null>>) => void;
saveActiveInventory: (inventory: readonly InventoryItem[]) => void;
saveActiveBossCards: (bossCards: BossCardCollection) => void;
saveAccountBossCards: (bossCards: BossCardCollection) => void;
saveActiveEquipment: (equipment: EquipmentAssignments) => void;
saveActiveSettings: (settings: GameplaySettings) => void;
updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => void;
@@ -138,12 +140,13 @@ function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
};
}
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "selectedCharacterId" | "combatPresentationSettings"> {
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "bossCards" | "selectedCharacterId" | "combatPresentationSettings"> {
const session = restoreStoredSession();
if (!session) return {
phase: "login",
session: null,
characters: [],
bossCards: emptyBossCardCollection(),
selectedCharacterId: null,
combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS,
};
@@ -152,6 +155,7 @@ function restoredState(): Pick<ShellState, "phase" | "session" | "characters" |
phase: "characters",
session,
characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters[0]?.id ?? null,
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
};
@@ -174,6 +178,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
phase: "characters",
session,
characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters[0]?.id ?? null,
activeCharacter: null,
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
@@ -189,6 +194,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
phase: "login",
session: null,
characters: [],
bossCards: emptyBossCardCollection(),
selectedCharacterId: null,
activeCharacter: null,
draft: defaultDraft(),
@@ -276,6 +282,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({
phase: "characters",
characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: result.character.id,
draft: defaultDraft(),
notice: `${result.character.name} is ready for the expedition.`,
@@ -439,7 +446,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
return true;
},
returnToMainMenu: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame();
const { session, characters, activeCharacter, selectedCharacterId } = get();
if (!session) {
@@ -477,7 +484,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
},
enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID),
returnToCharacters: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
const session = get().session;
if (!session) {
set({ phase: "login", activeCharacter: null });
@@ -488,6 +495,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({
phase: "characters",
characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters.some((character) => character.id === currentId)
? currentId
: characters[0]?.id ?? null,
@@ -538,15 +546,10 @@ export const useShellStore = create<ShellState>((set, get) => ({
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
},
saveActiveBossCards: (bossCards) => {
const { session, activeCharacter } = get();
if (!session || !activeCharacter) return;
const updated = updateCharacterBossCards(session.ownerId, activeCharacter.id, bossCards);
if (!updated) return;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
saveAccountBossCards: (bossCards) => {
const { session } = get();
if (!session) return;
set({ bossCards: persistAccountBossCards(session.ownerId, bossCards) });
},
saveActiveEquipment: (equipment) => {
const { session, activeCharacter } = get();
@@ -651,6 +654,7 @@ if (restored.session?.accessToken) {
useShellStore.setState({
session: refreshedSession,
characters,
bossCards: loadAccountBossCards(refreshedSession.ownerId),
selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId)
? useShellStore.getState().selectedCharacterId
: characters[0]?.id ?? null,
+1
View File
@@ -42,6 +42,7 @@ export interface CharacterProfile {
actionLoadouts?: Record<string, Record<string, string | null>>;
inventory: InventoryItem[];
equipment: EquipmentAssignments;
/** Legacy character log, read only during migration to the account collection. */
bossCards?: BossCardCollection;
/** Present on normalized and newly created profiles; optional for legacy callers. */
manastormProgress?: ManastormProgress;
+6 -5
View File
@@ -5,6 +5,7 @@ import { controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { resolveDisplayLayout } from "../platform/displayLayout";
import { THOR_DISPLAYS } from "../platform/thorDisplayProfile";
import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
interface DualDisplayFrameProps {
@@ -136,12 +137,12 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
}
return (
<div className="thor-preview-frame">
<div className="thor-preview-label"><b>Main display</b><span>960 x 540 CSS / 1920 x 1080</span></div>
<div className="thor-preview-top">{top}</div>
<div className="thor-preview-frame" style={{ width: THOR_DISPLAYS.top.cssWidth + 80 }}>
<div className="thor-preview-label"><b>Top · 6 inches</b><span>1920 × 1080 · {THOR_DISPLAYS.top.widthMm.toFixed(1)} × {THOR_DISPLAYS.top.heightMm.toFixed(1)} mm</span></div>
<div className="thor-preview-top" style={{ width: THOR_DISPLAYS.top.cssWidth, height: THOR_DISPLAYS.top.cssHeight }}>{top}</div>
<div className="thor-preview-hinge"><i /><b>AYN THOR</b><i /></div>
<div className="thor-preview-label"><b>Companion display</b><span>620 x 540 CSS / 1240 x 1080</span></div>
<div className="thor-preview-bottom">{bottom}</div>
<div className="thor-preview-label"><b>Bottom · 3.92 inches</b><span>1240 × 1080 · {THOR_DISPLAYS.bottom.widthMm.toFixed(1)} × {THOR_DISPLAYS.bottom.heightMm.toFixed(1)} mm</span></div>
<div className="thor-preview-bottom" style={{ width: THOR_DISPLAYS.bottom.cssWidth, height: THOR_DISPLAYS.bottom.cssHeight }}>{bottom}</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from "react";
import { useCombatStore } from "./combatStore";
import { useGameStore } from "./store";
export function bossTestIsDefeated(): boolean {
const test = useGameStore.getState().bossTest;
const mobs = useCombatStore.getState().mobs;
return Boolean(test?.runtimeIds.length && test.runtimeIds.every((id) => mobs[id]?.dead));
}
export function BossTestingBridge() {
useEffect(() => {
let defeated = false;
const observe = () => {
const next = bossTestIsDefeated();
if (next && !defeated) useGameStore.getState().openOverlay("boss-test-review");
defeated = next;
};
const unsubscribeCombat = useCombatStore.subscribe(observe);
const unsubscribeGame = useGameStore.subscribe((state, previous) => {
if (state.encounterResetRevision !== previous.encounterResetRevision || state.bossTest !== previous.bossTest) defeated = false;
});
observe();
return () => { unsubscribeCombat(); unsubscribeGame(); };
}, []);
return null;
}
+7 -3
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import { useShellStore } from "../app/shellStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { useCombatStore } from "./combatStore";
import { activateCharacterUi } from "./uiLayoutStore";
import { advanceDungeonPartyCombat } from "./partyRuntime";
import { useGameStore } from "./store";
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
@@ -40,7 +41,8 @@ export function CombatBridge() {
useEffect(() => {
if (!character) return;
useCombatStore.getState().initializeCharacter({ ...character, role: activeRole });
useCombatStore.getState().initializeCharacter({ ...character, bossCards: useShellStore.getState().bossCards, role: activeRole });
activateCharacterUi(character);
useCombatStore.getState().synchronizeActors();
if (gameMode === "manastorm") {
activateManastormActiveSpellLoadout(
@@ -66,12 +68,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 +105,7 @@ export function CombatBridge() {
shell.saveActiveInventory(state.inventory);
}
if (state.bossCards !== previousState.bossCards) {
shell.saveActiveBossCards(state.bossCards);
shell.saveAccountBossCards(state.bossCards);
}
if (state.equipment !== previousState.equipment) {
shell.saveActiveEquipment(state.equipment);
+3 -2
View File
@@ -246,6 +246,7 @@ function applyRelayEvent(
}
export function OnlineSessionBridge() {
const bossTest = useGameStore((state) => state.bossTest);
const session = useShellStore((state) => state.session);
const activeActivity = useOnlineGroupStore((state) => state.activeActivity);
const group = useOnlineGroupStore((state) => state.snapshot.group);
@@ -256,7 +257,7 @@ export function OnlineSessionBridge() {
const applyingRef = useRef(false);
useEffect(() => {
if (!session?.accessToken || !activeActivity || !group) {
if (bossTest || !session?.accessToken || !activeActivity || !group) {
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
reset();
return undefined;
@@ -362,7 +363,7 @@ export function OnlineSessionBridge() {
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
reset();
};
}, [accept, activeActivity?.id, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]);
}, [accept, activeActivity?.id, bossTest, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]);
return null;
}
+1 -1
View File
@@ -28,7 +28,7 @@ export async function runDungeonBossBenchmark(
const dungeon = requireDungeonDefinition("ragefire-chasm");
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)!;
const entity = dungeon.entities[spawn.entityId];
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, build.level, "normal", 1);
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, build.level, "normal", 1, build.source);
const specs = specializationsForClass(build.classId).filter((spec) => spec.role === build.role);
const specialization = specs.find((spec) => spec.displayName === build.orientation) ?? specs[0];
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); resetCombatSpatialProvider();
+29
View File
@@ -49,6 +49,35 @@ export function normalizeBossCardCollection(value: unknown): BossCardCollection
return { counts, lastClaims };
}
/** Sum independent legacy character logs; max reconciles copies of the same account log. */
export function mergeBossCardCollections(collections: readonly unknown[], mode: "sum" | "max" = "max"): BossCardCollection {
const counts: Record<string, BossCardCounts> = Object.create(null);
const lastClaims: Record<string, string> = Object.create(null);
for (const value of collections) {
const collection = normalizeBossCardCollection(value);
for (const [bossKey, entry] of Object.entries(collection.counts)) {
const merged = counts[bossKey] ??= {};
for (const rarity of BOSS_CARD_RARITIES) {
const count = entry[rarity];
if (count === undefined) continue;
merged[rarity] = mode === "sum"
? Math.min(Number.MAX_SAFE_INTEGER, (merged[rarity] ?? 0) + count)
: Math.max(merged[rarity] ?? 0, count);
}
}
for (const [spawnId, claim] of Object.entries(collection.lastClaims)) {
const previous = lastClaims[spawnId];
if (!previous) { lastClaims[spawnId] = claim; continue; }
if (/^\d+:/.test(previous) && /^\d+:/.test(claim)) {
const [previousTime, previousRevision] = previous.split(":").map(Number);
const [time, revision] = claim.split(":").map(Number);
if (time > previousTime || (time === previousTime && revision > previousRevision)) lastClaims[spawnId] = claim;
} else if (claim > previous) lastClaims[spawnId] = claim;
}
}
return { counts, lastClaims };
}
/** Independent bonus rolls; Normal is always awarded, including with all three bonuses. */
export function rollBossCards(random: () => number = Math.random): readonly BossCardRarity[] {
return BOSS_CARD_RARITIES.filter((rarity) => {
+119
View File
@@ -0,0 +1,119 @@
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";
import { dungeonEnemyScaling } from "./dungeonBossScaling";
import { creatureRuntimeBaseStats } from "./azerothCoreReference";
import { bossDifficultyMultipliers } from "./dungeonDifficulty";
import { finalDungeonBoss } from "./dungeonRunStore";
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("uses Celebras's native health at the player's scaled level without campaign health floors", () => {
const celebras = BOSS_TESTING_CATALOG.find((entry) => entry.name === "Celebras the Cursed")!;
const maraudon = requireDungeonDefinition(celebras.dungeonId);
const room = createBossTestingRoom(maraudon, celebras);
const entity = room.definition.entities[celebras.entityId];
const scale = (level: number, difficulty = 1) => dungeonEnemyScaling(room.definition, entity,
celebras.spawn.id, level, room.definition.defaultDifficultyId, difficulty);
const nativeHealth = creatureRuntimeBaseStats(entity.combat!.serverEntry!, 2)!.health
* entity.combat!.healthMultiplier!;
expect(scale(1)).toMatchObject({ level: 2, maxHealth: nativeHealth });
expect(Math.round(scale(1).maxHealth)).toBe(575);
expect(scale(8).maxHealth).toBeGreaterThan(scale(1).maxHealth);
expect(scale(20).maxHealth).toBeGreaterThan(scale(8).maxHealth);
expect(scale(1, 10).maxHealth).toBe(nativeHealth * bossDifficultyMultipliers(10).health);
const campaign = dungeonEnemyScaling(maraudon, maraudon.entities[celebras.entityId], celebras.spawn.id,
1, maraudon.defaultDifficultyId, 1);
expect(campaign.maxHealth).toBe(900);
expect(scale(1).damageMultiplier).toBe(campaign.damageMultiplier);
expect(entity.combat?.attacks).toEqual(maraudon.entities[celebras.entityId].combat?.attacks);
});
it("omits the campaign finale bonus even when testing the source dungeon's final boss", () => {
const final = finalDungeonBoss(source)!;
const finalBoss = BOSS_TESTING_CATALOG.find((entry) => entry.dungeonId === source.id && entry.spawn.id === final.id)!;
const room = createBossTestingRoom(source, finalBoss);
const entity = room.definition.entities[finalBoss.entityId];
const testing = dungeonEnemyScaling(room.definition, entity, finalBoss.spawn.id, 1, source.defaultDifficultyId, 1);
const campaign = dungeonEnemyScaling(source, entity, finalBoss.spawn.id, 1, source.defaultDifficultyId, 1);
expect(testing.maxHealth).toBe(creatureRuntimeBaseStats(entity.combat!.serverEntry!, 2)!.health
* (entity.combat?.healthMultiplier ?? 1));
expect(campaign.maxHealth).toBe(Math.max(testing.maxHealth, 900 * 1.8));
});
it("recognizes actual boss deaths and awards no progression or collectible rewards", () => {
const room = createBossTestingRoom(source, boss);
registerBossTestingDungeon(room.definition, source.id);
useGameStore.getState().activateDungeon(room.definition.id, undefined, { bossTest: room.session, gmTestMode: true });
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 20 });
expect(bossTestIsDefeated()).toBe(false);
useCombatStore.getState().registerMob(boss.spawn.id, { name: boss.name, boss: true, maxHealth: 10, xpReward: 500, hasLoot: true, lootSourceId: boss.entityId });
const xp = useCombatStore.getState().xp;
useCombatStore.getState().damageMob(boss.spawn.id, 100000, Date.now());
expect(bossTestIsDefeated()).toBe(true);
const combat = useCombatStore.getState();
expect(combat.xp).toBe(xp);
expect(combat.mobs[boss.spawn.id]).toMatchObject({ hasLoot: false, pendingLoot: [] });
expect(combat.mobs[boss.spawn.id].bossCardDrop).toBeFalsy();
});
it("draws a boss only from the selected filters and handles empty queues", () => {
expect(randomTestingBoss([], () => 0.5)).toBeNull();
const candidates = BOSS_TESTING_CATALOG.slice(0, 2);
expect(randomTestingBoss(candidates, () => 0)).toBe(candidates[0]);
expect(randomTestingBoss(candidates, () => 1)).toBe(candidates[1]);
expect(randomTestingBoss(candidates, () => Number.NaN)).toBe(candidates[0]);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
import type { DungeonDefinition, Vector3Tuple } from "./dungeonTypes";
import type { StaticMobSpawnDefinition } from "./mobPopulation";
import type { BossTestSession } from "./store";
export interface TestableBoss {
readonly key: string;
readonly name: string;
readonly dungeonId: string;
readonly dungeonTitle: string;
readonly entityId: string;
readonly category: "wow" | "rom";
readonly spawn: StaticMobSpawnDefinition;
}
export function buildBossTestingCatalog(definitions: readonly DungeonDefinition[]): readonly TestableBoss[] {
return definitions.flatMap((dungeon) => Object.values(dungeon.entities).flatMap((entity): TestableBoss[] => {
if (entity.kind !== "boss") return [];
const spawn = dungeon.staticSpawns.find((candidate) => candidate.entityId === entity.id)
?? dungeon.encounterSpawnGroups?.flatMap((group) => group.spawns).find((candidate) => candidate.entityId === entity.id);
const objective = dungeon.bosses.find((boss) => boss.id === spawn?.id || boss.name === entity.name);
const pack = dungeon.roamingPacks.find((candidate) => candidate.members.some((member) => member.entityId === entity.id));
const member = pack?.members.find((candidate) => candidate.entityId === entity.id);
const position = spawn?.position ?? objective?.position ?? pack?.waypoints[0];
if (!position) return [];
return [{
key: `${dungeon.id}/${entity.id}`, name: entity.name, dungeonId: dungeon.id,
dungeonTitle: dungeon.title, entityId: entity.id,
category: dungeon.expansion === "runewaker" ? "rom" : "wow",
spawn: { ...spawn, id: spawn?.id ?? (pack && member ? `${pack.id}:${member.id}` : objective?.id ?? entity.id),
entityId: entity.id, position, spawnChance: 1 },
}];
})).sort((a, b) => a.name.localeCompare(b.name) || a.dungeonTitle.localeCompare(b.dungeonTitle));
}
export const BOSS_TESTING_CATALOG = buildBossTestingCatalog(DUNGEON_DEFINITIONS);
export function randomTestingBoss(bosses: readonly TestableBoss[], random: () => number = Math.random): TestableBoss | null {
if (!bosses.length) return null;
const draw = random();
const index = Math.floor((Number.isFinite(draw) ? Math.max(0, Math.min(0.999999999, draw)) : 0) * bosses.length);
return bosses[index];
}
function references(value: unknown, ids: ReadonlySet<string>): boolean {
if (typeof value === "string") return ids.has(value);
if (Array.isArray(value)) return value.some((entry) => references(entry, ids));
return Boolean(value && typeof value === "object" && Object.values(value).some((entry) => references(entry, ids)));
}
function remapEncounterReferences<T>(value: T, ids: ReadonlyMap<string, string>): T {
if (typeof value === "string") return (ids.get(value) ?? value) as T;
if (Array.isArray(value)) return value.map((entry) => remapEncounterReferences(entry, ids)) as T;
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value)
.map(([key, entry]) => [key, remapEncounterReferences(entry, ids)])) as T;
return value;
}
/** Keep original mechanic coordinates and spawn IDs while replacing the environment with a clear floor. */
export function createBossTestingRoom(source: DungeonDefinition, boss: TestableBoss): {
readonly definition: DungeonDefinition;
readonly session: BossTestSession;
} {
const entity = source.entities[boss.entityId];
if (source.id !== boss.dungeonId || entity?.kind !== "boss") throw new Error("That boss is unavailable.");
const id = `boss-testing:${source.id}:${boss.entityId}`;
const ids = new Set([boss.entityId, boss.spawn.id]);
const sourceEncounters = (source.encounters ?? []).filter((encounter) =>
references(encounter.rules.map((rule) => rule.trigger), ids)
|| references(entity.combat, new Set([encounter.id]))
);
const encounterIds = new Map(sourceEncounters.map((encounter) => [encounter.id, `${id}:${encounter.id}`]));
const encounters = sourceEncounters.map((encounter) => {
const isolated = remapEncounterReferences(encounter, encounterIds);
return { ...isolated, id: encounterIds.get(encounter.id)!, dungeonId: id,
rules: isolated.rules.map((rule) => ({ ...rule, actions: rule.actions.filter((action) =>
action.kind !== "grant-loot" && action.kind !== "set-checkpoint"
) })),
};
});
const groups = (source.encounterSpawnGroups ?? []).filter((group) => references(encounters, new Set([group.id])));
const objects = source.serverGameObjects.filter((object) => references(encounters, new Set([object.id, object.interaction?.signalId ?? ""])));
const entityIds = new Set([boss.entityId, ...groups.flatMap((group) => group.spawns.map((spawn) => spawn.entityId))]);
// Summoned creature definitions are needed even though they are absent from the initial room.
for (const entityId of entityIds) {
for (const attack of source.entities[entityId]?.combat?.attacks ?? []) {
for (const effect of attack.effect ? [attack.effect] : []) {
if (effect.kind !== "summon") continue;
for (const candidate of Object.values(source.entities)) {
if (candidate.id === effect.entityId || candidate.combat?.serverEntry === effect.creatureEntry) entityIds.add(candidate.id);
}
}
}
}
const center = boss.spawn.position;
const radius = Math.max(40, ...groups.flatMap((group) => group.spawns.map((spawn) =>
Math.hypot(spawn.position[0] - center[0], spawn.position[2] - center[2]) + 8
)), ...objects.map((object) => Math.hypot(object.position[0] - center[0], object.position[2] - center[2]) + 8));
const spawnDistance = Math.max(14, (entity.combat?.aggroRange ?? 12) + 5);
const entrance: Vector3Tuple = [center[0], center[1], center[2] + Math.min(radius - 5, spawnDistance)];
const spawn = { ...boss.spawn, spawnMask: undefined, spawnChance: 1 };
const objective = { id: spawn.id, name: boss.name, position: center };
return {
session: { key: boss.key, name: boss.name, sourceDungeonId: source.id, entityId: boss.entityId, runtimeIds: [spawn.id] },
definition: {
...source, id, title: `${boss.name} — Boss Testing`, availability: "available",
assets: { visual: [], collision: [] },
bounds: { min: [center[0] - radius, center[1] - 2, center[2] - radius], max: [center[0] + radius, center[1] + 24, center[2] + radius] },
entrance: { name: "Testing room", footPosition: entrance, yaw: Math.PI, forward: [0, 0, -1] },
areas: [{ id: "testing-room", name: "Boss Testing", center, radius: radius * 2 }],
entities: Object.fromEntries([...entityIds].filter((entityId) => source.entities[entityId])
.map((entityId) => [entityId, remapEncounterReferences(source.entities[entityId], encounterIds)])),
staticSpawns: [spawn], roamingPacks: [], bosses: [objective], objectiveOrder: [objective],
navigationLinks: [], traversalRamps: [], encounters, encounterSpawnGroups: groups, serverGameObjects: objects,
presentation: { ...source.presentation, location: "Boss Testing", summary: "An isolated room for testing boss mechanics.",
loadingMessage: `Preparing ${boss.name}…`, background: "#111923",
fog: { color: "#111923", near: radius, far: radius * 3 },
ambientLight: { color: "#ffffff", intensity: 1.2 },
hemisphereLight: { skyColor: "#c8ddf0", groundColor: "#444c57", intensity: 1.4 },
map: { width: 600, height: 600, padding: 20, contours: [] } },
},
};
}
+5 -4
View File
@@ -2631,15 +2631,16 @@ function damageMobInWork(
const health = mob.health - applied;
const killed = health <= 0;
const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt;
const canAward = killed && !mob.experienceGranted;
const testing = Boolean(useGameStore.getState().bossTest);
const canAward = killed && !mob.experienceGranted && !testing;
const beforeLevel = work.progression.level;
const pendingLoot = killed && mob.lootSourceId
const pendingLoot = testing ? [] : killed && mob.lootSourceId
? rollBossLoot(mob.lootSourceId, beforeLevel, {
now,
bonusItemChance: mob.bonusLootChance,
})
: mob.pendingLoot;
const card = killed && mob.boss
const card = killed && mob.boss && !testing
? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name)
: null;
const bossCardDrop = card ? {
@@ -2648,7 +2649,7 @@ function damageMobInWork(
claimId: `${now}:${mob.deathRevision + 1}:${mob.id}`,
rarities: rollBossCards(),
} satisfies BossCardDrop : mob.bossCardDrop;
const hasLoot = killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot;
const hasLoot = testing ? false : killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot;
const xpAwarded = canAward ? grantMobExperience(classId, work, mob.xpReward) : 0;
engageMobGroupInWork(work, id, now, source.actorId);
const engagedMob = work.mobs[id] ?? mob;
+5 -5
View File
@@ -1,6 +1,6 @@
import { afterAll, describe, expect, it } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { BASE_CLASS_IDS, COA_CLASS_IDS } from "../app/characterCatalog";
import { BASE_CLASS_IDS, COA_CLASS_IDS, ROM_CLASS_IDS } from "../app/characterCatalog";
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
import { runDungeonBossBenchmark } from "./benchmark/dungeonBossBenchmark";
import { BENCHMARK_POLICY_ID } from "./benchmark/benchmarkController";
@@ -14,16 +14,16 @@ const samples: unknown[] = [];
afterAll(() => {
if (process.env.BOSS_BALANCE_REPORT !== "1") return;
mkdirSync("artifacts/boss-difficulty", { recursive: true });
writeFileSync("artifacts/boss-difficulty/wow-coa-balance.json", `${JSON.stringify({
writeFileSync("artifacts/boss-difficulty/wow-rom-coa-balance.json", `${JSON.stringify({
difficulty: 1, dungeon: dungeon.id, durationLimitSeconds: 60,
targets: { ordinary: 25, final: 45 }, policy: BENCHMARK_POLICY_ID, gear: "resolved starter heirlooms", samples,
}, null, 2)}\n`);
});
describe("default boss difficulty with WoW and CoA classes", () => {
describe("default boss difficulty across WoW, RoM, and CoA classes", () => {
for (const level of [8, 20, 40, 80]) {
it(`benchmarks every class-role build at level ${level}`, async () => {
const builds = await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...COA_CLASS_IDS]);
const builds = await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...ROM_CLASS_IDS, ...COA_CLASS_IDS]);
const tooFast: string[] = [];
for (const build of builds) {
// Let the test worker deliver progress between CPU-heavy simulations.
@@ -43,7 +43,7 @@ describe("default boss difficulty with WoW and CoA classes", () => {
for (const level of [8, 80]) {
it(`records healer outcomes with the final boss attacking at level ${level}`, async () => {
const builds = (await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...COA_CLASS_IDS]))
const builds = (await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...ROM_CLASS_IDS, ...COA_CLASS_IDS]))
.filter((build) => build.role === "healer");
for (const build of builds) {
const last = await runDungeonBossBenchmark(build, final.id, "class-balance-2", true);
+45 -8
View File
@@ -11,6 +11,7 @@ import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock
import { subscribeCombatEvents } from "./combatEvents";
import { bossDifficultyMultipliers } from "./dungeonDifficulty";
import { abilitiesForClass } from "./abilityCatalog";
import type { ContentCategoryId } from "../app/contentCategories";
const dungeon = requireDungeonDefinition("ragefire-chasm");
const final = finalDungeonBoss(dungeon)!;
@@ -21,15 +22,16 @@ afterAll(() => {
writeFileSync("artifacts/boss-difficulty/ragefire-balance.json", `${JSON.stringify({ samples }, null, 2)}\n`);
});
function fight(bossId: string, level: number, difficulty: number, seed: string, mechanics = false, playerDamage = false) {
function fight(bossId: string, level: number, difficulty: number, seed: string, mechanics = false, playerDamage = false,
characterSource: ContentCategoryId = "rom", encounterDungeon = dungeon) {
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
const entity = dungeon.entities[dungeon.staticSpawns.find((spawn) => spawn.id === bossId)!.entityId];
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, level, "normal", difficulty);
const entity = encounterDungeon.entities[encounterDungeon.staticSpawns.find((spawn) => spawn.id === bossId)!.entityId];
const scaling = dungeonEnemyScaling(encounterDungeon, entity, bossId, level, "normal", difficulty, characterSource);
const combat = useCombatStore.getState();
const classId = playerDamage ? "rom-mage" : "rom-priest";
combat.initializeCharacter({ classId, raceId: "rom-human", level });
const classId = characterSource === "wow" ? (playerDamage ? "mage" : "priest") : (playerDamage ? "rom-mage" : "rom-priest");
combat.initializeCharacter({ classId, raceId: characterSource === "wow" ? "human" : "rom-human", level });
combat.setPlayerPosition([-18, 0, 0]);
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health, playerDamage ? "damage" : "healer", 5, "rom");
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health, playerDamage ? "damage" : "healer", 5, characterSource);
combat.synchronizeActors();
combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry,
...scaling, attacks: entity.combat?.attacks, xpReward: 0 });
@@ -40,7 +42,8 @@ function fight(bossId: string, level: number, difficulty: number, seed: string,
}
let firstDamage: number | undefined;
let incomingDamage = 0; let healing = 0;
const heal = abilitiesForClass("rom-priest").find((ability) => ability.name === "Urgent Heal")!;
const heal = abilitiesForClass(classId).find((ability) => ability.target === "friendly"
&& ability.effects.some((effect) => effect.kind === "heal") && (ability.unlockLevel ?? 1) <= level)!;
const damage = abilitiesForClass(classId).find((ability) => ability.target === "hostile"
&& ability.effects.some((effect) => effect.kind === "damage"))!;
combat.selectMob(bossId);
@@ -59,7 +62,8 @@ function fight(bossId: string, level: number, difficulty: number, seed: string,
const injured = usePartyStore.getState().members.filter((member) => member.health > 0)
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0];
if (injured && injured.health < injured.maxHealth * .8 && !useCombatStore.getState().activeCast) {
usePartyStore.getState().selectMember(injured.id); combat.castAbility(heal.id, undefined, now);
usePartyStore.getState().selectMember(injured.id);
combat.castAbility(heal.id, { targetActorId: injured.id, now });
}
}
const mob = useCombatStore.getState().mobs[bossId];
@@ -72,6 +76,39 @@ function fight(bossId: string, level: number, difficulty: number, seed: string,
}
describe("dungeon boss durability and difficulty", () => {
const stockades = requireDungeonDefinition("stormwind-stockade");
const stockadesBoss = stockades.bosses[0];
const stockadesEntity = stockades.entities[stockades.staticSpawns.find((entry) => entry.id === stockadesBoss.id)!.entityId];
it("gives each level-three Stockades party its source-calibrated health minimum", () => {
const easy = dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "wow");
expect(easy).toMatchObject({ level: 4, maxHealth: 1300 });
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 10, "wow").maxHealth)
.toBe(1300 * bossDifficultyMultipliers(10).health);
// The dungeon catalog must not choose the companion power curve.
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "rom").maxHealth).toBeCloseTo(7074.8);
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "coa").maxHealth).toBe(10766);
});
for (const level of [1, 3, 8, 20, 40, 80]) {
it(`preserves WoW boss mechanic windows in Stockades at level ${level}`, () => {
for (const seed of ["wow-health-1", "wow-health-2", "wow-health-3"]) {
const ordinary = fight(stockadesBoss.id, level, 1, seed, false, false, "wow", stockades);
const last = fight(finalDungeonBoss(stockades)!.id, level, 1, seed, false, false, "wow", stockades);
samples.push({ level, seed, boss: ordinary, final: last });
expect(ordinary.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(25);
expect(last.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(45);
}
});
}
it("allows real Stockades boss mechanics during a level-three WoW Priest party fight", () => {
const result = fight(stockadesBoss.id, 3, 1, "wow-health-1", true, false, "wow", stockades);
expect(result.seconds, JSON.stringify(result)).not.toBeNull();
expect(result.seconds!).toBeLessThanOrEqual(180);
expect(result.attacks).toBeGreaterThan(0);
expect(result.incomingDamage).toBeGreaterThan(0);
});
it("increases boss health and damage at every slider step without changing trash", () => {
const spawn = dungeon.staticSpawns.find((entry) => entry.id === final.id)!;
const entity = dungeon.entities[spawn.entityId];
+39 -7
View File
@@ -4,11 +4,41 @@ import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLe
import { finalDungeonBoss } from "./dungeonRunStore";
import type { DungeonDefinition } from "./dungeonTypes";
import type { PopulationEntityDefinition } from "./mobPopulation";
import type { ContentCategoryId } from "../app/contentCategories";
/** Shared by rendering and deterministic combat benchmarks; preserves authored stats above the floor. */
const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [
[2, 900],
[4, 1_300],
[9, 7_000],
[21, 11_000],
[41, 23_000],
[80, 180_000],
];
function interpolatedFloor(level: number, anchors: readonly (readonly [number, number])[]): number {
const upperIndex = anchors.findIndex(([anchorLevel]) => level <= anchorLevel);
if (upperIndex <= 0) return anchors[0][1];
if (upperIndex < 0) return anchors[anchors.length - 1][1];
const [lowerLevel, lowerHealth] = anchors[upperIndex - 1];
const [upperLevel, upperHealth] = anchors[upperIndex];
const progress = (level - lowerLevel) / (upperLevel - lowerLevel);
return lowerHealth + (upperHealth - lowerHealth) * progress;
}
function partyBossHealthFloor(level: number, characterSource: ContentCategoryId): number {
if (characterSource === "wow") return interpolatedFloor(level, WOW_BOSS_HEALTH_FLOOR);
const baseline = 1_000 + level * 1_200 + level * level * 22;
// Each catalog is calibrated independently against its strongest canonical
// class-role cohort so every fight retains the 25/45-second mechanic window.
return baseline * (characterSource === "rom" ? 1.15 : 1.75);
}
/** Campaign fights retain a durability floor; testing rooms use level-scaled authored stats. */
export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: PopulationEntityDefinition,
instanceId: string, characterLevel: number, difficultyId: string, bossDifficulty: number) {
instanceId: string, characterLevel: number, difficultyId: string, bossDifficulty: number,
characterSource: ContentCategoryId = "wow") {
const boss = entity.kind === "boss";
const testing = dungeon.id.startsWith("boss-testing:");
const level = scaledDungeonEnemyLevel(characterLevel, boss);
const authoredHealth = entity.combat?.healthMultiplier ?? 1;
const authoredDamage = entity.combat?.damageMultiplier ?? 1;
@@ -16,10 +46,12 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
const native = entity.combat?.serverEntry ? creatureRuntimeBaseStats(entity.combat.serverEntry, level) : null;
const originalHealth = (baseline ?? native?.health ?? (boss ? 260 + level * 65 : 72 + level * 22)) * authoredHealth;
const multipliers = boss ? bossDifficultyMultipliers(bossDifficulty) : { health: 1, damage: 1 };
const finalBoss = boss && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
// Calibrated against active RoM, WoW and CoA class-role rotations with heirlooms.
// Every class faces the same floor; the margin covers stronger CoA burst builds.
const bossFloor = (1_000 + level * 1_200 + level * level * 22) * 1.75 * (finalBoss ? 1.8 : 1);
return { level, maxHealth: (boss ? Math.max(originalHealth, bossFloor) : originalHealth) * multipliers.health,
const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
// Match durability to the character's game, including when crossing dungeon catalogs.
const partyHealthFloor = partyBossHealthFloor(level, characterSource);
const bossFloor = partyHealthFloor * (finalBoss ? 1.8 : 1);
// A test room's sole objective must not promote every boss to a campaign finale
// or replace low-level native health with the campaign's full-party minimum.
return { level, maxHealth: (boss && !testing ? Math.max(originalHealth, bossFloor) : originalHealth) * multipliers.health,
healthMultiplier: authoredHealth * multipliers.health, damageMultiplier: authoredDamage * multipliers.damage };
}
+20 -1
View File
@@ -199,8 +199,27 @@ export type DungeonId = string;
export const DUNGEON_DEFINITIONS = Object.freeze(Object.values(DUNGEON_REGISTRY));
// Test rooms are session-only definitions; the shipped dungeon catalog stays immutable.
const bossTestingDefinitions = new Map<string, { definition: DungeonDefinition; sourceDungeonId: string }>();
export function registerBossTestingDungeon(definition: DungeonDefinition, sourceDungeonId: string): void {
if (!definition.id.startsWith("boss-testing:") || !DUNGEON_REGISTRY[sourceDungeonId]) {
throw new Error("Invalid boss testing dungeon.");
}
bossTestingDefinitions.clear();
bossTestingDefinitions.set(definition.id, { definition, sourceDungeonId });
}
export function bossTestingSourceDungeonId(id: string): string | null {
return bossTestingDefinitions.get(id)?.sourceDungeonId ?? null;
}
export function clearBossTestingDungeons(): void {
bossTestingDefinitions.clear();
}
export function dungeonDefinitionById(id: string): DungeonDefinition | null {
return DUNGEON_REGISTRY[id] ?? null;
return bossTestingDefinitions.get(id)?.definition ?? DUNGEON_REGISTRY[id] ?? null;
}
export function requireDungeonDefinition(id: string): DungeonDefinition {
+9 -3
View File
@@ -5,8 +5,8 @@ import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { resetPartyRuntimeClock } from "./partyRuntime";
import { usePartyStore } from "./partyStore";
import { useGameStore, type GameMode } from "./store";
import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
import { useGameStore, type BossTestSession, type GameMode } from "./store";
import { bossTestingSourceDungeonId, dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
import type { WorldSpawn } from "./dungeonTypes";
import type { GmReviewTarget } from "./gmContentReview";
import { shelvedDungeonSpawn } from "@shelvedDungeonSession";
@@ -27,6 +27,7 @@ export interface DungeonSessionOptions {
readonly gameMode?: GameMode;
readonly spawn?: WorldSpawn;
readonly gmTestMode?: boolean;
readonly bossTest?: BossTestSession | null;
readonly gmReviewTarget?: GmReviewTarget | null;
/** Used by in-game encounter transitions so character progression and inventory survive. */
readonly preserveCharacter?: boolean;
@@ -47,7 +48,10 @@ export function activateDungeonSession(
options: DungeonSessionOptions = {},
): boolean {
const definition = dungeonDefinitionById(dungeonId);
if (!definition || !dungeonCanEnter(dungeonId) || !dungeonContentIsUsable(dungeonId)) {
const testingSource = bossTestingSourceDungeonId(dungeonId);
const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource);
if (!definition || (!testing && !dungeonCanEnter(dungeonId))
|| !dungeonContentIsUsable(testing ? testingSource! : dungeonId)) {
return false;
}
if ((difficultyId !== undefined && !definition.difficultyVariants.some((variant) => variant.id === difficultyId))
@@ -107,6 +111,7 @@ export function activateDungeonSession(
gameMode: options.gameMode,
spawn: shelvedDungeonSpawn(dungeonId, options.spawn),
gmTestMode: options.gmTestMode,
bossTest: options.bossTest,
gmReviewTarget: options.gmReviewTarget,
bossDifficulty,
});
@@ -128,6 +133,7 @@ export function restartActiveDungeonSession(): boolean {
gameMode: game.gameMode,
spawn: game.activeSpawn,
gmTestMode: game.gmTestMode,
bossTest: game.bossTest,
gmReviewTarget: game.activeGmReviewTarget,
preserveCharacter: true,
bossDifficulty: game.activeBossDifficulty,
+14 -2
View File
@@ -9,7 +9,15 @@ import { validBossDifficulty } from "./dungeonDifficulty";
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
export type InputMode = "keyboard" | "gamepad";
export type GameMode = "dungeon" | "manastorm";
export type GameOverlay = "pause" | "report-issue" | "party" | "inventory" | "boss-cards" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | "dungeon-results" | null;
export type GameOverlay = "pause" | "boss-test-review" | "report-issue" | "party" | "inventory" | "boss-cards" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | "dungeon-results" | null;
export interface BossTestSession {
readonly key: string;
readonly name: string;
readonly sourceDungeonId: string;
readonly entityId: string;
readonly runtimeIds: readonly string[];
}
interface PlayerSnapshot {
position: Vector3Tuple;
@@ -24,6 +32,7 @@ export interface GameState {
activeBossDifficulty: number;
gameMode: GameMode;
gmTestMode: boolean;
bossTest: BossTestSession | null;
activeGmReviewTarget: GmReviewTarget | null;
activeSpawn: WorldSpawn;
sessionRevision: number;
@@ -55,6 +64,7 @@ export interface GameState {
readonly gameMode?: GameMode;
readonly spawn?: WorldSpawn;
readonly gmTestMode?: boolean;
readonly bossTest?: BossTestSession | null;
readonly gmReviewTarget?: GmReviewTarget | null;
readonly bossDifficulty?: number;
},
@@ -88,6 +98,7 @@ export const useGameStore = create<GameState>((set) => ({
activeBossDifficulty: 1,
gameMode: "dungeon",
gmTestMode: false,
bossTest: null,
activeGmReviewTarget: null,
activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw },
sessionRevision: 0,
@@ -125,6 +136,7 @@ export const useGameStore = create<GameState>((set) => ({
activeBossDifficulty: validBossDifficulty(options?.bossDifficulty) ? options.bossDifficulty : 1,
gameMode: options?.gameMode ?? "dungeon",
gmTestMode: options?.gmTestMode ?? false,
bossTest: options?.bossTest ?? null,
activeGmReviewTarget: options?.gmReviewTarget ?? null,
activeSpawn,
sessionRevision: state.sessionRevision + 1,
@@ -279,7 +291,7 @@ export const useGameStore = create<GameState>((set) => ({
const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw };
return {
gameMode: "dungeon",
gmTestMode: false,
gmTestMode: Boolean(state.bossTest),
activeGmReviewTarget: null,
activeSpawn,
overlay: null,
+77
View File
@@ -2,9 +2,85 @@ import { describe, expect, it } from "vitest";
import {
actionKeyForEvent, assignDesktopAbility, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resetDesktopAbilities, resolveDpsMeterDisplay,
HUD_ELEMENTS, resolveHudScreen,
activateDesktopAbilityLoadout, assignedActionBarAbilityIds,
} from "./uiLayout";
describe("personal UI layouts", () => {
it("populates fresh PC bars without changing shared keys, geometry, templates, or mobile controls", () => {
const ui = normalizeUiPreferences(null);
ui.current.pc.wowBars = true;
ui.current.pc.barCount = 2;
ui.current.pc.bars[1].columns = 3;
ui.current.pc = bindDesktopSlot(ui.current.pc, 1, 0, "Ctrl+KeyQ");
ui.slots.pc[0] = { name: "Raid", layout: ui.current.pc };
const defaults = Array.from({ length: 14 }, (_, i) => `spell-${i}`);
const activated = activateDesktopAbilityLoadout(ui, "new:priest:", defaults, defaults);
expect(activated.current.pc.bars[0].slots[0]).toEqual({ abilityId: "spell-0", key: "Digit1" });
expect(activated.current.pc.bars[1].slots[0]).toEqual({ abilityId: "spell-12", key: "Ctrl+KeyQ" });
expect(activated.current.pc.bars[1].columns).toBe(3);
expect(activated.current.pc.barCount).toBe(2);
expect(activated.current.pc.wowBars).toBe(true);
expect(activated.current.mobile).toEqual(ui.current.mobile);
expect(activated.slots).toEqual(ui.slots);
expect(ui.current.pc.bars[0].slots[0].abilityId).toBeNull();
});
it("restores separate character and ordered-class loadouts, including intentionally empty bars", () => {
let ui = activateDesktopAbilityLoadout(normalizeUiPreferences(null), "one:priest:", ["heal"], ["heal"]);
ui.desktopAbilityLoadouts["one:priest:"] = Array(60).fill(null);
ui = activateDesktopAbilityLoadout(ui, "two:priest:", ["heal"], ["heal"]);
expect(ui.current.pc.bars[0].slots[0].abilityId).toBe("heal");
ui = activateDesktopAbilityLoadout(ui, "one:priest:", ["heal"], ["heal"]);
expect(ui.current.pc.bars.every((bar) => bar.slots.every((slot) => slot.abilityId === null))).toBe(true);
ui = activateDesktopAbilityLoadout(ui, "one:rom-priest:rom-warrior", ["secondary-attack"], ["secondary-attack"]);
expect(ui.current.pc.bars[0].slots[0].abilityId).toBe("secondary-attack");
expect(importUiPreferences(exportUiPreferences(ui))).toEqual(ui);
});
it("adopts compatible legacy assignments once and repairs bars holding only another class's spells", () => {
const ui = normalizeUiPreferences(null);
ui.current.pc = assignDesktopAbility(ui.current.pc, 0, 2, "custom-heal");
const migrated = activateDesktopAbilityLoadout(ui, "one:priest:", ["heal"], ["heal", "custom-heal"]);
expect(migrated.current.pc.bars[0].slots[0].abilityId).toBeNull();
expect(migrated.current.pc.bars[0].slots[2].abilityId).toBe("custom-heal");
const next = activateDesktopAbilityLoadout(migrated, "two:priest:", ["heal"], ["heal", "custom-heal"]);
expect(next.current.pc.bars[0].slots[0].abilityId).toBe("heal");
expect(next.current.pc.bars[0].slots[2].abilityId).toBeNull();
const repaired = activateDesktopAbilityLoadout(ui, "new:mage:", ["fireball"], ["fireball"]);
expect(repaired.current.pc.bars[0].slots[0].abilityId).toBe("fireball");
const oldExport = JSON.parse(exportUiPreferences(ui));
delete oldExport.desktopAbilityLoadouts;
expect(importUiPreferences(JSON.stringify(oldExport)).desktopAbilityLoadouts).toEqual({});
});
it("checks the selected layout across modifier layers and active PC bars", () => {
const layout = defaultUiLayout();
layout.bars[0].slots[0].abilityId = "pc-heal";
layout.bars[1].slots[0].abilityId = "inactive-heal";
const bindings = { "primary:face-bottom": "controller-heal", "tertiary:r2": "modifier-heal" };
expect([...assignedActionBarAbilityIds(layout, bindings, false)]).toEqual(["controller-heal", "modifier-heal"]);
expect([...assignedActionBarAbilityIds(layout, bindings, true)]).toEqual(["pc-heal"]);
layout.barCount = 2;
expect(assignedActionBarAbilityIds(layout, bindings, true).has("inactive-heal")).toBe(true);
});
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 };
@@ -96,6 +172,7 @@ describe("personal UI layouts", () => {
["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "NotAKey"; }],
["duplicate chords", (data: any) => { data.current.pc.bars[0].slots[1].key = "Digit1"; }],
["missing buttons", (data: any) => { data.current.pc.bars[0].slots.pop(); }],
["invalid character bars", (data: any) => { data.desktopAbilityLoadouts = { character: ["heal"] }; }],
])("rejects %s without mutating the original preferences", (_, mutate) => {
const ui = normalizeUiPreferences(null);
const original = exportUiPreferences(ui);
+64 -3
View File
@@ -6,7 +6,7 @@ export const UI_DEVICES: readonly UiDevice[] = ["mobile", "pc"];
export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const;
export const HUD_ELEMENTS = {
party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap",
player: "Player status", timeline: "Encounter timeline", target: "Enemy target",
player: "Buffs and debuffs", timeline: "Encounter timeline", target: "Enemy target",
friendly: "Friendly target", threat: "Threat meter", dps: "DPS meter", cast: "Cast bar",
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
@@ -16,8 +16,10 @@ export const HUD_ELEMENTS = {
"bar-4": "Action bar 4", "bar-5": "Action bar 5",
} as const;
export type HudElementId = keyof typeof HUD_ELEMENTS;
export type HudScreen = "auto" | "top" | "bottom" | "hidden";
export interface HudElementLayout {
visible: boolean;
screen?: HudScreen;
position: NormalizedHudPosition | null;
width: number | null;
}
@@ -34,6 +36,8 @@ export interface UiTemplate { name: string; layout: UiLayout }
export interface UiPreferences {
version: 1;
keybinds: InputPreferences;
/** Ability assignments belong to a character and ordered class pair; geometry and keys stay shared. */
desktopAbilityLoadouts: Record<string, (string | null)[]>;
current: Record<UiDevice, UiLayout>;
slots: Record<UiDevice, (UiTemplate | null)[]>;
}
@@ -87,6 +91,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 +119,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);
@@ -123,6 +141,12 @@ export function normalizeUiPreferences(value: unknown): UiPreferences {
return {
version: 1,
keybinds: normalizeInputPreferences(source.keybinds),
desktopAbilityLoadouts: Object.fromEntries(Object.entries(record(source.desktopAbilityLoadouts))
.filter(([key, value]) => key.length <= 300 && Array.isArray(value))
.map(([key, value]) => [key, Array.from({ length: 60 }, (_, index) => {
const id = (value as unknown[])[index];
return typeof id === "string" && id.length <= 200 ? id : null;
})])),
current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) },
slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => {
const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null);
@@ -146,11 +170,41 @@ export function assignDesktopAbility(layout: UiLayout, barIndex: number, slotInd
}
/** Restore abilities only; key chords and the arrangement belong to the UI template. */
export function resetDesktopAbilities(layout: UiLayout, abilityIds: readonly string[], barIndex?: number): UiLayout {
export function resetDesktopAbilities(layout: UiLayout, abilityIds: readonly (string | null)[], barIndex?: number): UiLayout {
return { ...layout, bars: layout.bars.map((bar, b) => barIndex !== undefined && b !== barIndex ? bar : {
...bar, slots: bar.slots.map((slot, s) => ({ ...slot, abilityId: abilityIds[b * 12 + s] ?? null })),
}) };
}
/** Use the same class defaults as mobile, while preserving an existing character's empty slots. */
export function activateDesktopAbilityLoadout(
preferences: UiPreferences, key: string, defaults: readonly string[], availableIds: readonly string[],
): UiPreferences {
const layout = preferences.current.pc;
const available = new Set(availableIds);
const legacy = layout.bars.flatMap((bar) => bar.slots.map((slot) => slot.abilityId));
const saved = preferences.desktopAbilityLoadouts[key];
// Adopt the old account-wide assignments once, only for a compatible character.
const abilities = saved ?? (Object.keys(preferences.desktopAbilityLoadouts).length === 0
&& legacy.some((id) => id && available.has(id))
? legacy.map((id) => id && available.has(id) ? id : null)
: defaults);
const pc = resetDesktopAbilities(layout, abilities);
return { ...preferences, current: { ...preferences.current, pc },
desktopAbilityLoadouts: { ...preferences.desktopAbilityLoadouts,
[key]: pc.bars.flatMap((bar) => bar.slots.map((slot) => slot.abilityId)) },
};
}
/** Modifier layers count together; inactive PC bars cannot be used until enabled. */
export function assignedActionBarAbilityIds(
layout: UiLayout, bindings: Readonly<Record<string, string | null>>, desktop: boolean,
): ReadonlySet<string> {
const ids = desktop
? layout.bars.slice(0, layout.barCount).flatMap((bar) => bar.slots.map((slot) => slot.abilityId))
: Object.values(bindings);
return new Set(ids.filter((id): id is string => Boolean(id)));
}
export function desktopSlotForKey(layout: UiLayout, key: string | null): DesktopActionSlot | null {
if (!layout.wowBars || !key) return null;
// Hidden bars retain their bindings, like WoW; reducing the bar count deactivates them.
@@ -164,6 +218,13 @@ export function importUiPreferences(text: string): UiPreferences {
let source: Record<string, unknown>;
try { source = record(JSON.parse(text)); } catch { throw new Error("This file is not valid JSON."); }
if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version.");
if (source.desktopAbilityLoadouts !== undefined) {
if (!source.desktopAbilityLoadouts || typeof source.desktopAbilityLoadouts !== "object" || Array.isArray(source.desktopAbilityLoadouts)) throw new Error("Invalid character action bars.");
for (const [key, ids] of Object.entries(record(source.desktopAbilityLoadouts))) {
if (key.length > 300 || !Array.isArray(ids) || ids.length !== 60
|| ids.some((id) => id !== null && (typeof id !== "string" || id.length > 200))) throw new Error("Invalid character action bars.");
}
}
const validateLayout = (value: unknown) => {
const layout = record(value);
if (layout.dpsMeterDisplay !== undefined && !["auto", "main", "companion"].includes(String(layout.dpsMeterDisplay))) throw new Error("Invalid DPS meter display.");
@@ -174,7 +235,7 @@ export function importUiPreferences(text: string): UiPreferences {
if (id === "dps" && record(layout.elements)[id] === undefined) continue;
const element = record(record(layout.elements)[id]);
const position = record(element.position);
if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
if ((element.screen !== undefined && !["auto", "top", "bottom", "hidden"].includes(String(element.screen))) || typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
|| (element.width !== null && (typeof element.width !== "number" || !Number.isFinite(element.width) || element.width < 20 || element.width > 2000))) throw new Error("Invalid HUD position or visibility.");
}
const keys = new Set<string>();
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDefaultAppearance } from "../app/characterCatalog";
import { createCharacter, loadUiPreferences, saveUiPreferences } from "../app/accountRepository";
import { useShellStore } from "../app/shellStore";
import { defaultActionBarForCharacter } from "./abilityCatalog";
import { assignDesktopAbility, normalizeUiPreferences } from "./uiLayout";
import { activateCharacterUi, useUiLayoutStore } from "./uiLayoutStore";
afterEach(() => { vi.unstubAllGlobals(); useShellStore.setState({ session: null }); });
describe("character PC action bars", () => {
it("persists custom assignments and empty slots across character switches and sign-in", () => {
const values = new Map<string, string>();
const storage = { getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value); },
removeItem: (key: string) => { values.delete(key); } };
vi.stubGlobal("window", { localStorage: storage });
const create = (name: string) => createCharacter({ ownerId: "offline-bars", name,
classId: "priest", raceId: "human", gender: "female", appearance: createDefaultAppearance(),
}, storage).character!;
const first = create("Firstbars");
const second = create("Secondbars");
const ui = normalizeUiPreferences(null);
ui.current.pc.wowBars = true;
saveUiPreferences(first.ownerId, ui, storage);
const session = { kind: "offline" as const, ownerId: first.ownerId, displayName: "Offline" };
useShellStore.setState({ session });
activateCharacterUi(first);
expect(useUiLayoutStore.getState().preferences.current.pc.bars[0].slots[0].abilityId)
.toBe(defaultActionBarForCharacter("priest", null, 1)[0]);
const state = useUiLayoutStore.getState();
const pc = assignDesktopAbility(assignDesktopAbility(state.preferences.current.pc, 0, 0, null), 0, 2, "priest-flash-heal");
state.persist({ ...state.preferences, current: { ...state.preferences.current, pc } });
activateCharacterUi(second);
expect(useUiLayoutStore.getState().preferences.current.pc.bars[0].slots[0].abilityId).not.toBeNull();
activateCharacterUi(first);
expect(useUiLayoutStore.getState().preferences.current.pc.bars[0].slots[0].abilityId).toBeNull();
expect(useUiLayoutStore.getState().preferences.current.pc.bars[0].slots[2].abilityId).toBe("priest-flash-heal");
useShellStore.setState({ session: null });
useShellStore.setState({ session: { ...session } });
activateCharacterUi(first);
expect(useUiLayoutStore.getState().preferences.current.pc.bars[0].slots[0].abilityId).toBeNull();
expect(useUiLayoutStore.getState().preferences.current.pc.bars[0].slots[2].abilityId).toBe("priest-flash-heal");
expect(loadUiPreferences("another-owner", storage).desktopAbilityLoadouts).toEqual({});
activateCharacterUi({ ...second, ownerId: "another-owner" });
expect(useUiLayoutStore.getState().desktopLoadoutKey).toContain(first.id);
});
});
+25 -5
View File
@@ -2,16 +2,20 @@ import { create } from "zustand";
import { Capacitor } from "@capacitor/core";
import { loadUiPreferences, saveUiPreferences } from "../app/accountRepository";
import { useShellStore } from "../app/shellStore";
import { normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout";
import type { CharacterProfile } from "../app/types";
import { abilitiesForCharacter, defaultActionBarForCharacter } from "./abilityCatalog";
import { activateDesktopAbilityLoadout, normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout";
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";
}
interface UiLayoutState {
ownerId: string | null;
desktopLoadoutKey: string | null;
preferences: UiPreferences;
draft: UiLayout | null;
device: UiDevice;
@@ -24,7 +28,7 @@ interface UiLayoutState {
finish: (save: boolean) => void;
}
export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
ownerId: null, preferences: normalizeUiPreferences(null), draft: null, device: currentUiDevice(),
ownerId: null, desktopLoadoutKey: null, preferences: normalizeUiPreferences(null), draft: null, device: currentUiDevice(),
mode: "layout", selectedButton: null, selectedElement: null,
begin: () => {
const device = currentUiDevice();
@@ -35,7 +39,11 @@ export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
const ownerId = get().ownerId;
if (!ownerId) throw new Error("Sign in or choose an offline profile before saving your UI.");
// Write first: storage errors leave the editor open with its draft intact.
const saved = saveUiPreferences(ownerId, preferences);
const key = get().desktopLoadoutKey;
const saved = saveUiPreferences(ownerId, key ? { ...preferences,
desktopAbilityLoadouts: { ...preferences.desktopAbilityLoadouts,
[key]: preferences.current.pc.bars.flatMap((bar) => bar.slots.map((slot) => slot.abilityId)) },
} : preferences);
set({ preferences: saved });
},
finish: (save) => {
@@ -47,7 +55,7 @@ export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
function activateOwner() {
const ownerId = useShellStore.getState().session?.ownerId ?? null;
useUiLayoutStore.setState({ ownerId, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null });
useUiLayoutStore.setState({ ownerId, desktopLoadoutKey: null, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null });
}
activateOwner();
publishInputPreferences(useUiLayoutStore.getState().preferences.keybinds);
@@ -56,6 +64,18 @@ useShellStore.subscribe((state, previous) => {
if (state.session !== previous.session) activateOwner();
});
/** Call after the character's catalogs have loaded, in both menu and gameplay paths. */
export function activateCharacterUi(character: CharacterProfile): void {
const ui = useUiLayoutStore.getState();
if (ui.ownerId !== character.ownerId) return;
const key = `${character.id}:${character.classId}:${character.secondaryClassId ?? ""}`;
useUiLayoutStore.setState({ desktopLoadoutKey: key, draft: null,
preferences: activateDesktopAbilityLoadout(ui.preferences, key,
defaultActionBarForCharacter(character.classId, character.secondaryClassId, character.level),
abilitiesForCharacter(character.classId, character.secondaryClassId).map((ability) => ability.id)),
});
}
export function activeUiLayout(): UiLayout {
const state = useUiLayoutStore.getState();
return state.draft ?? state.preferences.current[currentUiDevice()];
+2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import type { CharacterProfile } from "../app/types";
import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "./combatStore";
import { activateCharacterUi } from "./uiLayoutStore";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { useArenaStore } from "./arenaStore";
@@ -16,6 +17,7 @@ export function useArenaCombatView(character: CharacterProfile | null, actor: Ar
const previousParty = usePartyStore.getState();
const previousGame = useGameStore.getState();
useCombatStore.getState().initializeCharacter(character);
activateCharacterUi(character);
usePartyStore.setState({ members: [] });
useGameStore.setState({ overlay: null, paused: false, companionOpen: false });
const unsubscribe = useCombatStore.subscribe((state, before) => {
+6
View File
@@ -4,15 +4,21 @@ import { Capacitor } from "@capacitor/core";
import App from "./App";
import { startControllerInput } from "./input/controller";
import { currentDisplayLayout } from "./platform/displayLayout";
import { THOR_DISPLAYS } from "./platform/thorDisplayProfile";
import { initializeContentSystem } from "./content/contentManager";
import "./styles.css";
import "./ui/dungeonReports.css";
import "./ui/thorUi.css";
const BottomDisplayApp = lazy(() => import("./platform/BottomDisplayApp").then((module) => ({ default: module.BottomDisplayApp })));
const params = new URLSearchParams(window.location.search);
const displayMode = params.get("display");
const nativeLayoutRequested = params.has("nativeLayout");
if (Capacitor.isNativePlatform() && (displayMode === "top" || displayMode === "bottom")) {
// Let each WebView fit the same physical UI density, independently of Android's shared display density.
document.querySelector('meta[name="viewport"]')?.setAttribute("content", `width=${THOR_DISPLAYS[displayMode].cssWidth}, viewport-fit=cover`);
}
if (Capacitor.isNativePlatform() || nativeLayoutRequested) document.documentElement.classList.add("native-platform");
if (displayMode === "top" || displayMode === "bottom") document.documentElement.dataset.displaySurface = displayMode;
document.documentElement.dataset.displayLayout = currentDisplayLayout();
+4 -1
View File
@@ -15,6 +15,7 @@ import {
import { normalizeInputPreferences } from "../input/keybinds";
import { publishInputPreferences } from "../input/keybindRuntime";
import { useForcedThorDisplays } from "./useThorDualScreen";
import { RemoteDungeonHud } from "./RemoteDungeonHud";
export function BottomDisplayApp() {
useForcedThorDisplays();
@@ -78,5 +79,7 @@ export function BottomDisplayApp() {
};
}, []);
return <main className="bottom-display-root"><CompanionPanel snapshot={snapshot} send={send} preferControllerBinds /></main>;
return <main className="bottom-display-root">{snapshot.phase === "game"
? <RemoteDungeonHud snapshot={snapshot} send={send} />
: <CompanionPanel snapshot={snapshot} send={send} preferControllerBinds />}</main>;
}
+53
View File
@@ -0,0 +1,53 @@
import { useLayoutEffect } from "react";
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store";
import { clearBossTestingDungeons, registerBossTestingDungeon } from "../game/dungeonRegistry";
import { usePartyStore } from "../game/partyStore";
import { useManastormStore } from "../game/manastormStore";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useShellStore } from "../app/shellStore";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore";
import { DungeonCompanionHud } from "../ui/DungeonCompanionHud";
import type { CompanionCommand, CompanionSnapshot } from "./dualScreenSync";
export function RemoteDungeonHud({ snapshot, send }: {
snapshot: CompanionSnapshot;
send: (command: CompanionCommand) => void;
}) {
const hud = snapshot.game.hud;
useLayoutEffect(() => {
if (hud.bossTestingDungeon) registerBossTestingDungeon(hud.bossTestingDungeon.definition, hud.bossTestingDungeon.sourceDungeonId);
const session = useShellStore.getState().session;
useShellStore.setState({ ...hud.shell,
session: session?.ownerId === snapshot.session?.ownerId ? session : snapshot.session,
});
useUiLayoutStore.setState(hud.ui);
useGameStore.setState({ ...hud.game,
toggleMap: () => send({ name: "toggleMap" }),
openOverlay: (overlay) => send({ name: "openOverlay", overlay }),
});
if (!hud.bossTestingDungeon) clearBossTestingDungeons();
useCombatStore.setState({ ...hud.combat,
castActionBinding: (layer, control) => { send({ name: "castActionBinding", layer, control }); return { ok: false, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 }; },
selectFriendlyActor: (id) => { send({ name: "selectFriendlyActor", id }); return true; },
clearTarget: () => send({ name: "clearTarget" }),
updateSettings: (patch) => { if (patch.threatMeterPosition) send({ name: "moveThreatMeter", position: patch.threatMeterPosition }); },
});
usePartyStore.setState({ ...hud.party,
selectMember: (memberId) => { if (memberId) send({ name: "selectPartyMember", memberId }); else send({ name: "clearPartySelection" }); return true; },
selectSelf: () => send({ name: "selectPlayer" }),
clearSelection: () => send({ name: "clearPartySelection" }),
setCommand: (command) => send({ name: "setPartyCommand", command }),
recallParty: () => send({ name: "recallParty" }),
});
useManastormStore.setState(hud.manastorm);
useDungeonRunStore.setState({ progress: hud.run });
useOnlineSessionStore.setState({ snapshot: hud.onlineSession });
useOnlineGroupStore.setState(hud.onlineGroup);
useDpsMeterStore.setState(hud.dps);
}, [hud, send, snapshot.session]);
return <DungeonCompanionHud send={send} remote completion={hud.completion} interaction={hud.interaction} />;
}
@@ -7,6 +7,10 @@ import { currentCompanionSnapshot, executeCompanionCommand, type DualScreenMessa
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import { subscribeDungeonEncounterSnapshots } from "../game/dungeonEncounterRuntime";
export function connectAuthoritativeDualScreen(
channel: BroadcastChannel,
@@ -38,6 +42,11 @@ export function connectAuthoritativeDualScreen(
const unsubscribeDps = useDpsMeterStore.subscribe(() => {
if (companionReady) gamePublisher.request();
});
const request = () => { if (companionReady) gamePublisher.request(); };
const unsubscribeOnline = useOnlineSessionStore.subscribe(request);
const unsubscribeGroup = useOnlineGroupStore.subscribe(request);
const unsubscribeRun = useDungeonRunStore.subscribe(request);
const unsubscribeEncounters = subscribeDungeonEncounterSnapshots(request);
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "companion-ready") {
companionReady = true;
@@ -62,6 +71,10 @@ export function connectAuthoritativeDualScreen(
unsubscribeParty();
unsubscribeUi();
unsubscribeDps();
unsubscribeOnline();
unsubscribeGroup();
unsubscribeRun();
unsubscribeEncounters();
gamePublisher.dispose();
};
}
+33 -1
View File
@@ -32,6 +32,10 @@ import { activeUiLayout, useUiLayoutStore } from "../game/uiLayoutStore";
import { normalizeHudPosition } from "../game/hudLayout";
import { resolveDpsMeterDisplay, type HudElementLayout } from "../game/uiLayout";
import { currentDisplayLayout } from "./displayLayout";
import { currentHudSnapshot, type HudSnapshot } from "./hudSnapshot";
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
import { interactWithActiveDungeonObject } from "../game/dungeonWorldInteraction";
import { resurrectDeadPartyMembers } from "../game/partyResurrection";
export { createDualScreenChannel } from "./dualScreenChannel";
@@ -95,6 +99,7 @@ export interface CompanionPartyObjectiveSnapshot {
}
export interface CompanionGameSnapshot {
hud: HudSnapshot;
dpsMeter: {
values: DpsMeterSnapshot;
layout: HudElementLayout;
@@ -172,7 +177,15 @@ export type CompanionCommand =
| { name: "setPartyCommand"; command: PartyCommand }
| { name: "recallParty" }
| { name: "resetAtEntrance" }
| { name: "editDpsMeter"; update: Partial<HudElementLayout> };
| { name: "editDpsMeter"; update: Partial<HudElementLayout> }
| { name: "editHudElement"; id: HudElementId; update: Partial<HudElementLayout> }
| { name: "selectFriendlyActor"; id: string | null }
| { name: "selectPlayer" }
| { name: "clearPartySelection" }
| { name: "resurrectParty"; checkpointId: string }
| { name: "interactDungeonObject" }
| { name: "finishDungeon" }
| { name: "moveThreatMeter"; position: { x: number; y: number } };
export type DualScreenMessage =
| { type: "companion-ready" }
@@ -265,6 +278,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
draft: shell.draft,
notice: shell.notice,
game: {
hud: currentHudSnapshot(),
dpsMeter: {
values: currentDpsMeterSnapshot(),
layout: uiLayout.elements.dps,
@@ -342,6 +356,24 @@ export function executeCompanionCommand(command: CompanionCommand): void {
const game = useGameStore.getState();
const combat = useCombatStore.getState();
switch (command.name) {
case "editHudElement": {
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft || !(command.id in HUD_ELEMENTS)) break;
const update: Partial<HudElementLayout> = {};
if (command.update.position) update.position = normalizeHudPosition(command.update.position, { x: .5, y: .5 });
if (typeof command.update.width === "number" && Number.isFinite(command.update.width)) update.width = Math.max(20, Math.min(2000, command.update.width));
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
[command.id]: { ...layout.elements[command.id], ...update },
} }));
useUiLayoutStore.setState({ selectedElement: command.id });
break;
}
case "selectFriendlyActor": combat.selectFriendlyActor(command.id); break;
case "selectPlayer": combat.clearTarget(); usePartyStore.getState().selectSelf(); break;
case "clearPartySelection": usePartyStore.getState().clearSelection(); break;
case "resurrectParty": resurrectDeadPartyMembers(command.checkpointId); break;
case "interactDungeonObject": if (!game.overlay) interactWithActiveDungeonObject(); break;
case "finishDungeon": if (currentHudSnapshot().completion) { shell.saveActiveDungeonRunCheckpoint(null); shell.returnToMainMenu(); } break;
case "moveThreatMeter": combat.updateSettings({ threatMeterPosition: normalizeHudPosition(command.position, { x: .8, y: .3 }) }); break;
case "editDpsMeter": {
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft) break;
const update: Partial<HudElementLayout> = {};
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { currentHudSnapshot } from "./hudSnapshot";
import { executeCompanionCommand } from "./dualScreenSync";
import { useGameStore } from "../game/store";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { normalizeUiPreferences } from "../game/uiLayout";
import { BOSS_TESTING_CATALOG, createBossTestingRoom } from "../game/bossTesting";
import { clearBossTestingDungeons, registerBossTestingDungeon, requireDungeonDefinition } from "../game/dungeonRegistry";
describe("remote HUD presentation", () => {
it("carries the isolated boss room to the lower display without simulation methods", () => {
const original = useGameStore.getState();
const boss = BOSS_TESTING_CATALOG.find((entry) => entry.dungeonId === "wailing-caverns")!;
const room = createBossTestingRoom(requireDungeonDefinition(boss.dungeonId), boss);
try {
registerBossTestingDungeon(room.definition, boss.dungeonId);
useGameStore.getState().activateDungeon(room.definition.id, undefined, { bossTest: room.session, gmTestMode: true });
const snapshot = structuredClone(currentHudSnapshot());
expect(snapshot.bossTestingDungeon?.definition).toEqual(room.definition);
expect(snapshot.game.bossTest).toEqual(room.session);
clearBossTestingDungeons();
registerBossTestingDungeon(snapshot.bossTestingDungeon!.definition, snapshot.bossTestingDungeon!.sourceDungeonId);
expect(requireDungeonDefinition(snapshot.game.activeDungeonId).staticSpawns).toHaveLength(1);
} finally {
useGameStore.setState(original);
clearBossTestingDungeons();
}
});
it("contains cloneable presentation data without simulation methods", () => {
const snapshot = currentHudSnapshot();
expect(() => structuredClone(snapshot)).not.toThrow();
expect(snapshot.combat).not.toHaveProperty("tick");
expect(snapshot.game).not.toHaveProperty("activateDungeon");
expect(snapshot.party).not.toHaveProperty("recallParty");
});
it("accepts bounded moves only while the authoritative editor is open", () => {
const originalUi = useUiLayoutStore.getState();
const originalGame = useGameStore.getState();
try {
const draft = normalizeUiPreferences(null).current.mobile;
useUiLayoutStore.setState({ draft });
useGameStore.setState({ overlay: null });
executeCompanionCommand({ name: "editHudElement", id: "party", update: { position: { x: .8, y: .7 } } });
expect(useUiLayoutStore.getState().draft).toBe(draft);
useGameStore.setState({ overlay: "ui-layout" });
executeCompanionCommand({ name: "editHudElement", id: "party", update: { position: { x: 5, y: -1 }, width: 99999, screen: "hidden" } });
expect(useUiLayoutStore.getState().draft?.elements.party).toMatchObject({ position: { x: 1, y: 0 }, width: 2000, visible: true });
expect(useUiLayoutStore.getState().draft?.elements.party.screen).toBeUndefined();
} finally {
useUiLayoutStore.setState(originalUi);
useGameStore.setState(originalGame);
}
});
});
+40
View File
@@ -0,0 +1,40 @@
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store";
import { usePartyStore } from "../game/partyStore";
import { useManastormStore } from "../game/manastormStore";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useShellStore } from "../app/shellStore";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore";
import { activeDungeonRunIsComplete } from "../game/dungeonEncounterRuntime";
import { activeDungeonInteractionCandidate } from "../game/dungeonWorldInteraction";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
function pick<T, K extends keyof T>(state: T, keys: readonly K[]): Pick<T, K> {
return Object.fromEntries(keys.map((key) => [key, state[key]])) as Pick<T, K>;
}
/** Presentation data only. The lower WebView never starts another simulation. */
export function currentHudSnapshot() {
const game = useGameStore.getState();
const run = useDungeonRunStore.getState().progress;
const candidate = game.overlay ? null : activeDungeonInteractionCandidate();
return {
game: pick(game, ["activeDungeonId", "gameMode", "gmTestMode", "bossTest", "areaName", "distanceTravelled", "playerPosition", "cameraYaw", "hasMoved", "cameraLookActive", "inputMode", "actionLayer", "assetStatus", "assetMessage", "overlay", "paused", "companionOpen"]),
bossTestingDungeon: game.bossTest ? { definition: requireDungeonDefinition(game.activeDungeonId), sourceDungeonId: game.bossTest.sourceDungeonId } : null,
combat: pick(useCombatStore.getState(), ["classId", "secondaryClassId", "specializationId", "level", "xp", "xpToNext", "health", "maxHealth", "shield", "shieldExpiresAt", "resource", "maxResource", "resourcePools", "maxResourcePools", "resourceName", "abilities", "talentRanks", "talentModifiers", "auras", "effects", "summons", "actors", "actionBindings", "cooldowns", "globalCooldownEndsAt", "activeCast", "pendingGroundTarget", "feedback", "selectedTargetId", "selectedFriendlyActorId", "mobs", "settings"]),
party: pick(usePartyStore.getState(), ["members", "selectedMemberId", "selfSelected", "playerRole", "command", "routeBlocked"]),
manastorm: pick(useManastormStore.getState(), ["currentEncounter", "resurrectionCharges"]),
shell: pick(useShellStore.getState(), ["activeCharacter", "activeDungeonRole"]),
onlineSession: useOnlineSessionStore.getState().snapshot,
onlineGroup: pick(useOnlineGroupStore.getState(), ["snapshot", "activeActivity"]),
ui: pick(useUiLayoutStore.getState(), ["preferences", "draft", "device", "selectedElement"]),
run,
dps: useDpsMeterStore.getState(),
completion: game.gameMode === "dungeon" && (activeDungeonRunIsComplete(game.activeDungeonId) || (run?.dungeonId === game.activeDungeonId && run.finishedAt !== null)),
interaction: candidate ? { prompt: candidate.object.interaction!.prompt, name: candidate.object.name, label: candidate.state.label } : null,
};
}
export type HudSnapshot = ReturnType<typeof currentHudSnapshot>;
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { THOR_DISPLAYS, THOR_UI_PIXELS_PER_INCH } from "./thorDisplayProfile";
describe("Thor physical display sizing", () => {
it("uses millimetres for relative panel size, with equal physical touch targets", () => {
const { top, bottom } = THOR_DISPLAYS;
expect(top.widthMm).toBeCloseTo(132.83, 1);
expect(bottom.widthMm).toBeCloseTo(75.12, 1);
expect(bottom.cssWidth / top.cssWidth).toBeCloseTo(bottom.widthMm / top.widthMm, 2);
expect(bottom.deviceScaleFactor).toBeGreaterThan(top.deviceScaleFactor);
for (const panel of [top, bottom]) {
expect(44 * panel.deviceScaleFactor / panel.pixelsPerInch * 25.4).toBeCloseTo(44 / THOR_UI_PIXELS_PER_INCH * 25.4);
}
});
});
+17
View File
@@ -0,0 +1,17 @@
/** AYN Thor panel dimensions, at a common physical UI density (160 CSS px/in). */
export const THOR_UI_PIXELS_PER_INCH = 160;
export function physicalDisplayProfile(width: number, height: number, diagonalInches: number) {
const pixelsPerInch = Math.hypot(width, height) / diagonalInches;
return {
width, height, diagonalInches, pixelsPerInch,
widthMm: width / pixelsPerInch * 25.4,
heightMm: height / pixelsPerInch * 25.4,
cssWidth: Math.round(width / pixelsPerInch * THOR_UI_PIXELS_PER_INCH),
cssHeight: Math.round(height / pixelsPerInch * THOR_UI_PIXELS_PER_INCH),
deviceScaleFactor: pixelsPerInch / THOR_UI_PIXELS_PER_INCH,
};
}
export const THOR_DISPLAYS = {
top: physicalDisplayProfile(1920, 1080, 6),
bottom: physicalDisplayProfile(1240, 1080, 3.92),
};
+30
View File
@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { RigidBody } from "@react-three/rapier";
import type { DungeonDefinition } from "../game/dungeonTypes";
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_WALL_COLLISION_GROUPS } from "./dungeonLineOfSight";
export function BossTestingRoom({ definition, onReady }: { readonly definition: DungeonDefinition; readonly onReady?: () => void }) {
const { min, max } = definition.bounds;
const x = (min[0] + max[0]) / 2;
const z = (min[2] + max[2]) / 2;
const y = definition.bosses[0].position[1];
const width = max[0] - min[0];
const depth = max[2] - min[2];
useEffect(() => {
onReady?.();
}, [onReady]);
return <group name="boss-testing-room">
<RigidBody type="fixed" colliders="cuboid" collisionGroups={DUNGEON_PHYSICAL_COLLISION_GROUPS} position={[x, y - 0.5, z]}>
<mesh receiveShadow><boxGeometry args={[width, 1, depth]} /><meshStandardMaterial color="#535c68" roughness={0.9} /></mesh>
</RigidBody>
{([
[min[0] - 0.5, y + 4, z, 1, 8, depth + 2], [max[0] + 0.5, y + 4, z, 1, 8, depth + 2],
[x, y + 4, min[2] - 0.5, width, 8, 1], [x, y + 4, max[2] + 0.5, width, 8, 1],
] as const).map(([px, py, pz, w, h, d], index) =>
<RigidBody key={index} type="fixed" colliders="cuboid" collisionGroups={DUNGEON_WALL_COLLISION_GROUPS} position={[px, py, pz]}>
<mesh receiveShadow><boxGeometry args={[w, h, d]} /><meshStandardMaterial color="#293441" roughness={0.95} /></mesh>
</RigidBody>
)}
<gridHelper args={[Math.max(width, depth), Math.ceil(Math.max(width, depth) / 5), "#8794a5", "#626d7b"]} position={[x, y + 0.015, z]} />
</group>;
}
+5 -1
View File
@@ -5,10 +5,12 @@ import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { useGameStore } from "../game/store";
import { ProductionDungeon } from "./ProductionDungeon";
import { PrototypeDungeon } from "./PrototypeDungeon";
import { BossTestingRoom } from "./BossTestingRoom";
export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void }) {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const gameMode = useGameStore((state) => state.gameMode);
const bossTest = useGameStore((state) => state.bossTest);
const sessionRevision = useGameStore((state) => state.sessionRevision);
const definition = requireDungeonDefinition(activeDungeonId);
const manastormPackage = gameMode === "manastorm"
@@ -31,6 +33,7 @@ export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void
&& assets.collision.length > 0;
useEffect(() => {
if (bossTest) return;
if (hasProductionAssets) return;
const missing = [
...definition.assets.visual.filter((_, index) => !assets.visual[index]).map((asset) => asset.fileName),
@@ -38,8 +41,9 @@ export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void
];
useGameStore.getState().setAssetStatus("proxy", `Waiting for ${missing.join(" and ") || "generated dungeon assets"}.`);
onReady?.();
}, [assets.collision, assets.visual, definition, hasProductionAssets, onReady]);
}, [assets.collision, assets.visual, bossTest, definition, hasProductionAssets, onReady]);
if (bossTest) return <BossTestingRoom definition={definition} onReady={onReady} />;
if (!hasProductionAssets) return <PrototypeDungeon definition={definition} />;
return (
<Suspense fallback={null}>
+4 -1
View File
@@ -47,6 +47,7 @@ import { combatHasLineOfSight } from "../game/combatSpatial";
import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset";
import { scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling";
import { dungeonEnemyScaling } from "../game/dungeonBossScaling";
import { contentCategoryForClass } from "../app/characterCatalog";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { stepFearedMobAway } from "../game/mobMotion";
import {
@@ -891,6 +892,7 @@ function ProxyMob({
const difficultyId = useGameStore((state) => state.activeDifficultyId);
const bossDifficulty = useGameStore((state) => state.activeBossDifficulty);
const characterLevel = useCombatStore((state) => state.level);
const characterSource = useCombatStore((state) => contentCategoryForClass(state.classId));
const manastormLevel = useManastormStore((state) => state.level);
const manastormPartySize = useManastormStore((state) => state.partySize);
const manastormModeId = useManastormStore((state) => state.modeId);
@@ -933,7 +935,7 @@ function ProxyMob({
? definition.combat?.level
: scaledDungeonEnemyLevel(characterLevel, boss);
const scaling = gameMode === "dungeon"
? dungeonEnemyScaling(dungeon, definition, instanceId, characterLevel, difficultyId, bossDifficulty)
? dungeonEnemyScaling(dungeon, definition, instanceId, characterLevel, difficultyId, bossDifficulty, characterSource)
: null;
const healthMultiplier = scaling?.healthMultiplier ?? baseHealthMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1);
useCombatStore.getState().registerMob(instanceId, {
@@ -965,6 +967,7 @@ function ProxyMob({
}, [
boss,
characterLevel,
characterSource,
definition.hasLoot,
definition.id,
definition.name,
+30 -3
View File
@@ -28,6 +28,30 @@ html, body, #root {
body { color: var(--ink); }
button { font: inherit; }
.ability-tooltip {
position: fixed;
z-index: 10000;
width: min(340px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
overflow: hidden;
padding: 12px;
border: 1px solid var(--gold);
border-radius: 8px 3px;
background: var(--panel-solid);
box-shadow: var(--shadow);
color: var(--ink);
font-size: 12px;
line-height: 1.5;
text-align: left;
pointer-events: none;
}
.ability-tooltip header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.ability-tooltip header strong { color: var(--gold); font: 600 16px Georgia, serif; }
.ability-tooltip kbd { color: var(--muted); font-size: 10px; }
.ability-tooltip p { margin: 8px 0; white-space: pre-line; overflow-wrap: anywhere; }
.ability-tooltip footer { display: flex; flex-wrap: wrap; gap: 4px 12px; padding-top: 8px; border-top: 1px solid var(--edge); color: var(--moss-bright); font-size: 11px; }
.ability-tooltip .ability-tooltip__lock { margin-bottom: 0; color: var(--gold); }
.game-shell, .scene-shell {
position: relative;
width: 100%;
@@ -1278,7 +1302,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.spellbook-panel__progress { display: grid; grid-template-columns: auto auto 1px auto auto; align-items: baseline; gap: 4px; color: var(--muted); font-size: 7px; text-transform: uppercase; }
.spellbook-panel__progress strong { color: #e7d491; font: 700 14px Georgia, serif; }
.spellbook-panel__progress i { width: 1px; height: 19px; margin: 0 5px; background: rgba(216,226,194,.15); }
.spellbook-panel__toolbar { display: flex; justify-content: flex-end; margin-top: 10px; }
.spellbook-panel__toolbar { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 10px; }
.spellbook-panel__filter { display: flex; min-height: 28px; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid rgba(190,210,157,.17); border-radius: 999px; background: rgba(152,181,122,.05); color: var(--muted); font-size: 7px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; cursor: pointer; }
.spellbook-panel__filter:hover { border-color: rgba(190,210,157,.32); background: rgba(152,181,122,.11); color: var(--ink); }
.spellbook-panel__filter > span { position: relative; width: 22px; height: 12px; border: 1px solid rgba(190,210,157,.26); border-radius: 999px; background: rgba(0,0,0,.26); }
@@ -1457,6 +1481,9 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.ability-bindings__choices button { display: grid; width: 100%; min-height: 47px; grid-template-columns: 35px minmax(0, 1fr); align-items: center; gap: 7px; padding: 6px; border: 1px solid rgba(194,213,164,.13); border-radius: 7px; background: rgba(255,255,255,.025); color: var(--ink); cursor: pointer; text-align: left; }
.ability-bindings__choices button:hover { background: rgba(138,171,104,.1); }
.ability-bindings__choices button.is-current { border-color: rgba(151,199,108,.34); background: rgba(101,151,72,.12); }
.spellbook-ability.is-missing-from-bars, .ability-bindings__choices button.is-missing-from-bars { border-color: #92d5ff; box-shadow: inset 0 0 0 1px rgba(146,213,255,.22), 0 0 6px rgba(146,213,255,.13); }
.is-missing-from-bars :is(.spellbook-ability__icon, .ability-bindings__icon) { border-color: #92d5ff; }
.action-bar-missing-legend, .ability-bindings__picker > p.action-bar-missing-legend { color: #92d5ff; font-size: 9px; line-height: 1.4; }
.ability-bindings__choices button.is-locked { filter: grayscale(.85); opacity: .65; }
.ability-bindings__choices button.is-danger { border-color: rgba(202,111,87,.32); background: rgba(138,48,34,.14); }
.ability-bindings__choices button > span:last-child { min-width: 0; }
@@ -2233,7 +2260,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 +3799,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; }
+35 -11
View File
@@ -10,9 +10,9 @@ 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 { actionKeyLabel, assignedActionBarAbilityIds, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller";
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
@@ -20,6 +20,7 @@ import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import { AbilityTooltipTarget, type AbilityTooltipData } from "./AbilityTooltip";
function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: string; color: string }) {
const [failed, setFailed] = useState(false);
@@ -97,7 +98,7 @@ const MAIN_ACTION_NEIGHBORS: Readonly<Record<string, NonNullable<MenuAction["nei
},
};
function AbilityBindingsDialog() {
function AbilityBindingsDialog({ onClose }: { onClose?: () => void }) {
const panel = useRef<HTMLElement>(null);
const keyLabel = useInputBindingLabels();
const bindingChord = (layer: ActionBindingLayer, control: ActionBindingControl) => `${keyLabel("controller", `ability:${layer}:${control}`)} / ${keyLabel("pc", `ability:${layer}:${control}`)}`;
@@ -106,6 +107,19 @@ function AbilityBindingsDialog() {
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
const level = useCombatStore((state) => state.level);
const abilities = useCombatStore((state) => state.abilities);
const talentRanks = useCombatStore((state) => state.talentRanks);
const tooltipData = (definition: AbilityDefinition | null | undefined): AbilityTooltipData | null => {
if (!definition) return null;
const talentRank = definition.talentEntryId === undefined ? undefined : talentRanks[`coa-entry-${definition.talentEntryId}`] ?? 0;
const ability = abilityAtLevel(definition, level, talentRank);
return {
name: ability.name, description: ability.description, target: ability.target,
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000,
range: ability.range.max, lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
talentLocked: ability.talentEntryId !== undefined && !talentRank,
};
};
const bindings = useCombatStore((state) => state.actionBindings);
const pcLayout = useUiLayoutStore((state) => state.preferences.current.pc);
const [layout, setLayout] = useState<BindingLayout>(() => currentUiDevice() === "pc" && pcLayout.wowBars ? "wow" : "controller");
@@ -116,6 +130,10 @@ function AbilityBindingsDialog() {
const [notice, setNotice] = useState("Choose a button, then choose the ability it should use.");
const classColor = classById(classId).color;
const isWow = layout === "wow";
const assignedIds = assignedActionBarAbilityIds(pcLayout, bindings, isWow);
const isMissing = (ability: AbilityDefinition) => ability.unlockLevel <= level
&& (ability.talentEntryId === undefined || Boolean(talentRanks[`coa-entry-${ability.talentEntryId}`]))
&& !assignedIds.has(ability.id);
const assignment = (control: BindingControl) => typeof control === "number"
? pcLayout.bars[bar].slots[control].abilityId : bindings[actionBindingId(layer, control)];
const controlLabel = (control: BindingControl) => typeof control === "number"
@@ -133,7 +151,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 +353,7 @@ function AbilityBindingsDialog() {
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
const locked = Boolean(ability && ability.unlockLevel > level);
return (
<AbilityTooltipTarget key={control} ability={tooltipData(ability)} bindingLabel={bindingChord(layer, control)}>
<ControllerButton
key={control}
controlId={slotControlId(control)}
@@ -352,6 +371,7 @@ function AbilityBindingsDialog() {
: <span className="ability-bindings__empty" aria-hidden="true">+</span>}
<span><strong>{definition.label}</strong><small>{locked ? `${ability!.name} · Level ${ability!.unlockLevel}` : ability?.name ?? "Empty"}</small></span>
</ControllerButton>
</AbilityTooltipTarget>
);
};
@@ -363,7 +383,7 @@ function AbilityBindingsDialog() {
<p className="eyebrow">{isWow ? "WoW loadout · 5 bars · 60 customizable slots" : "Controller loadout · 24 customizable slots"}</p>
<h2 id="ability-bindings-title">Ability controls</h2>
</div>
<button type="button" className="icon-button" onClick={close} aria-label="Return to pause menu">×</button>
<button type="button" className="icon-button" onClick={close} aria-label="Close button assignments">×</button>
</header>
<nav className="ability-bindings__layouts" aria-label="Action bar style">
@@ -407,14 +427,15 @@ function AbilityBindingsDialog() {
const ability = abilities.find((candidate) => candidate.id === slot.abilityId);
const locked = Boolean(ability && ability.unlockLevel > level);
const name = ability?.name ?? (slot.abilityId ? "Unavailable for this character" : "Empty");
return <ControllerButton key={index} controlId={slotControlId(index)} selectedId={controller.selectedId} select={controller.select}
return <AbilityTooltipTarget key={index} ability={tooltipData(ability)} bindingLabel={controlLabel(index)}>
<ControllerButton controlId={slotControlId(index)} selectedId={controller.selectedId} select={controller.select}
type="button" className={`ability-binding-slot ${locked ? "is-locked" : ""} ${editingControl === index ? "is-editing" : ""}`}
aria-label={`${controlLabel(index)}: ${name}${locked ? `, unlocks at level ${ability!.unlockLevel}` : ""}`}
aria-pressed={editingControl === index} title={`${controlLabel(index)}: ${name}`} onClick={() => beginEditing(index)}>
<kbd>{actionKeyLabel(slot.key) || "—"}</kbd>
{ability ? <AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} /> : <span className="ability-bindings__empty" aria-hidden="true">+</span>}
<span><strong>Button {index + 1}</strong><small>{locked ? `${name} · Level ${ability!.unlockLevel}` : name}</small></span>
</ControllerButton>;
</ControllerButton></AbilityTooltipTarget>;
})}
</div>
<p className="ability-bindings__wow-note">{!pcLayout.wowBars ? "Enable WoW action bars in UI layout to use these assignments on PC." : bar >= pcLayout.barCount ? "This bar is inactive. Increase the visible bar count in UI layout to use it." : "Choose a button to change its ability. Changes save immediately."}</p>
@@ -473,12 +494,15 @@ function AbilityBindingsDialog() {
<>
<p className="eyebrow">Assign {controlLabel(editingControl)}</p>
<h3>Choose an ability</h3>
<p className="action-bar-missing-legend">Light blue: missing from action bars</p>
<div className="ability-bindings__choices">
{pickerAbilities.map((ability) => (
<ControllerButton key={ability.id} controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""}`} onClick={() => assign(ability.id)}>
<AbilityTooltipTarget key={ability.id} ability={tooltipData(ability)}>
<ControllerButton controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""} ${isMissing(ability) ? "is-missing-from-bars" : ""}`} aria-label={`${ability.name}${isMissing(ability) ? ", missing from action bars" : ""}`} onClick={() => assign(ability.id)}>
<AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} />
<span><strong>{ability.name}</strong><small>{ability.unlockLevel > level ? `Unlocks at level ${ability.unlockLevel}` : ability.id === editingAbilityId ? "Current assignment" : ability.target === "friendly" ? "Friendly / self" : ability.target}</small></span>
</ControllerButton>
</AbilityTooltipTarget>
))}
<ControllerButton controlId="binding-choice-empty" selectedId={controller.selectedId} select={controller.select} type="button" className={editingAbilityId === null ? "is-current" : ""} onClick={() => assign(null)}>
<span className="ability-bindings__empty" aria-hidden="true">×</span>
@@ -507,7 +531,7 @@ function AbilityBindingsDialog() {
);
}
export function AbilityBindingsPanel() {
const open = useGameStore((state) => state.overlay === "bindings");
return open ? <AbilityBindingsDialog /> : null;
export function AbilityBindingsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
const storeOpen = useGameStore((state) => state.overlay === "bindings");
return (open ?? storeOpen) ? <AbilityBindingsDialog onClose={onClose} /> : null;
}
+74
View File
@@ -0,0 +1,74 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import {
AbilityTooltipContent, AbilityTooltipTarget, abilityTooltipFacts, abilityTooltipPosition,
type AbilityTooltipData,
} from "./AbilityTooltip";
const ability: AbilityTooltipData = {
name: "Healing Wave", description: "Heals a friendly target for 120 health.",
resourceCost: 15, resourceLabel: "mana", cooldownSeconds: 6,
castSeconds: 1.5, range: 40, target: "friendly",
};
describe("ability tooltips", () => {
it("shows the ability's description, binding, and gameplay facts", () => {
const markup = renderToStaticMarkup(createElement(AbilityTooltipContent, { ability, bindingLabel: "Shift+1" }));
for (const text of [ability.name, ability.description!, "Shift+1", "15 mana", "1.5 sec cast", "6 sec cooldown", "40 m range", "Selected ally or self"]) {
expect(markup).toContain(text);
}
});
it("explains free, instant abilities and distinguishes no cooldown from missing data", () => {
expect(abilityTooltipFacts({ name: "Free", resourceCost: 0, castSeconds: 0, cooldownSeconds: 0 }))
.toEqual(["No cost", "Instant", "No cooldown"]);
expect(abilityTooltipFacts({ name: "Unknown" })).toEqual([]);
});
it("shows unlock and talent requirements without hiding the description", () => {
const markup = renderToStaticMarkup(createElement(AbilityTooltipContent, {
ability: { ...ability, lockedUntilLevel: 20, talentLocked: true },
}));
expect(markup).toContain(ability.description);
expect(markup).toContain("Unlocks at level 20");
expect(markup).toContain("Learn in talents");
});
it("does not present active cast facts for passive abilities", () => {
expect(abilityTooltipFacts({ ...ability, passive: true, rank: 2 })).toEqual(["Passive", "Rank 2"]);
});
it("preserves inspectable unavailable controls and creates no layout wrapper", () => {
const markup = renderToStaticMarkup(createElement(AbilityTooltipTarget, {
ability,
children: createElement("button", { "aria-disabled": true, title: "Old browser tooltip" }, "Healing Wave"),
}));
expect(markup).toBe('<button aria-disabled="true">Healing Wave</button>');
});
it("keeps the empty-slot browser hint without creating an ability tooltip", () => {
const markup = renderToStaticMarkup(createElement(AbilityTooltipTarget, {
ability: null,
children: createElement("button", { title: "Empty binding" }, "Empty"),
}));
expect(markup).toBe('<button title="Empty binding">Empty</button>');
});
it("places a bottom-bar tooltip above its control", () => {
expect(abilityTooltipPosition({ left: 400, right: 440, top: 700, bottom: 740 }, { width: 300, height: 180 }, { width: 1000, height: 800 }))
.toEqual({ left: 270, top: 512 });
});
it("flips below top-edge controls and clamps to either horizontal edge", () => {
expect(abilityTooltipPosition({ left: 0, right: 40, top: 20, bottom: 60 }, { width: 300, height: 180 }, { width: 1000, height: 800 }))
.toEqual({ left: 12, top: 68 });
expect(abilityTooltipPosition({ left: 960, right: 1000, top: 20, bottom: 60 }, { width: 300, height: 180 }, { width: 1000, height: 800 }))
.toEqual({ left: 688, top: 68 });
});
it("keeps a tall tooltip inside a narrow viewport", () => {
expect(abilityTooltipPosition({ left: 200, right: 240, top: 120, bottom: 160 }, { width: 296, height: 400 }, { width: 320, height: 480 }))
.toEqual({ left: 12, top: 68 });
});
});
+146
View File
@@ -0,0 +1,146 @@
import {
cloneElement, useEffect, useId, useLayoutEffect, useRef, useState,
type ButtonHTMLAttributes, type ReactElement,
} from "react";
import { createPortal } from "react-dom";
import type { AbilityTarget } from "../game/abilityCatalog";
const INSPECT_EVENT = "healer-man:ability-tooltip-inspect";
export interface AbilityTooltipData {
name: string;
description?: string;
resourceCost?: number;
resourceLabel?: string;
cooldownSeconds?: number;
castSeconds?: number;
range?: number;
target?: AbilityTarget;
passive?: boolean;
rank?: number;
lockedUntilLevel?: number;
talentLocked?: boolean;
}
export function abilityTooltipFacts(ability: AbilityTooltipData): string[] {
const facts: string[] = [];
if (ability.passive) facts.push("Passive");
if (ability.rank !== undefined) facts.push(`Rank ${ability.rank}`);
if (!ability.passive) {
if (ability.resourceCost !== undefined) facts.push(ability.resourceCost > 0
? `${Number(ability.resourceCost.toFixed(2))} ${ability.resourceLabel ?? "resource"}` : "No cost");
if (ability.castSeconds !== undefined) facts.push(ability.castSeconds > 0 ? `${ability.castSeconds} sec cast` : "Instant");
if (ability.cooldownSeconds !== undefined) facts.push(ability.cooldownSeconds > 0 ? `${ability.cooldownSeconds} sec cooldown` : "No cooldown");
if (ability.range !== undefined && ability.range > 0) facts.push(`${ability.range} m range`);
if (ability.target) facts.push({ friendly: "Selected ally or self", hostile: "Enemy target", any: "Enemy or ally", self: "Self" }[ability.target]);
}
return facts;
}
export function abilityTooltipPosition(
anchor: { left: number; right: number; top: number; bottom: number },
size: { width: number; height: number },
viewport: { width: number; height: number },
): { left: number; top: number } {
const margin = 12;
const gap = 8;
const clamp = (value: number, maximum: number) => Math.max(margin, Math.min(value, maximum - margin));
const above = anchor.top - size.height - gap;
return {
left: clamp((anchor.left + anchor.right - size.width) / 2, viewport.width - size.width),
top: clamp(above >= margin ? above : anchor.bottom + gap, viewport.height - size.height),
};
}
export function AbilityTooltipContent({ ability, bindingLabel }: { ability: AbilityTooltipData; bindingLabel?: string }) {
return <>
<header><strong>{ability.name}</strong>{bindingLabel && <kbd>{bindingLabel}</kbd>}</header>
<p>{ability.description || "No description available for this ability."}</p>
<footer>{abilityTooltipFacts(ability).map((fact) => <span key={fact}>{fact}</span>)}</footer>
{ability.lockedUntilLevel !== undefined && <p className="ability-tooltip__lock">Unlocks at level {ability.lockedUntilLevel}</p>}
{ability.talentLocked && <p className="ability-tooltip__lock">Learn in talents</p>}
</>;
}
/** Adds inspection without a layout wrapper or changing the control's activation behavior. */
export function AbilityTooltipTarget({ ability, bindingLabel, children }: {
ability: AbilityTooltipData | null | undefined;
bindingLabel?: string;
children: ReactElement<ButtonHTMLAttributes<HTMLButtonElement>>;
}) {
const id = useId();
const [anchor, setAnchor] = useState<HTMLButtonElement | null>(null);
const [position, setPosition] = useState({ left: 12, top: 12 });
const tooltip = useRef<HTMLDivElement>(null);
const hovering = useRef(false);
const keyboardFocused = useRef(false);
const visible = Boolean(anchor && ability);
const inspect = (element: HTMLButtonElement) => {
window.dispatchEvent(new CustomEvent(INSPECT_EVENT, { detail: id }));
setAnchor(element);
};
useLayoutEffect(() => {
if (!anchor || !tooltip.current || !ability) return;
setPosition(abilityTooltipPosition(anchor.getBoundingClientRect(), tooltip.current.getBoundingClientRect(), {
width: window.innerWidth, height: window.innerHeight,
}));
}, [anchor, ability, bindingLabel]);
useEffect(() => {
if (!visible) return;
const dismiss = () => {
hovering.current = false;
keyboardFocused.current = false;
setAnchor(null);
};
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") dismiss(); };
const onInspect = (event: Event) => { if ((event as CustomEvent<string>).detail !== id) dismiss(); };
window.addEventListener(INSPECT_EVENT, onInspect);
window.addEventListener("keydown", onKey);
window.addEventListener("blur", dismiss);
window.addEventListener("resize", dismiss);
window.addEventListener("scroll", dismiss, true);
return () => {
window.removeEventListener(INSPECT_EVENT, onInspect);
window.removeEventListener("keydown", onKey);
window.removeEventListener("blur", dismiss);
window.removeEventListener("resize", dismiss);
window.removeEventListener("scroll", dismiss, true);
};
}, [visible, id]);
const original = children.props;
return <>
{cloneElement(children, {
title: ability ? undefined : original.title,
"aria-describedby": [original["aria-describedby"], visible ? id : undefined].filter(Boolean).join(" ") || undefined,
onPointerEnter: (event) => {
original.onPointerEnter?.(event);
if (event.pointerType === "touch" || !ability) return;
hovering.current = true;
inspect(event.currentTarget);
},
onPointerLeave: (event) => {
original.onPointerLeave?.(event);
hovering.current = false;
if (!keyboardFocused.current) setAnchor(null);
},
onFocus: (event) => {
original.onFocus?.(event);
keyboardFocused.current = event.currentTarget.matches(":focus-visible");
if (keyboardFocused.current && ability) inspect(event.currentTarget);
},
onBlur: (event) => {
original.onBlur?.(event);
keyboardFocused.current = false;
if (!hovering.current) setAnchor(null);
},
})}
{visible && createPortal(
<div ref={tooltip} id={id} role="tooltip" className="ability-tooltip" style={position}>
<AbilityTooltipContent ability={ability!} bindingLabel={bindingLabel} />
</div>, document.body,
)}
</>;
}
+7
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, type CSSProperties } from "react";
import type { AbilityTarget } from "../game/abilityCatalog";
import { AbilityTooltipTarget } from "./AbilityTooltip";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import {
ACTION_LAYER_LABELS,
@@ -21,6 +22,10 @@ export interface ActionBarItem {
disabled?: boolean;
lockedUntilLevel?: number;
target?: AbilityTarget;
cooldownSeconds?: number;
castSeconds?: number;
range?: number;
talentLocked?: boolean;
}
export type ActionBarItems = Readonly<Record<ActionBindingControl, ActionBarItem | null>>;
@@ -94,6 +99,7 @@ function ActionSlot({
: "";
return (
<AbilityTooltipTarget ability={item} bindingLabel={chord}>
<button
type="button"
className={`action-slot action-slot--${control} ${item ? "" : "action-slot--empty"} ${unavailable ? "action-slot--disabled" : ""} ${item?.target === "friendly" || item?.target === "any" ? "action-slot--friendly" : ""} ${cooldown > 0 ? "action-slot--cooldown" : ""}`}
@@ -124,6 +130,7 @@ function ActionSlot({
<em>{item!.cooldownRemaining! < 10 ? item!.cooldownRemaining!.toFixed(1) : Math.ceil(item!.cooldownRemaining!)}</em>
)}
</button>
</AbilityTooltipTarget>
);
}
+6 -3
View File
@@ -8,6 +8,7 @@ import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { ActionBar, ActionIcon, type ActionBarItem, type ActionBarItems } from "./ActionBar";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useArenaStore } from "../game/arenaStore";
import { AbilityTooltipTarget } from "./AbilityTooltip";
const PC_ACTION_CONTROLS = ["face-bottom", "face-left", "face-top", "face-right", "r1", "r2", "dpad-left", "dpad-right"] as const;
@@ -38,6 +39,7 @@ export function ArenaActionBar({ character, actor, layer, inputMode, blocked, on
resourceCost: ability.cost.amount, resourceLabel: kit.resource.name,
cooldownDuration: Math.max(ability.cooldownMs, ability.gcdMs) / 1000,
cooldownRemaining: remaining,
cooldownSeconds: ability.cooldownMs / 1000, castSeconds: ability.castTimeMs / 1000, range: ability.range.max,
disabled: blocked || !actor?.alive || !learned || remaining > 0 || actor.effects.some((effect) => effect.id.startsWith("casting:")),
lockedUntilLevel: learned ? undefined : ability.unlockLevel,
}];
@@ -65,12 +67,13 @@ export function ArenaActionBar({ character, actor, layer, inputMode, blocked, on
{desktopBars.map((bar, index) => <section key={index} aria-label={`Action bar ${index + 1}`} style={{ gridTemplateColumns: `repeat(${bar.columns}, minmax(0, 1fr))` }}>
{bar.slots.map((slot, slotIndex) => {
const item = slot.abilityId ? items[slot.abilityId] : null;
return <button type="button" key={slotIndex} disabled={!item || item.disabled}
return <AbilityTooltipTarget key={slotIndex} ability={item} bindingLabel={slot.label}>
<button type="button" aria-disabled={!item || item.disabled}
aria-label={`${item?.name ?? "Empty"}, ${slot.label}`} title={item?.description ?? "Empty binding"}
onClick={() => item && onCast(item.id)}>
onClick={() => item && !item.disabled && onCast(item.id)}>
<kbd>{slot.label}</kbd><ActionIcon item={item ?? null} /><span>{item?.name ?? "Empty"}</span>
{item && (item.cooldownRemaining ?? 0) > 0 && <em>{item.cooldownRemaining!.toFixed(1)}</em>}
</button>;
</button></AbilityTooltipTarget>;
})}
</section>)}
</nav>
+2 -1
View File
@@ -8,12 +8,13 @@ describe("boss card display", () => {
it("shows per-rarity copy totals and every dungeon for the visible boss", () => {
const boss = BOSS_CARD_CATALOG[0];
const markup = renderToStaticMarkup(createElement(BossCardCollection, {
onClose: () => {}, characterName: "Collector",
onClose: () => {},
collection: { counts: { [boss.key]: { normal: 7, rare: 1 } }, lastClaims: {} },
}));
expect(markup).toContain("7 copies");
expect(markup).toContain("1 copy");
expect(markup).toContain("8 total copies");
expect(markup).toContain("Account-wide collection log");
expect(markup).toContain("Group by dungeon");
expect(markup).toContain("Group by rarity");
for (const dungeon of boss.dungeons) expect(markup).toContain(dungeon.title);
+6 -13
View File
@@ -1,9 +1,8 @@
import { lazy, Suspense, useMemo, useState, type CSSProperties } from "react";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore";
import { useShellStore } from "../app/shellStore";
import { useGameStore } from "../game/store";
import { BOSS_CARD_CATALOG, groupBossCards, type BossCardGrouping } from "../game/bossCardCatalog";
import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, emptyBossCardCollection, type BossCardCollection as Collection } from "../game/bossCards";
import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, type BossCardCollection as Collection } from "../game/bossCards";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import { dungeonContentIsUsable } from "../content/contentManager";
@@ -11,13 +10,11 @@ import { useContentManagerSnapshot } from "../content/useContentManager";
import "./bossCards.css";
const BossCardPortrait = lazy(() => import("./BossCardPortrait"));
const EMPTY_COLLECTION = emptyBossCardCollection();
const PAGE_SIZE = 8;
export function BossCardCollection({ onClose, collection, characterName }: {
export function BossCardCollection({ onClose, collection }: {
readonly onClose: () => void;
readonly collection: Collection;
readonly characterName: string;
}) {
useContentManagerSnapshot();
const [earnedOnly, setEarnedOnly] = useState(false);
@@ -52,7 +49,7 @@ export function BossCardCollection({ onClose, collection, characterName }: {
return <div className="modal-backdrop boss-collection-backdrop" role="presentation" onPointerDown={(event) => event.stopPropagation()}>
<section className="boss-collection" role="dialog" aria-modal="true" aria-labelledby="boss-collection-title">
<header className="boss-collection__header">
<div><p className="eyebrow">{characterName}’s collection log</p><h2 id="boss-collection-title">Boss Cards</h2></div>
<div><p className="eyebrow">Account-wide collection log</p><h2 id="boss-collection-title">Boss Cards</h2></div>
<ControllerButton {...controlProps} controlId="cards-close" className="icon-button" onClick={onClose} aria-label="Close boss card collection">×</ControllerButton>
</header>
<div className="boss-collection__summary">
@@ -100,11 +97,7 @@ export function BossCardCollection({ onClose, collection, characterName }: {
}
export default function BossCardCollectionScreen({ onClose }: { readonly onClose?: () => void }) {
const savedCharacter = useShellStore(selectedCharacter);
const activeCharacter = useShellStore((state) => state.activeCharacter);
const phase = useShellStore((state) => state.phase);
const liveCollection = useCombatStore((state) => state.bossCards);
const character = activeCharacter ?? savedCharacter;
const collection = useShellStore((state) => state.bossCards);
return <BossCardCollection onClose={onClose ?? (() => useGameStore.getState().closeOverlay())}
collection={phase === "game" ? liveCollection : character?.bossCards ?? EMPTY_COLLECTION} characterName={character?.name ?? "Adventurer"} />;
collection={collection} />;
}
+3 -2
View File
@@ -10,11 +10,12 @@ const GUIDE_CATALOG = buildBossGuideCatalog(DUNGEON_DEFINITIONS, BOSS_GUIDE_OVER
function BossGuideDialog() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const bossTest = useGameStore((state) => state.bossTest);
const openOverlay = useGameStore((state) => state.openOverlay);
const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === activeDungeonId) ?? GUIDE_CATALOG[0];
const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === (bossTest?.sourceDungeonId ?? activeDungeonId)) ?? GUIDE_CATALOG[0];
const [selectedDungeonId, setSelectedDungeonId] = useState(initialDungeon?.id ?? "");
const selectedDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon;
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses[0]?.id ?? "");
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses.find((boss) => boss.entityId === bossTest?.entityId)?.id ?? selectedDungeon?.bosses[0]?.id ?? "");
const selectedBoss = selectedDungeon?.bosses.find((boss) => boss.id === selectedBossId)
?? selectedDungeon?.bosses[0];
const close = () => openOverlay("pause");
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from "react";
import { useShellStore } from "../app/shellStore";
import { BOSS_TESTING_STATUSES, BOSS_TESTING_STATUS_LABELS, useBossTestingReviews, type BossTestingStatus } from "../app/bossTestingReviews";
import { bossTestIsDefeated } from "../game/BossTestingBridge";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import "./bossTesting.css";
export function BossTestingReviewPanel() {
const boss = useGameStore((state) => state.bossTest);
const session = useShellStore((state) => state.session);
const review = useBossTestingReviews((state) => boss ? state.reviews[boss.key] : undefined);
const save = useBossTestingReviews((state) => state.save);
const refresh = useBossTestingReviews((state) => state.refresh);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const busy = useRef(false);
const mark = async (status: BossTestingStatus) => {
if (!boss || busy.current) return;
busy.current = true; setSaving(true); setMessage("");
const synced = await save(boss, status, session);
setMessage(synced ? `Marked ${BOSS_TESTING_STATUS_LABELS[status].toLowerCase()}.` : "Saved on this device. This label will sync when you reconnect or reopen Boss Testing.");
busy.current = false; setSaving(false);
};
useEffect(() => {
void refresh(session);
const reconnect = () => void refresh(session);
window.addEventListener("online", reconnect);
return () => window.removeEventListener("online", reconnect);
}, [refresh, session]);
const defeated = bossTestIsDefeated();
const back = () => { if (!busy.current) useGameStore.getState().openOverlay("pause"); };
const retry = () => useGameStore.getState().resetAtEntrance();
const leave = () => useShellStore.getState().returnToMainMenu();
const actions: MenuAction[] = [
...BOSS_TESTING_STATUSES.map((status) => ({ id: `boss-review-${status}`, run: () => void mark(status), enabled: !saving })),
{ id: "boss-review-report", run: () => useGameStore.getState().openOverlay("report-issue"), enabled: !saving },
{ id: "boss-review-retry", run: retry, enabled: !saving },
{ id: "boss-review-leave", run: leave, enabled: !saving },
{ id: "boss-review-back", run: back, enabled: !saving },
];
const controller = useMenuController(actions, { initialId: "boss-review-needs-testing", onBack: back });
if (!boss) return null;
const button = (controlId: string, label: string, run: () => void, pressed?: boolean) =>
<ControllerButton controlId={controlId} selectedId={controller.selectedId} select={controller.select}
type="button" className="button" onClick={run} disabled={saving} aria-pressed={pressed}>{label}</ControllerButton>;
return <div className="modal-backdrop" role="presentation">
<section className="pause-menu boss-testing-review" role="dialog" aria-modal="true" aria-labelledby="boss-review-title">
<p className="eyebrow">{defeated ? "Boss defeated" : "Boss Testing paused"}</p><h2 id="boss-review-title">{boss.name}</h2>
<p>Current readiness: <strong>{BOSS_TESTING_STATUS_LABELS[review?.status ?? "needs-testing"]}</strong></p>
<div className="boss-testing-actions" aria-label="Boss readiness">{BOSS_TESTING_STATUSES.map((status) =>
<span key={status}>{button(`boss-review-${status}`, `Mark ${BOSS_TESTING_STATUS_LABELS[status].toLowerCase()}`, () => void mark(status), review?.status === status)}</span>
)}</div>
{message && <p role="status">{message}</p>}
<div className="pause-menu__actions">
{button("boss-review-report", "Submit a boss report", () => useGameStore.getState().openOverlay("report-issue"))}
{button("boss-review-retry", "Fight this boss again", retry)}
{button("boss-review-leave", "Return to main menu", leave)}
{button("boss-review-back", "Back to pause menu", back)}
</div>
</section>
</div>;
}
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { enterBossTesting, prepareBossTesting } from "../app/bossTestingRuntime";
import { BOSS_TESTING_STATUS_LABELS, useBossTestingReviews } from "../app/bossTestingReviews";
import { BOSS_TESTING_CATALOG, randomTestingBoss, type TestableBoss } from "../game/bossTesting";
import { defaultPartyRoleForClass, partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyRoles";
import { specializationById } from "../game/specializationCatalog";
import { useContentManagerSnapshot } from "../content/useContentManager";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { FrontSurface, BrandMark, ControllerLegend } from "./FrontSurface";
import { ControllerButton } from "./ControllerButton";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import "./bossTesting.css";
export function BossTestingScreen({ onClose }: { readonly onClose: () => void }) {
const character = useShellStore(selectedCharacter);
const session = useShellStore((state) => state.session);
const reviews = useBossTestingReviews((state) => state.reviews);
const refresh = useBossTestingReviews((state) => state.refresh);
const content = useContentManagerSnapshot();
const [query, setQuery] = useState("");
const [category, setCategory] = useState("all");
const [status, setStatus] = useState("all");
const [selectedKey, setSelectedKey] = useState("");
const [preparing, setPreparing] = useState(false);
const [prepared, setPrepared] = useState(false);
const [error, setError] = useState("");
const [role, setRole] = useState<PartyRole>(character ? defaultPartyRoleForClass(character.classId) : "damage");
const busy = useRef(false);
const alive = useRef(true);
const bosses = useMemo(() => BOSS_TESTING_CATALOG.filter((boss) =>
(category === "all" || boss.category === category)
&& (status === "all" || (reviews[boss.key]?.status ?? "needs-testing") === status)
&& `${boss.name} ${boss.dungeonTitle}`.toLowerCase().includes(query.trim().toLowerCase())
), [category, query, reviews, status]);
const selected = BOSS_TESTING_CATALOG.find((boss) => boss.key === selectedKey);
const specialization = specializationById(character?.specializationId);
const roles = character ? specialization ? [specialization.role] : partyRolesForClass(character.classId) : [];
const effectiveRole = specialization?.role ?? role;
useEffect(() => {
alive.current = true;
void refresh(session);
const reconnect = () => void refresh(session);
window.addEventListener("online", reconnect);
return () => { alive.current = false; window.removeEventListener("online", reconnect); };
}, [refresh, session]);
const choose = async (boss: TestableBoss, start = false) => {
if (busy.current || !character) return;
busy.current = true;
setSelectedKey(boss.key); setPreparing(true); setPrepared(false); setError("");
try {
await prepareBossTesting(boss);
if (!alive.current) return;
setPrepared(true);
if (start) enterBossTesting(boss, effectiveRole);
} catch (failure) {
if (alive.current) setError(failure instanceof Error ? failure.message : "The boss could not be prepared. Please retry.");
} finally {
busy.current = false;
if (alive.current) setPreparing(false);
}
};
const testSelected = () => {
if (!selected || preparing) return;
if (!prepared) { void choose(selected, true); return; }
try { enterBossTesting(selected, effectiveRole); } catch (failure) {
setError(failure instanceof Error ? failure.message : "The test could not be started.");
}
};
const random = () => {
const boss = randomTestingBoss(bosses);
if (boss) void choose(boss, true);
};
const actions: MenuAction[] = [
{ id: "boss-testing-back", run: onClose },
{ id: "boss-testing-category", run: () => setCategory(category === "all" ? "wow" : category === "wow" ? "rom" : "all"), enabled: !preparing },
{ id: "boss-testing-status", run: () => { const values = ["all", "ready", "needs-testing", "not-ready"]; setStatus(values[(values.indexOf(status) + 1) % values.length]); }, enabled: !preparing },
{ id: "boss-testing-picker", run: () => { const next = bosses[(bosses.findIndex((boss) => boss.key === selectedKey) + 1) % bosses.length]; if (next) void choose(next); }, enabled: !preparing && bosses.length > 0 },
...roles.map((value) => ({ id: `boss-testing-role-${value}`, run: () => setRole(value), enabled: !preparing })),
{ id: "boss-testing-start", run: testSelected, enabled: Boolean(selected) && !preparing },
{ id: "boss-testing-random", run: random, enabled: bosses.length > 0 && !preparing },
];
const controller = useMenuController(actions, { initialId: "boss-testing-picker", onBack: onClose });
const selectProps = (controlId: string) => ({ className: controller.selectedId === controlId ? "is-controller-selected" : "",
onPointerEnter: () => controller.select(controlId), disabled: preparing });
const button = (controlId: string, label: string, run: () => void, disabled = false) =>
<ControllerButton controlId={controlId} selectedId={controller.selectedId} select={controller.select}
className="front-button" type="button" onClick={run} disabled={disabled}>{label}</ControllerButton>;
return <DualDisplayFrame contextLabel="Boss Testing" top={
<FrontSurface className="boss-testing-screen" ariaLabel="Boss Testing selection">
<header className="boss-testing-header"><BrandMark compact /><div><span>Main menu / Mechanics laboratory</span><h1>Boss Testing</h1></div>
{button("boss-testing-back", "Back to main menu", onClose)}</header>
<main className="boss-testing-main">
<p>Choose a boss to test with your party in an empty room. Missing content downloads automatically when you select a boss.</p>
<div className="boss-testing-filters">
<label>Search bosses<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Boss or dungeon name" disabled={preparing} /></label>
<label>Game<select {...selectProps("boss-testing-category")} value={category} onChange={(event) => setCategory(event.target.value)}>
<option value="all">All games</option><option value="wow">WoW</option><option value="rom">RoM</option></select></label>
<label>Readiness<select {...selectProps("boss-testing-status")} value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="all">All statuses</option>{Object.entries(BOSS_TESTING_STATUS_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
</div>
<label className="boss-testing-picker">Choose a boss <small>{bosses.length} bosses</small>
<select {...selectProps("boss-testing-picker")} value={bosses.some((boss) => boss.key === selectedKey) ? selectedKey : ""}
onChange={(event) => { const boss = bosses.find((entry) => entry.key === event.target.value); if (boss) void choose(boss); }}>
<option value="" disabled>Select a boss…</option>
{bosses.map((boss) => <option key={boss.key} value={boss.key}>{boss.name} · {boss.dungeonTitle} · {BOSS_TESTING_STATUS_LABELS[reviews[boss.key]?.status ?? "needs-testing"]}</option>)}
</select>
</label>
{selected && <article className="boss-testing-selected"><p className="front-kicker">Selected encounter</p><h2>{selected.name}</h2><p>{selected.dungeonTitle}</p>
<strong className={`boss-testing-status boss-testing-status--${reviews[selected.key]?.status ?? "needs-testing"}`}>{BOSS_TESTING_STATUS_LABELS[reviews[selected.key]?.status ?? "needs-testing"]}</strong>
{reviews[selected.key]?.pending && <small>Readiness saved on this device; waiting to sync.</small>}
</article>}
{roles.length > 1 && <div className="boss-testing-actions" aria-label="Your party role">{roles.map((value) =>
<ControllerButton key={value} controlId={`boss-testing-role-${value}`} selectedId={controller.selectedId} select={controller.select}
className="front-button" type="button" aria-pressed={effectiveRole === value} onClick={() => setRole(value)} disabled={preparing}>{PARTY_ROLE_LABELS[value]}</ControllerButton>
)}</div>}
{preparing && <div role="status" className="boss-testing-progress"><p>Preparing {selected?.name}… {content.installingPackId ? "Downloading content" : "Checking assets"}</p>
{content.installingPackId && <><progress max={Math.max(1, content.totalBytes)} value={content.downloadedBytes} /><small>{Math.round(content.downloadedBytes / 1024 / 1024)} / {Math.round(content.totalBytes / 1024 / 1024)} MB</small></>}
</div>}
{error && <p role="alert" className="dungeon-report-error">{error}</p>}
{!bosses.length && <p role="status">No bosses match these filters.</p>}
<div className="boss-testing-actions">{button("boss-testing-start", error ? "Retry and test selected boss" : "Test selected boss", testSelected, !selected || preparing)}
{button("boss-testing-random", "Fight a random boss", random, !bosses.length || preparing)}</div>
<p className="boss-testing-help">Mark readiness and submit a boss report from pause or after victory. Test fights do not award loot, XP, or boss cards.</p>
</main><ControllerLegend back />
</FrontSurface>
} bottom={<LazyLocalCompanionPanel />} />;
}
+19 -3
View File
@@ -34,6 +34,8 @@ import { EditableHudElement } from "./EditableHudElement";
import { DpsMeterWidget } from "./DpsMeterWidget";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore";
import { currentDisplayLayout } from "../platform/displayLayout";
import { DungeonCompanionHud } from "./DungeonCompanionHud";
function safePercent(current: number, maximum: number): number {
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
@@ -166,12 +168,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
<p className="companion-kicker">Ready on both screens</p>
<h2>One roster. Two ways to play.</h2>
<div className="companion-feature-grid">
<article><b>01</b><span><strong>Local account</strong><small>Salted password verifier on this device</small></span></article>
<article><b>01</b><span><strong>Your roster</strong><small>Choose your adventurer after signing in</small></span></article>
<article><b>02</b><span><strong>Offline play</strong><small>No sign-in or network required</small></span></article>
<article><b>03</b><span><strong>PC fallback</strong><small>This panel opens as an overlay</small></span></article>
</div>
</main>
<footer><span>Top screen owns account entry</span><b>THOR / 620 x 540</b></footer>
<footer><span>Sign in on the top screen</span><b>THOR / 3.92 INCHES</b></footer>
</FrontSurface>
);
}
@@ -297,6 +299,19 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
return <CompanionDungeonSelection selected={selected} send={send} />;
}
if (snapshot.phase === "class-hall") {
const definition = selected ? classById(selected.classId) : null;
return <FrontSurface bottom className="companion-panel companion-create" ariaLabel="Class Hall companion screen">
<header><BrandMark compact /><b>CLASS HALL</b></header>
<main><section><p className="companion-kicker">{selected?.name ?? "Your adventurer"}</p>
<h2>{definition?.name ?? "Choose your path"}</h2>
<p>Choose your specialization on the top screen.</p>
<p>Your specialization determines your role and starting equipment.</p>
</section></main>
<footer className="companion-actions"><button type="button" onClick={() => send({ name: "returnToMainMenu" })}>Back to main menu</button></footer>
</FrontSurface>;
}
if (snapshot.phase === "manastorms") {
return <ShelvedCompanionPanel selected={selected} send={send} />;
}
@@ -510,11 +525,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
}
export function LocalCompanionPanel() {
useShellStore();
const shell = useShellStore();
useGameStore();
useCombatStore();
usePartyStore();
useUiLayoutStore();
useDpsMeterStore();
if (shell.phase === "game" && currentDisplayLayout() !== "single") return <DungeonCompanionHud send={executeCompanionCommand} />;
return <CompanionPanel snapshot={currentCompanionSnapshot()} send={executeCompanionCommand} />;
}
+4 -2
View File
@@ -5,6 +5,7 @@ import { actionKeyLabel, type HudElementId } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { EditableHudElement } from "./EditableHudElement";
import { ActionIcon, type ActionBarItem } from "./ActionBar";
import { AbilityTooltipTarget } from "./AbilityTooltip";
export function DesktopActionBars({ items }: { items: Readonly<Record<string, ActionBarItem>> }) {
const ui = useUiLayoutStore();
@@ -19,7 +20,8 @@ export function DesktopActionBars({ items }: { items: Readonly<Record<string, Ac
{bar.slots.map((button, s) => {
const item = button.abilityId ? items[button.abilityId] : null;
const selected = editing && ui.selectedButton?.bar === b && ui.selectedButton.slot === s;
return <button type="button" key={s} className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`}
return <AbilityTooltipTarget key={s} ability={item} bindingLabel={actionKeyLabel(button.key)}>
<button type="button" className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`}
aria-label={`Bar ${b + 1} button ${s + 1}: ${item?.name ?? "Empty"}${button.key ? `, ${actionKeyLabel(button.key)}` : ""}`}
aria-disabled={!editing && (!item || item.disabled || blocked)} aria-pressed={editing ? selected : undefined}
title={item ? `${item.name}\n${item.description ?? ""}\n${actionKeyLabel(button.key)}${item.lockedUntilLevel ? ` · Level ${item.lockedUntilLevel}` : ""}` : `Empty · ${actionKeyLabel(button.key)}`}
@@ -30,7 +32,7 @@ export function DesktopActionBars({ items }: { items: Readonly<Record<string, Ac
<ActionIcon item={item ?? null} /><kbd>{actionKeyLabel(button.key)}</kbd>
{item && item.cooldownRemaining! > .05 && <span className="desktop-action-button__cooldown">{item.cooldownRemaining! < 10 ? item.cooldownRemaining!.toFixed(1) : Math.ceil(item.cooldownRemaining!)}</span>}
{editing && !item && <small>{s + 1}</small>}
</button>;
</button></AbilityTooltipTarget>;
})}
</section>
</EditableHudElement>; })}</>;
+3 -1
View File
@@ -4,11 +4,13 @@ import { resolveDpsMeterDisplay } from "../game/uiLayout";
import { currentDpsMeterSnapshot, useDpsMeterStore } from "../game/dpsMeterStore";
import { EditableHudElement } from "./EditableHudElement";
import { DpsMeterWidget } from "./DpsMeterWidget";
import { useHudSurface } from "./HudSurface";
export function DpsMeter() {
const { surface } = useHudSurface();
useDpsMeterStore();
const ui = useUiLayoutStore();
const layout = ui.draft ?? ui.preferences.current[currentUiDevice()];
if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== "main") return null;
if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== (surface === "top" ? "main" : "companion")) return null;
return <EditableHudElement id="dps"><DpsMeterWidget snapshot={currentDpsMeterSnapshot()} width={layout.elements.dps.width} /></EditableHudElement>;
}
+27
View File
@@ -0,0 +1,27 @@
import { Hud } from "./Hud";
import { useGameStore } from "../game/store";
import type { CompanionCommand } from "../platform/dualScreenSync";
export function DungeonCompanionHud({ send, remote = false, completion, interaction }: {
send: (command: CompanionCommand) => void;
remote?: boolean;
completion?: boolean;
interaction?: { prompt: string; name: string; label: string } | null;
}) {
const bossTest = useGameStore((state) => state.bossTest);
return <main className="game-shell companion-hud-shell" aria-label="Dungeon bottom screen">
<Hud surface="bottom" completion={completion} interaction={interaction}
onFinish={remote ? () => send({ name: "finishDungeon" }) : undefined}
onInteract={remote ? () => send({ name: "interactDungeonObject" }) : undefined}
onResurrect={remote ? (checkpointId) => send({ name: "resurrectParty", checkpointId }) : undefined}
onLayoutChange={remote ? (id, update) => send({ name: "editHudElement", id, update }) : undefined} />
<nav className="companion-hud-menus" aria-label="Dungeon menus">
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "map" })}>Map</button>
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "spellbook" })}>Spells</button>
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "inventory" })}>Gear</button>
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "ui-layout" })}>Edit UI</button>
<button type="button" onClick={() => send({ name: "togglePause" })}>Pause</button>
{bossTest && <button type="button" onClick={() => send({ name: "openOverlay", overlay: "boss-test-review" })}>Boss review</button>}
</nav>
</main>;
}
+10 -6
View File
@@ -10,7 +10,10 @@ import {
import { useGameStore } from "../game/store";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
export function DungeonInteractionPrompt() {
export function DungeonInteractionPrompt({ interaction, onInteract }: {
interaction?: { prompt: string; name: string; label: string } | null;
onInteract?: () => void;
} = {}) {
const label = useInputBindingLabels();
const playerPosition = useGameStore((state) => state.playerPosition);
const inputMode = useGameStore((state) => state.inputMode);
@@ -21,19 +24,20 @@ export function DungeonInteractionPrompt() {
activeDungeonEncounterRevision,
);
void playerPosition;
const candidate = overlay ? null : activeDungeonInteractionCandidate();
const local = interaction === undefined && !overlay ? activeDungeonInteractionCandidate() : null;
const candidate = interaction ?? (local ? { prompt: local.object.interaction!.prompt, name: local.object.name, label: local.state.label } : null);
if (!candidate) return null;
return (
<button
type="button"
className="dungeon-interaction-prompt"
onClick={() => interactWithActiveDungeonObject()}
aria-label={`${candidate.object.interaction!.prompt}: ${candidate.object.name}`}
onClick={onInteract ?? (() => interactWithActiveDungeonObject())}
aria-label={`${candidate.prompt}: ${candidate.name}`}
>
<span className="dungeon-interaction-prompt__key">{label(inputMode === "gamepad" ? "controller" : "pc", "loot")}</span>
<span>
<strong>{candidate.object.interaction!.prompt}</strong>
<small>{candidate.object.name} · {candidate.state.label}</small>
<strong>{candidate.prompt}</strong>
<small>{candidate.name} · {candidate.label}</small>
</span>
</button>
);
+9 -7
View File
@@ -8,6 +8,7 @@ import { ControllerButton } from "./ControllerButton";
export function DungeonIssueReportPanel() {
const dungeonId = useGameStore((state) => state.activeDungeonId);
const bossTest = useGameStore((state) => state.bossTest);
const openOverlay = useGameStore((state) => state.openOverlay);
const session = useShellStore((state) => state.session);
const [comment, setComment] = useState("");
@@ -16,12 +17,12 @@ export function DungeonIssueReportPanel() {
const [error, setError] = useState("");
const busy = useRef(false);
const form = useRef<HTMLFormElement>(null);
const back = () => { if (!busy.current) openOverlay("pause"); };
const back = () => { if (!busy.current) openOverlay(bossTest ? "boss-test-review" : "pause"); };
const canSubmit = Boolean(comment.trim()) && comment.length <= DUNGEON_REPORT_COMMENT_MAX_LENGTH && !submitting;
const actions = useMemo<MenuAction[]>(() => [
{ id: "report-back", run: back, enabled: !submitting },
{ id: "report-submit", run: () => form.current?.requestSubmit(), enabled: canSubmit && !submitted },
], [canSubmit, openOverlay, submitted, submitting]);
], [bossTest, canSubmit, openOverlay, submitted, submitting]);
const controller = useMenuController(actions, { initialId: "report-back", onBack: back });
const submit = async (event: FormEvent) => {
@@ -31,7 +32,8 @@ export function DungeonIssueReportPanel() {
setSubmitting(true);
setError("");
try {
await submitDungeonReport(session, dungeonId, comment);
await submitDungeonReport(session, bossTest?.sourceDungeonId ?? dungeonId, comment,
bossTest ? { bossKey: bossTest.key, bossName: bossTest.name } : undefined);
setSubmitted(true);
} catch (failure) {
setError(failure instanceof Error ? failure.message : "Your report could not be submitted. Please try again.");
@@ -44,9 +46,9 @@ export function DungeonIssueReportPanel() {
return (
<div className="modal-backdrop" role="presentation">
<section className="pause-menu dungeon-issue-report" role="dialog" aria-modal="true" aria-labelledby="issue-report-title">
<p className="eyebrow">Dungeon feedback</p>
<h2 id="issue-report-title">Report an issue</h2>
<p>{dungeonById(dungeonId)?.title ?? dungeonId}</p>
<p className="eyebrow">{bossTest ? "Boss feedback" : "Dungeon feedback"}</p>
<h2 id="issue-report-title">{bossTest ? "Submit a boss report" : "Report an issue"}</h2>
<p>{bossTest ? `${bossTest.name} · ${dungeonById(bossTest.sourceDungeonId)?.title ?? bossTest.sourceDungeonId}` : dungeonById(dungeonId)?.title ?? dungeonId}</p>
{submitted ? <p role="status">Report submitted. You can follow its status in View Reports on the main menu.</p> : (
<form ref={form} onSubmit={(event) => void submit(event)}>
<label htmlFor="dungeon-issue-comment">Describe the issue</label>
@@ -62,7 +64,7 @@ export function DungeonIssueReportPanel() {
</form>
)}
<ControllerButton controlId="report-back" selectedId={controller.selectedId} select={controller.select}
className="button" type="button" onClick={back} disabled={submitting}>Back to pause menu</ControllerButton>
className="button" type="button" onClick={back} disabled={submitting}>{bossTest ? "Back to boss review" : "Back to pause menu"}</ControllerButton>
</section>
</div>
);
+1
View File
@@ -23,6 +23,7 @@ function ReportCard({ report, canManage, saving, onUpdate, selectedId, select }:
return (
<article className={`dungeon-report-card ${report.status === "Closed" ? "is-closed" : ""}`}>
<header><h2>{dungeonById(report.dungeonId)?.title ?? report.dungeonId}</h2><span className="dungeon-report-status">{report.status}</span></header>
{report.bossName && <strong>Boss: {report.bossName}</strong>}
<p className="dungeon-report-comment">{report.comment}</p>
<small>Submitted by {report.submittedBy} · <time dateTime={new Date(report.createdAt).toISOString()}>{new Date(report.createdAt).toLocaleString()}</time></small>
{report.updatedBy && <small>Updated by {report.updatedBy} · {new Date(report.updatedAt).toLocaleString()}</small>}
+12 -5
View File
@@ -1,6 +1,8 @@
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { HUD_ELEMENTS, type HudElementId, type HudElementLayout } from "../game/uiLayout";
import { HUD_ELEMENTS, resolveHudScreen, type HudElementId, type HudElementLayout } from "../game/uiLayout";
import { currentDisplayLayout } from "../platform/displayLayout";
import { useHudSurface } from "./HudSurface";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { useGameStore } from "../game/store";
import "./uiLayout.css";
@@ -22,23 +24,26 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
onChange?: (update: Partial<HudElementLayout>) => void;
}) {
const overlay = useGameStore((state) => state.overlay);
const surface = useHudSurface();
const dualScreen = currentDisplayLayout() !== "single";
const editing = editingOverride ?? overlay === "ui-layout";
const state = useUiLayoutStore();
const layout = state.draft ?? state.preferences.current[currentUiDevice()];
const element = elementLayout ?? layout.elements[id];
const screen = resolveHudScreen(id, layout, dualScreen);
const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
const ref = useRef<HTMLDivElement>(null);
const [host, setHost] = useState<Element | null>(null);
const drag = useRef<{ pointerId: number; dx: number; dy: number } | null>(null);
const [bounds, setBounds] = useState({ width: 0, height: 0, panelWidth: 0, panelHeight: 0 });
const positioned = editing || element.position !== null;
const positioned = editing || element.position !== null || (dualScreen && surface.surface === "bottom" && !["player", "actions", "cast", "experience", "dps"].includes(id));
const fallback = FALLBACKS[id] ?? [.5, .35, 250];
const position = element.position ?? (bottomAnchor !== undefined
? { x: .5, y: 1 - bottomAnchor / Math.max(1, bounds.height) }
: origin ?? { x: fallback[0], y: fallback[1] });
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2];
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector]);
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector, screen]);
useLayoutEffect(() => {
if (positioned || !ref.current) return;
@@ -66,18 +71,19 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
const observer = new ResizeObserver(measure);
observer.observe(node); observer.observe(host); measure();
return () => observer.disconnect();
}, [positioned, host, hostSelector]);
}, [positioned, host, hostSelector, screen]);
const updatePosition = (x: number, y: number) => {
const update = { position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width };
if (onChange) onChange(update);
else if (surface.onChange) surface.onChange(id, update);
else state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, [id]: { ...draft.elements[id], ...update } } }));
};
const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2));
const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2));
const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined;
const content = <div ref={ref} data-hud-element={id} style={style}
className={`editable-hud ${positioned ? "editable-hud--positioned" : "editable-hud--default"} ${editing ? "editable-hud--editing" : ""} ${!element.visible ? editing ? "editable-hud--ghost" : "editable-hud--hidden" : ""}`}
className={`editable-hud ${positioned ? "editable-hud--positioned" : "editable-hud--default"} ${editing ? "editable-hud--editing" : ""} ${state.selectedElement === id ? "editable-hud--selected" : ""} ${!element.visible ? editing ? "editable-hud--ghost" : "editable-hud--hidden" : ""}`}
onClickCapture={(event) => { if (editing && !id.startsWith("bar-")) { event.preventDefault(); event.stopPropagation(); useUiLayoutStore.setState({ selectedElement: id }); } }}>
{children}
{editing && <button type="button" className="editable-hud__handle" aria-label={`Move ${HUD_ELEMENTS[id]}`} title="Drag to move; arrow keys nudge"
@@ -102,5 +108,6 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
updatePosition(position.x + delta[0] * .01, position.y + delta[1] * .01);
}}>{HUD_ELEMENTS[id]}{!element.visible ? " · Hidden" : ""}</button>}
</div>;
if (dualScreen && screen !== surface.surface) return null;
return positioned && host ? createPortal(content, host) : content;
}
+27 -9
View File
@@ -1,4 +1,7 @@
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { HudSurfaceContext } from "./HudSurface";
import type { HudElementId, HudElementLayout } from "../game/uiLayout";
import { DungeonInteractionPrompt } from "./DungeonInteractionPrompt";
import {
useCallback,
useEffect,
@@ -168,7 +171,15 @@ export function DungeonCompletePrompt({
);
}
export function Hud() {
export function Hud({ surface = "top", onLayoutChange, completion, onFinish, onResurrect, interaction, onInteract }: {
surface?: "top" | "bottom";
onLayoutChange?: (id: HudElementId, update: Partial<HudElementLayout>) => void;
completion?: boolean;
onFinish?: () => void;
onResurrect?: (checkpointId: string) => void;
interaction?: { prompt: string; name: string; label: string } | null;
onInteract?: () => void;
} = {}) {
const bindingLabel = useInputBindingLabels();
const ui = useUiLayoutStore();
const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars;
@@ -230,7 +241,7 @@ export function Hud() {
);
const runProgress = useDungeonRunStore((state) => state.progress);
const finalBossDefeated = runProgress?.dungeonId === activeDungeonId && runProgress.finishedAt !== null;
const dungeonComplete = gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId));
const dungeonComplete = completion ?? (gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId)));
useEffect(() => {
const timer = window.setInterval(() => setClock(Date.now()), 100);
@@ -282,6 +293,10 @@ export function Hud() {
disabled: talentLocked || level < ability.unlockLevel || health <= 0 || remaining > 0 || Boolean(activeCast) || cannotPay,
lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined,
target: ability.target,
cooldownSeconds: ability.cooldownMs / 1_000,
castSeconds: ability.castTimeMs / 1_000,
range: ability.range.max,
talentLocked,
};
return [catalogAbility.id, item];
})), [assignedAbilities, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
@@ -391,12 +406,14 @@ export function Hud() {
};
return (
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
<HudSurfaceContext.Provider value={{ surface, onChange: onLayoutChange, onResurrect }}>
<div className={`hud hud-surface-${surface}`} style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
<ShelvedHud />
<DpsMeter />
<BossLootWindow />
{surface === "top" && <BossLootWindow />}
<EditableHudElement id="interaction"><DungeonInteractionPrompt interaction={interaction} onInteract={onInteract} /></EditableHudElement>
<EditableHudElement id="completion">{dungeonComplete ? (
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon}
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={onFinish ?? finishDungeon}
finalBossOnly={!activeDungeonRunIsComplete(activeDungeonId)} durationMs={runProgress?.timed ? runProgress.durationMs : null}
onResults={finalBossDefeated ? () => useGameStore.getState().openOverlay("dungeon-results") : undefined} />
) : null}</EditableHudElement>
@@ -409,7 +426,7 @@ export function Hud() {
<EditableHudElement id="party"><PartyFrames /></EditableHudElement>
<EditableHudElement id="summons"><SummonFrames now={clock} /></EditableHudElement>
{pendingGroundTarget ? (
{surface === "top" && pendingGroundTarget ? (
<section className="ground-target-reticle" role="status" aria-live="polite">
<div aria-hidden="true"><i /><b /></div>
<strong>Place ground effect</strong>
@@ -507,7 +524,7 @@ export function Hud() {
items={actionItems}
activeLayer={actionLayer}
onActivate={castBinding}
inputMode={inputMode}
inputMode={surface === "bottom" ? "gamepad" : inputMode}
feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null}
/></EditableHudElement>}
</div>
@@ -527,8 +544,8 @@ export function Hud() {
].map((asset) => asset.fileName).join(" and ")}.`}</span>
</div>
)}</EditableHudElement>
<LoadingOverlay />
{health <= 0 ? (
{surface === "top" && <LoadingOverlay />}
{surface === "top" && health <= 0 ? (
<DefeatedPrompt
gameMode={gameMode}
resurrectionCharges={resurrectionCharges}
@@ -539,5 +556,6 @@ export function Hud() {
/>
) : null}
</div>
</HudSurfaceContext.Provider>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { createContext, useContext } from "react";
import type { HudElementId, HudElementLayout } from "../game/uiLayout";
export const HudSurfaceContext = createContext<{
surface: "top" | "bottom";
onChange?: (id: HudElementId, update: Partial<HudElementLayout>) => void;
onResurrect?: (checkpointId: string) => void;
}>({ surface: "top" });
export const useHudSurface = () => useContext(HudSurfaceContext);
+5 -5
View File
@@ -26,7 +26,7 @@ const PC_KEYS = [
"Insert", "Home", "End", "PageUp", "PageDown", ...Array.from({ length: 10 }, (_, i) => `Numpad${i}`),
"NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide", "NumpadDecimal", "NumpadEnter",
];
function KeybindsDialog() {
function KeybindsDialog({ onClose }: { onClose?: () => void }) {
const [device, setDevice] = useState<InputDevice>(() => defaultInputDevice(Capacitor.isNativePlatform(), currentUiDevice() === "mobile"));
const [draft, setDraft] = useState(() => useUiLayoutStore.getState().preferences);
const [search, setSearch] = useState("");
@@ -57,7 +57,7 @@ function KeybindsDialog() {
const shown = INPUT_ACTIONS.filter((entry) => (group === "All actions" || entry.group === group)
&& (!conflictsOnly || conflicts.some((conflict) => conflict.id === entry.id))
&& `${entry.label} ${detail(entry.id)} ${inputBindingLabel(device, bindings[entry.id])}`.toLowerCase().includes(search.toLowerCase()));
const close = () => useGameStore.getState().openOverlay("options");
const close = onClose ?? (() => useGameStore.getState().openOverlay("options"));
const endCapture = () => { setListening(false); setCapturingInput(false); };
const cancelEdit = () => { endCapture(); setEditing(null); setPending(null); setResetPending(false); };
const apply = (id: string, key: string | null) => {
@@ -211,7 +211,7 @@ function KeybindsDialog() {
</section>
</div>;
}
export function KeybindsPanel() {
const open = useGameStore((state) => state.overlay === "keybinds");
return open ? <KeybindsDialog /> : null;
export function KeybindsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
const storeOpen = useGameStore((state) => state.overlay === "keybinds");
return (open ?? storeOpen) ? <KeybindsDialog onClose={onClose} /> : null;
}
+53
View File
@@ -0,0 +1,53 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { classById } from "../app/characterCatalog";
import { beginMainMenuCharacterEditing } from "../app/mainMenuCharacterEditing";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { AbilityBindingsPanel } from "./AbilityBindingsPanel";
import { BrandMark, FrontSurface } from "./FrontSurface";
import { KeybindsPanel } from "./KeybindsPanel";
import { RomSkillsPanel } from "./RomSkillsPanel";
import { loadTalentGameplayPanel } from "./talentGameplayResources";
import "./mainMenuCharacterEditor.css";
const TalentGameplayPanel = lazy(loadTalentGameplayPanel);
export type MainMenuEditor = "talents" | "keybinds" | "bindings";
export function MainMenuCharacterEditor({ editor, onClose }: {
editor: MainMenuEditor;
onClose: () => void;
}) {
const character = useShellStore(selectedCharacter);
const ownerId = useShellStore((state) => state.session?.ownerId);
const notice = useShellStore((state) => state.notice);
const characterKey = `${ownerId ?? ""}:${character?.id ?? ""}`;
const [readyKey, setReadyKey] = useState<string | null>(null);
useEffect(() => {
const stop = beginMainMenuCharacterEditing();
setReadyKey(stop ? characterKey : null);
return () => stop?.();
}, [characterKey]);
const ready = readyKey === characterKey;
return <DualDisplayFrame companionDisabled contextLabel="Character setup" bottom={null} top={
<FrontSurface className="main-menu-character-editor" ariaLabel="Main menu character setup">
<header className="main-menu-character-editor__header">
<BrandMark compact />
<div><strong>{character?.name ?? "Character setup"}</strong>
<span>{character ? `Level ${character.level} ${classById(character.classId).name}` : "Select a character to edit."}</span></div>
<button className="front-button" type="button" onClick={onClose}>Back to main menu</button>
</header>
{notice && <p className="front-notice" role="status">{notice}</p>}
<div className="main-menu-character-editor__panel">
{!ready ? <p role="status">Preparing character…</p> : <Suspense fallback={<p role="status">Opening talents…</p>}>
{editor === "talents" && (character?.categoryId === "rom"
? <RomSkillsPanel open onClose={onClose} />
: <TalentGameplayPanel open onClose={onClose} />)}
{editor === "keybinds" && <KeybindsPanel open onClose={onClose} />}
{editor === "bindings" && <AbilityBindingsPanel open onClose={onClose} />}
</Suspense>}
</div>
</FrontSurface>
} />;
}
+37 -5
View File
@@ -11,7 +11,9 @@ import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { DungeonReportsScreen } from "./DungeonReportsScreen";
import { MainMenuCharacterEditor, type MainMenuEditor } from "./MainMenuCharacterEditor";
const BossCardCollectionScreen = lazy(() => import("./BossCardCollection"));
const BossTestingScreen = lazy(() => import("./BossTestingScreen").then((module) => ({ default: module.BossTestingScreen })));
const GAME_MODES = [
{
@@ -68,16 +70,22 @@ export function MainMenuScreen() {
const [contentManagerOpen, setContentManagerOpen] = useState(false);
const [reportsOpen, setReportsOpen] = useState(false);
const [cardsOpen, setCardsOpen] = useState(false);
const [bossTestingOpen, setBossTestingOpen] = useState(false);
const [editor, setEditor] = useState<MainMenuEditor | null>(null);
if (editor) return <MainMenuCharacterEditor editor={editor} onClose={() => setEditor(null)} />;
if (bossTestingOpen) return <Suspense fallback={<p role="status">Opening Boss Testing…</p>}><BossTestingScreen onClose={() => setBossTestingOpen(false)} /></Suspense>;
if (cardsOpen) return <Suspense fallback={<p role="status">Opening collection log…</p>}><BossCardCollectionScreen onClose={() => setCardsOpen(false)} /></Suspense>;
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenEditor={setEditor} />;
}
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenEditor }: {
readonly onOpenDownloads: () => void;
readonly onOpenReports: () => void;
readonly onOpenCards: () => void;
readonly onOpenBossTesting: () => void;
readonly onOpenEditor: (editor: MainMenuEditor) => void;
}) {
const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter);
@@ -118,11 +126,17 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
run: returnToCharacters,
neighbors: { up: "mode-dungeons", left: "mode-dungeons" },
},
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
...(character ? [
{ id: "mode-class-hall", run: openClassHall },
{ id: "mode-talents", run: () => onOpenEditor("talents") },
{ id: "mode-keybinds", run: () => onOpenEditor("keybinds") },
{ id: "mode-bindings", run: () => onOpenEditor("bindings") },
] : []),
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
{ id: "mode-reports", run: onOpenReports },
{ id: "mode-cards", run: onOpenCards },
], [character, isGm, onOpenCards, onOpenDownloads, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
{ id: "mode-boss-testing", run: onOpenBossTesting },
], [character, isGm, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
const controller = useMenuController(actions, {
initialId: "mode-dungeons",
onBack: returnToCharacters,
@@ -177,7 +191,25 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
<p>Select a destination. More adventures will unlock here as they are built.</p>
</header>
<OnlineGroupPanel />
<nav className="main-menu-mode-grid" aria-label="Game modes">
<nav className="main-menu-mode-grid" aria-label="Main menu destinations">
<ControllerButton controlId="mode-boss-testing" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenBossTesting}>
<b aria-hidden="true">BT</b><span><strong>Boss Testing</strong><small>Choose a boss or fight a random encounter in an empty testing room.</small></span><em>Open</em>
</ControllerButton>
{character && <>
<ControllerButton controlId="mode-talents" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("talents")}>
<b aria-hidden="true">T</b><span><strong>{isRom ? "Skills" : "Talents"}</strong><small>{isRom ? "Review your primary, secondary, and elite skills." : "View your talent trees, spend points, or reset your build."}</small></span><em>Open</em>
</ControllerButton>
<ControllerButton controlId="mode-keybinds" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("keybinds")}>
<b aria-hidden="true">K</b><span><strong>Keybinds</strong><small>View and change keyboard or controller bindings.</small></span><em>Open</em>
</ControllerButton>
<ControllerButton controlId="mode-bindings" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("bindings")}>
<b aria-hidden="true">A</b><span><strong>Button assignments</strong><small>Choose the abilities on your action bars and controller buttons.</small></span><em>Open</em>
</ControllerButton>
</>}
<ControllerButton controlId="mode-cards" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenCards}>
<b aria-hidden="true">◆</b><span><strong>Boss Cards</strong><small>Track your collection, rarities, and copies from every dungeon.</small></span><em>Open</em>
+5 -2
View File
@@ -37,6 +37,7 @@ import {
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton";
import { AbilityTooltipTarget } from "./AbilityTooltip";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
@@ -413,6 +414,7 @@ export function ManastormSelectScreen() {
const capped = !selected
&& selectedSpellIds.length >= spellLoadoutModel.maxSelections;
return (
<AbilityTooltipTarget key={spell.id} ability={spell}>
<ControllerButton
key={spell.id}
controlId={`manastorm-spell-${spell.id}`}
@@ -420,8 +422,8 @@ export function ManastormSelectScreen() {
select={controller.select}
className={`manastorm-spell-option ${selected ? "is-active" : ""}`}
type="button"
disabled={capped}
onClick={() => toggleSpell(spell.id)}
aria-disabled={capped}
onClick={() => { if (!capped) toggleSpell(spell.id); }}
title={`${spell.description} · Client Spell.dbc ID ${spell.dbcSpellId}`}
>
<img src={spell.icon} alt="" aria-hidden="true" />
@@ -430,6 +432,7 @@ export function ManastormSelectScreen() {
<small>Spell {spell.dbcSpellId}</small>
</span>
</ControllerButton>
</AbilityTooltipTarget>
);
})}
{!spellLoadoutModel?.options.length ? (
+3 -1
View File
@@ -20,6 +20,7 @@ import { auraDefinitionIsDispellableDebuff } from "./auraDisplay";
import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../game/partyResurrection";
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
import { useGameStore } from "../game/store";
import { useHudSurface } from "./HudSurface";
const PARTY_COMMANDS: readonly {
command: PartyCommand;
@@ -212,6 +213,7 @@ function OnlineMemberFrame({
/** Compact roster for the player plus the selected number of targetable AI allies. */
export function PartyFrames() {
const { onResurrect } = useHudSurface();
const character = useShellStore((state) => state.activeCharacter);
const playerRole = useShellStore((state) => state.activeDungeonRole);
const playerHealth = useCombatStore((state) => state.health);
@@ -342,7 +344,7 @@ export function PartyFrames() {
title={bossCombat ? "Unavailable during boss combat." : !onlineSessionOwnsWorldSimulation()
? "Only the party's simulation host can resurrect NPC teammates."
: "Resurrect fallen NPC teammates at the selected checkpoint. They will return to you."}
onClick={() => resurrectDeadPartyMembers(checkpointId)}
onClick={() => (onResurrect ?? resurrectDeadPartyMembers)(checkpointId)}
>
<strong>Rez dead members</strong>
<small>{bossCombat ? "Unavailable during boss combat" : !onlineSessionOwnsWorldSimulation()
+8 -3
View File
@@ -7,6 +7,7 @@ import { sessionCanUseShelvedGmTools } from "../app/releaseFeatures";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { INPUT_ACTION_BY_ID } from "../input/keybinds";
import { DungeonIssueReportPanel } from "./DungeonIssueReportPanel";
import { BossTestingReviewPanel } from "./BossTestingReviewPanel";
function PauseDialog() {
const bindingLabel = useInputBindingLabels();
@@ -14,6 +15,7 @@ function PauseDialog() {
const openOverlay = useGameStore((state) => state.openOverlay);
const reset = useGameStore((state) => state.resetAtEntrance);
const gameMode = useGameStore((state) => state.gameMode);
const bossTest = useGameStore((state) => state.bossTest);
const activeCharacter = useShellStore((state) => state.activeCharacter);
const session = useShellStore((state) => state.session);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
@@ -50,12 +52,13 @@ function PauseDialog() {
{ id: "pause-options", run: () => openOverlay("options") },
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
...(gameMode === "dungeon" ? [{ id: "pause-report-issue", run: () => openOverlay("report-issue") }] : []),
...(bossTest ? [{ id: "pause-boss-testing-review", run: () => openOverlay("boss-test-review") }] : []),
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
...(gameMode === "manastorm"
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
: [{ id: "pause-entrance", run: returnToEntrance }]),
{ id: "pause-main-menu", run: leaveDungeon },
], [abandonManastormRun, closeOverlay, gameMode, isGm, openOverlay, reset, returnToMainMenu]);
], [abandonManastormRun, bossTest, closeOverlay, gameMode, isGm, openOverlay, reset, returnToMainMenu]);
const controller = useMenuController(actions, { initialId: "pause-resume", onBack: resume });
const menuButton = (id: string, label: string, run: () => void, primary = false) => (
@@ -73,10 +76,11 @@ function PauseDialog() {
<div className="modal-backdrop" role="presentation">
<section className="pause-menu" role="dialog" aria-modal="true" aria-labelledby="pause-title">
<p className="eyebrow">Healer Man paused</p>
<h2 id="pause-title">The cavern waits.</h2>
<h2 id="pause-title">{bossTest ? `${bossTest.name} — Boss Testing` : "The cavern waits."}</h2>
<p>Resume play or open any character and system menu from this hub.</p>
<div className="pause-menu__actions">
{menuButton("pause-resume", "Resume expedition", resume, true)}
{bossTest && menuButton("pause-boss-testing-review", "Boss readiness & report", () => openOverlay("boss-test-review"))}
{menuButton("pause-party", "Party management", () => openOverlay("party"))}
{menuButton("pause-inventory", "Inventory & equipment", () => openOverlay("inventory"))}
{menuButton("pause-boss-cards", "Boss card collection", () => openOverlay("boss-cards"))}
@@ -92,7 +96,7 @@ function PauseDialog() {
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
{gameMode === "manastorm"
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
: menuButton("pause-entrance", "Return to entrance", returnToEntrance)}
: menuButton("pause-entrance", bossTest ? "Restart boss test" : "Return to entrance", returnToEntrance)}
{menuButton("pause-main-menu", "Return to main menu", leaveDungeon)}
</div>
<dl className="controls-list">
@@ -110,5 +114,6 @@ function PauseDialog() {
export function PauseMenu() {
const overlay = useGameStore((state) => state.overlay);
if (overlay === "report-issue") return <DungeonIssueReportPanel />;
if (overlay === "boss-test-review") return <BossTestingReviewPanel />;
return overlay === "pause" ? <PauseDialog /> : null;
}
+5 -4
View File
@@ -20,9 +20,10 @@ function visibleGroup(ability: AbilityDefinition, primaryClassId: string): (type
return "primary";
}
export function RomSkillsPanel() {
const open = useGameStore((state) => state.overlay === "talents");
const close = useGameStore((state) => state.closeOverlay);
export function RomSkillsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
const storeOpen = useGameStore((state) => state.overlay === "talents");
const closeOverlay = useGameStore((state) => state.closeOverlay);
const close = onClose ?? closeOverlay;
const classId = useCombatStore((state) => state.classId);
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
const level = useCombatStore((state) => state.level);
@@ -32,7 +33,7 @@ export function RomSkillsPanel() {
{ id: "rom-skills-close", run: close },
], [abilities, close]);
const controller = useMenuController(actions, { initialId: abilities[0] ? `rom-skill-${abilities[0].id}` : "rom-skills-close", onBack: close });
if (!open) return null;
if (!(open ?? storeOpen)) return null;
const primary = classById(classId);
const secondary = secondaryClassId ? classById(secondaryClassId) : null;
+19 -2
View File
@@ -1,7 +1,11 @@
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { useGameStore } from "../game/store";
import { useCombatStore } from "../game/combatStore";
import { assignedActionBarAbilityIds } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import { AbilityTooltipTarget } from "./AbilityTooltip";
export interface SpellbookAbilityView {
id: string;
@@ -79,6 +83,11 @@ function SpellbookDialog({
unlockedOnly,
onUnlockedOnlyChange,
}: SpellbookDialogProps) {
const bindings = useCombatStore((state) => state.actionBindings);
const ui = useUiLayoutStore();
const device = currentUiDevice();
const layout = ui.draft ?? ui.preferences.current[device];
const assignedIds = assignedActionBarAbilityIds(layout, bindings, device === "pc" && layout.wowBars);
const orderedAbilities = useMemo(
() => spellbookAbilitiesForDisplay(abilities, playerLevel, false),
[abilities, playerLevel],
@@ -130,6 +139,7 @@ function SpellbookDialog({
</header>
<div className="spellbook-panel__toolbar">
<span className="action-bar-missing-legend">Light blue: missing from action bars</span>
<button
type="button"
className={`spellbook-panel__filter ${unlockedOnly ? "is-active" : ""}`}
@@ -145,10 +155,15 @@ function SpellbookDialog({
<div className="spellbook-panel__list" aria-label={unlockedOnly ? "Unlocked abilities" : "Known and locked abilities"}>
{visibleAbilities.map((ability) => {
const locked = !ability.known || ability.level > playerLevel;
const missing = !locked && !ability.passive && !assignedIds.has(ability.id);
const lockLabel = ability.talentLocked && !ability.known
? "Learn in talents"
: `Unlocks at level ${ability.level}`;
return (
<AbilityTooltipTarget key={ability.id} ability={{ ...ability,
lockedUntilLevel: ability.level > playerLevel ? ability.level : undefined,
talentLocked: ability.talentLocked && !ability.known,
}}>
<ControllerButton
key={ability.id}
controlId={`spell-${ability.id}`}
@@ -158,9 +173,9 @@ function SpellbookDialog({
setInspectedId(ability.id);
}}
type="button"
className={`spellbook-ability ${locked ? "spellbook-ability--locked" : ""}`}
className={`spellbook-ability ${locked ? "spellbook-ability--locked" : ""} ${missing ? "is-missing-from-bars" : ""}`}
aria-disabled={locked}
aria-label={locked ? `${ability.name}, ${lockLabel.toLowerCase()}` : `${ability.name}, unlocked`}
aria-label={locked ? `${ability.name}, ${lockLabel.toLowerCase()}` : `${ability.name}, unlocked${missing ? ", missing from action bars" : ""}`}
onFocus={() => setInspectedId(ability.id)}
onClick={() => {
setInspectedId(ability.id);
@@ -184,6 +199,7 @@ function SpellbookDialog({
? <em>{ability.talentLocked && !ability.known ? "Talent" : `Level ${ability.level}`}</em>
: <em className="spellbook-ability__known">{ability.passive ? "Passive" : "Known"}</em>}
</ControllerButton>
</AbilityTooltipTarget>
);
})}
{!visibleAbilities.length && (
@@ -208,6 +224,7 @@ function SpellbookDialog({
? "Learn in talents"
: `Unlocks at level ${inspected.level}`
: inspected.passive ? "Passive" : "Unlocked"}</dd></div>
{!inspectedLocked && !inspected.passive && <div><dt>Action bars</dt><dd>{assignedIds.has(inspected.id) ? "Assigned" : "Missing from action bars"}</dd></div>}
{inspected.rank !== undefined && <div><dt>Current rank</dt><dd>{inspected.rank} / {inspected.rankCount ?? inspected.rank}</dd></div>}
{inspected.resourceCost !== undefined && <div><dt>Cost</dt><dd>{inspected.resourceCost} {inspected.resourceLabel}</dd></div>}
{inspected.cooldownSeconds !== undefined && <div><dt>Cooldown</dt><dd>{inspected.cooldownSeconds || "None"}{inspected.cooldownSeconds ? " sec" : ""}</dd></div>}
+7 -1
View File
@@ -13,7 +13,11 @@ function treeSigil(name: string): string {
return name.split(/\s+/).map((word) => word[0]).join("").slice(0, 2).toUpperCase();
}
export default function TalentGameplayPanel({ readOnly = false }: { readOnly?: boolean } = {}) {
export default function TalentGameplayPanel({ readOnly = false, open, onClose }: {
readOnly?: boolean;
open?: boolean;
onClose?: () => void;
} = {}) {
const classId = useCombatStore((state) => state.classId);
const level = useCombatStore((state) => state.level);
const talentRanks = useCombatStore((state) => state.talentRanks);
@@ -45,5 +49,7 @@ export default function TalentGameplayPanel({ readOnly = false }: { readOnly?: b
onInvest={(nodeId) => { if (!readOnly) useCombatStore.getState().allocateTalent(nodeId); }}
onReset={() => { if (!readOnly) useCombatStore.getState().resetTalents(); }}
readOnly={readOnly}
open={open}
onClose={onClose}
/>;
}
+2 -2
View File
@@ -242,7 +242,7 @@ function TalentsDialog({
<><strong>{availablePoints}</strong><span>points available</span></>
)}
</div>
<button type="button" className="icon-button" onClick={onClose} aria-label="Return to pause menu">×</button>
<button type="button" className="icon-button" onClick={onClose} aria-label="Close talents">×</button>
</header>
<p className="talents-panel__intro">
@@ -436,7 +436,7 @@ function TalentsDialog({
>Reset talents</ControllerButton>
</div>
<footer className="game-panel__footer talents-panel__footer">
<span><kbd>D-pad</kbd> Navigate</span><span><kbd>A</kbd> Learn / inspect</span><span><kbd>B</kbd> Pause menu</span>
<span><kbd>D-pad</kbd> Navigate</span><span><kbd>A</kbd> Learn / inspect</span><span><kbd>B</kbd> Back</span>
</footer>
</section>
</div>
+40 -14
View File
@@ -2,15 +2,18 @@ import { useEffect, useRef, useState } from "react";
import { useShellStore } from "../app/shellStore";
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
import { useCombatStore } from "../game/combatStore";
import { defaultActionBarForCharacter } from "../game/abilityCatalog";
import { abilityAtLevel, defaultActionBarForCharacter } from "../game/abilityCatalog";
import { useGameStore } from "../game/store";
import {
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout,
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout, resetDesktopAbilities,
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
resolveHudScreen, type HudScreen,
} from "../game/uiLayout";
import { INPUT_ACTION_BY_ID, conflictingInputActions } from "../input/keybinds";
import { profileInputBindings } from "../game/keybindPreferences";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { AbilityTooltipTarget } from "./AbilityTooltip";
import { currentDisplayLayout } from "../platform/displayLayout";
export function UiLayoutEditor() {
const open = useGameStore((state) => state.overlay === "ui-layout");
@@ -18,6 +21,8 @@ export function UiLayoutEditor() {
const session = useShellStore((state) => state.session);
const cloudSave = useCloudSaveStatus();
const abilities = useCombatStore((state) => state.abilities);
const level = useCombatStore((state) => state.level);
const talentRanks = useCombatStore((state) => state.talentRanks);
const [tab, setTab] = useState<UiDevice>(ui.device);
const [name, setName] = useState("My UI");
const [slot, setSlot] = useState(0);
@@ -30,13 +35,13 @@ export function UiLayoutEditor() {
const file = useRef<HTMLInputElement>(null);
const wasOpen = useRef(false);
useEffect(() => {
if (open && !wasOpen.current) {
if (open && (!wasOpen.current || !useUiLayoutStore.getState().draft)) {
useUiLayoutStore.getState().begin();
setTab(useUiLayoutStore.getState().device); setMessage("");
}
if (!open && wasOpen.current) useUiLayoutStore.getState().finish(false);
wasOpen.current = open;
}, [open]);
}, [open, ui.draft !== null]);
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
@@ -69,6 +74,7 @@ export function UiLayoutEditor() {
}, [open]);
if (!open || !ui.draft) return null;
const draft = ui.draft;
const dualScreen = currentDisplayLayout() !== "single";
const attempt = (run: () => void) => { try { run(); } catch (error) { setMessage(error instanceof Error ? error.message : "Could not save this UI."); } };
const saveAndClose = () => attempt(() => { ui.finish(true); useGameStore.getState().closeOverlay(); });
const validName = name.trim().length > 0;
@@ -87,7 +93,7 @@ export function UiLayoutEditor() {
<button type="button" onClick={() => setDockRight(!dockRight)} aria-label={dockRight ? "Dock editor left" : "Dock editor right"}>{dockRight ? "←" : "→"}</button>
<button type="button" onClick={() => setCollapsed(!collapsed)} aria-label={collapsed ? "Expand editor" : "Collapse editor"}>{collapsed ? "+" : "−"}</button></header>
{!collapsed && <div className="ui-layout-editor__body">
<p>Drag a widget’s label to move it. Hidden and inactive widgets appear as placeholders. Use the list to show them again.</p>
<p>Choose a screen for each element below. Collapse this panel and drag a widget’s label to move it. Use the list to show hidden elements again.</p>
<details open><summary>UI templates · 6 slots</summary>
<div className="ui-layout-editor__tabs">{(["mobile", "pc"] as const).map((device) => <button key={device} type="button" aria-pressed={tab === device} onClick={() => chooseSlot(device, 0)}>{device === "pc" ? "PC" : "Mobile"} · 3 slots</button>)}</div>
<div className="ui-layout-editor__slots">{ui.preferences.slots[tab].map((template, index) => <button type="button" key={index} aria-pressed={slot === index} onClick={() => chooseSlot(tab, index)}><small>Slot {index + 1}</small><strong>{template?.name ?? "Empty"}</strong></button>)}</div>
@@ -131,20 +137,30 @@ export function UiLayoutEditor() {
})}>Replace with imported UI</button><button type="button" onClick={() => setPendingImport(null)}>Keep existing UI</button></div>}
<small>{session?.accessToken ? "Templates save to your account and upload with cloud saves. Sign in on another device to load them." : "Offline profile: saved on this device. Use export/import to transfer your UI."}</small>
</details>
<details><summary>Elements · move / hide</summary>
{(Object.entries(HUD_ELEMENTS) as [HudElementId, string][]).filter(([id]) => !id.startsWith("bar-") || ui.device === "pc").map(([id, label]) => <div key={id} className={`ui-layout-editor__element ${ui.selectedElement === id ? "is-selected" : ""}`}>
<label><input type="checkbox" checked={draft.elements[id].visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], visible: event.target.checked } } }))} />{label}</label>
<details open={dualScreen}><summary>Elements · screen / move / hide</summary>
{(Object.entries(HUD_ELEMENTS) as [HudElementId, string][]).filter(([id]) => !id.startsWith("bar-") || ui.device === "pc").map(([id, label]) => <div key={id} className={`ui-layout-editor__element ${ui.selectedElement === id ? "is-selected" : ""}`}
onClick={() => useUiLayoutStore.setState({ selectedElement: id })} onFocus={() => useUiLayoutStore.setState({ selectedElement: id })}>
<label>{label}</label>
<select aria-label={`${label} screen`} value={dualScreen ? resolveHudScreen(id, draft, true) : draft.elements[id].visible ? draft.elements[id].screen ?? "auto" : "hidden"}
onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
[id]: { ...layout.elements[id], screen: event.target.value as HudScreen, visible: event.target.value !== "hidden", position: null, width: null },
} }))}>
{!dualScreen && <option value="auto">Automatic</option>}
<option value="top">Top</option><option value="bottom">Bottom</option><option value="hidden">Hidden</option>
</select>
<button type="button" aria-label={`Reset ${label} position`} onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], position: null, width: null } } }))}>Reset</button>
</div>)}
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button>
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements, dpsMeterDisplay: "auto" }))}>Reset all elements</button>
{dualScreen && <p>Top: party health and the dungeon. Bottom: action bars, buffs and debuffs. Collapse this panel to drag labels on either display.</p>}
</details>
<details open className="ui-layout-editor__dps"><summary>DPS meter</summary>
<details className="ui-layout-editor__dps"><summary>DPS meter</summary>
<label><input type="checkbox" checked={draft.elements.dps.visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: event.target.checked } },
elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: event.target.checked,
screen: event.target.checked && layout.elements.dps.screen === "hidden" ? "auto" : layout.elements.dps.screen } },
}))} />Show DPS meter</label>
<label>Screen<select aria-label="DPS meter screen" value={draft.dpsMeterDisplay} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
dpsMeterDisplay: event.target.value as typeof layout.dpsMeterDisplay,
elements: { ...layout.elements, dps: { ...layout.elements.dps, position: null } },
elements: { ...layout.elements, dps: { ...layout.elements.dps, screen: "auto", position: null } },
}))}>
<option value="auto">Automatic · PC lower left / Thor bottom</option>
<option value="main">Main screen</option><option value="companion">Companion screen</option>
@@ -160,7 +176,7 @@ export function UiLayoutEditor() {
const combat = useCombatStore.getState();
const defaults = defaultActionBarForCharacter(combat.classId, combat.secondaryClassId, combat.level);
const empty = layout.bars.every((bar) => bar.slots.every((button) => !button.abilityId));
return { ...layout, wowBars: event.target.checked, bars: empty && event.target.checked ? layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((button, s) => ({ ...button, abilityId: defaults[b * 12 + s] ?? null })) })) : layout.bars };
return { ...(empty && event.target.checked ? resetDesktopAbilities(layout, defaults) : layout), wowBars: event.target.checked };
})} />Use WoW action bars</label>
{draft.wowBars && <>
<label>Visible bar count<select value={draft.barCount} onChange={(event) => { ui.updateDraft((layout) => ({ ...layout, barCount: Number(event.target.value) })); useUiLayoutStore.setState({ selectedButton: null }); }}>{[1, 2, 3, 4, 5].map((count) => <option key={count}>{count}</option>)}</select></label>
@@ -173,7 +189,17 @@ export function UiLayoutEditor() {
{selected && <div className="ui-layout-editor__assignment"><strong>Bar {selected.bar + 1} · Button {selected.slot + 1}{ui.mode === "bindings" ? " · Press a key…" : ""}</strong>
<button type="button" onClick={() => setAbility(null)}>Clear ability</button>
<label>Find ability<input value={search} onChange={(event) => setSearch(event.target.value)} /></label>
<div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((ability) => <button key={ability.id} type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button>)}</div>
<div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((definition) => {
const talentRank = definition.talentEntryId === undefined ? undefined : talentRanks[`coa-entry-${definition.talentEntryId}`] ?? 0;
const ability = abilityAtLevel(definition, level, talentRank);
return <AbilityTooltipTarget key={ability.id} ability={{
name: ability.name, description: ability.description, target: ability.target,
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000, range: ability.range.max,
lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
talentLocked: ability.talentEntryId !== undefined && !talentRank,
}}><button type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button></AbilityTooltipTarget>;
})}</div>
</div>}
</>}
</details>}
+47
View File
@@ -0,0 +1,47 @@
.boss-testing-screen {
--boss-test-border: rgba(177, 199, 218, 0.22);
--boss-test-panel: rgba(14, 23, 33, 0.76);
display: flex;
flex-direction: column;
min-height: 100%;
overflow: auto;
padding: clamp(16px, 3vw, 40px);
gap: 20px;
}
.boss-testing-header { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; }
.boss-testing-header > div:nth-child(2) { flex: 1; }
.boss-testing-header h1, .boss-testing-selected h2 { margin: 5px 0; }
.boss-testing-header span, .boss-testing-help { color: var(--front-muted, #b1bbc5); }
.boss-testing-main { width: min(100%, 1060px); margin: 0 auto; display: grid; gap: 18px; }
.boss-testing-main p { margin: 0; line-height: 1.6; }
.boss-testing-filters { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 14px; }
.boss-testing-main label { display: grid; gap: 8px; font-weight: 600; min-width: 0; }
.boss-testing-main input, .boss-testing-main select {
color: #e8edf2;
background: #17232f;
border: 1px solid var(--boss-test-border);
padding: 12px;
border-radius: 8px;
width: 100%;
min-width: 0;
font: inherit;
}
.boss-testing-main select option { background: #17232f; }
.boss-testing-selected { background: var(--boss-test-panel); border: 1px solid var(--boss-test-border); border-radius: 12px; padding: 20px; display: grid; gap: 8px; }
.boss-testing-status { width: fit-content; padding: 5px 10px; border-radius: 5px; background: #354454; }
.boss-testing-status--ready { color: #b6f0cd; }
.boss-testing-status--needs-testing { color: #f4db9e; }
.boss-testing-status--not-ready { color: #f7b7b7; }
.boss-testing-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.boss-testing-actions [aria-pressed="true"] { border-color: #d6b66b; box-shadow: inset 0 0 0 1px #d6b66b; }
.boss-testing-progress { display: grid; gap: 8px; }
.boss-testing-progress progress { width: 100%; accent-color: #d6b66b; }
.boss-testing-help { font-size: 0.9rem; }
.boss-testing-review .boss-testing-actions { margin: 18px 0; }
@media (max-width: 700px) {
.boss-testing-filters { grid-template-columns: 1fr 1fr; }
.boss-testing-filters label:first-child { grid-column: 1 / -1; }
.boss-testing-header .front-brand { display: none; }
.boss-testing-header { gap: 12px; }
.boss-testing-actions > button { flex: 1 1 180px; }
}
+37
View File
@@ -0,0 +1,37 @@
.main-menu-character-editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.main-menu-character-editor__header {
display: flex;
align-items: center;
gap: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--edge);
}
.main-menu-character-editor__header > div:nth-child(2) {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
}
.main-menu-character-editor__header strong { color: var(--gold); }
.main-menu-character-editor__header span { color: var(--muted); font-size: 12px; }
.main-menu-character-editor__panel { position: relative; flex: 1; min-height: 0; }
.main-menu-character-editor__panel .modal-backdrop {
padding: 0;
background: transparent;
backdrop-filter: none;
}
.main-menu-character-editor__panel .game-panel {
max-width: 100%;
max-height: 100%;
}
.main-menu-character-editor__panel .keybinds-panel { width: min(1100px, 100%); height: 100%; }
@media (max-width: 640px), (max-height: 500px) {
.main-menu-character-editor { gap: 6px; }
.main-menu-character-editor__header { gap: 10px; padding-bottom: 6px; }
.main-menu-character-editor__header .front-brand { display: none; }
.main-menu-character-editor__header .front-button { padding: 8px 12px; font-size: 12px; }
}
+29
View File
@@ -0,0 +1,29 @@
import { createElement, type ComponentType } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { AbilityBindingsPanel } from "./AbilityBindingsPanel";
import { KeybindsPanel } from "./KeybindsPanel";
import { RomSkillsPanel } from "./RomSkillsPanel";
const editors: {
Panel: ComponentType<{ open?: boolean; onClose?: () => void }>;
title: string;
close: string;
}[] = [
{ Panel: AbilityBindingsPanel, title: "Ability controls", close: "Close button assignments" },
{ Panel: KeybindsPanel, title: "Keybinds", close: "Close keybinds" },
{ Panel: RomSkillsPanel, title: "Skills", close: ">Close</button>" },
];
describe.each(editors)("standalone main-menu editor: $title", ({ Panel, title, close }) => {
it("can open without changing the gameplay overlay", () => {
const markup = renderToStaticMarkup(createElement(Panel, { open: true, onClose: () => undefined }));
expect(markup).toContain('role="dialog"');
expect(markup).toContain(title);
expect(markup).toContain(close);
});
it("respects an explicit closed state", () => {
expect(renderToStaticMarkup(createElement(Panel, { open: false }))).toBe("");
});
});
+39 -1
View File
@@ -1,12 +1,27 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
SpellbookPanel,
spellbookAbilitiesForDisplay,
type SpellbookAbilityView,
} from "./SpellbookPanel";
const barState = vi.hoisted(() => ({ pcIds: [] as (string | null)[], controllerIds: [] as string[], wowBars: true }));
vi.mock("../game/uiLayoutStore", async (importOriginal) => {
const actual = await importOriginal<typeof import("../game/uiLayoutStore")>();
const { normalizeUiPreferences } = await import("../game/uiLayout");
return { ...actual, currentUiDevice: () => "pc", useUiLayoutStore: () => {
const preferences = normalizeUiPreferences(null);
preferences.current.pc.wowBars = barState.wowBars;
preferences.current.pc.bars[0].slots.forEach((slot, index) => { slot.abilityId = barState.pcIds[index] ?? null; });
return { preferences, draft: null };
} };
});
vi.mock("../game/combatStore", () => ({ useCombatStore: (selector: (state: unknown) => unknown) => selector({
actionBindings: Object.fromEntries(barState.controllerIds.map((id, index) => [`layer:${index}`, id])),
}) }));
const abilities: readonly SpellbookAbilityView[] = [
{
id: "known-one",
@@ -47,6 +62,29 @@ const abilities: readonly SpellbookAbilityView[] = [
];
describe("spellbook ability progression", () => {
it("highlights only usable spells missing from the active layout and updates after assignment", () => {
barState.wowBars = true;
barState.pcIds = [null, null, null, null, "known-one"];
barState.controllerIds = ["known-two"];
try {
const render = () => renderToStaticMarkup(createElement(SpellbookPanel, {
abilities: [...abilities, { ...abilities[1], id: "passive", passive: true }],
playerLevel: 1, onActivate: () => undefined, open: true, onClose: () => undefined,
}));
let markup = render();
expect(markup).toContain("Known Two, unlocked, missing from action bars");
expect(markup).not.toContain("Known One, unlocked, missing from action bars");
expect(markup.match(/is-missing-from-bars/g)).toHaveLength(1);
barState.pcIds[11] = "known-two";
markup = render();
expect(markup).not.toContain("is-missing-from-bars");
barState.wowBars = false;
markup = render();
expect(markup).toContain("Known One, unlocked, missing from action bars");
expect(markup).not.toContain("Known Two, unlocked, missing from action bars");
expect(markup.match(/is-missing-from-bars/g)).toHaveLength(1);
} finally { barState.pcIds = []; barState.controllerIds = []; barState.wowBars = true; }
});
it("renders future abilities as inspectable locked buttons with level requirements", () => {
const markup = renderToStaticMarkup(createElement(SpellbookPanel, {
abilities,
+167
View File
@@ -0,0 +1,167 @@
/* Logical sizing uses a common physical density for both panels (160 px/in). */
.thor-preview-frame { min-height: 0; }
.thor-preview-label, .thor-preview-hinge { width: 100%; }
.thor-preview-top, .thor-preview-bottom, .dedicated-display-surface, .bottom-display-root { container-type: size; }
.companion-hud-shell { position: relative; width: 100%; height: 100%; overflow: hidden; background: radial-gradient(ellipse at top, #1c3827, #07110d 70%); }
.companion-hud-shell .hud-top-right { display: contents; }
.companion-hud-shell .player-status-panel { position: absolute; top: 12px; left: 12px; width: calc(100% - 24px); grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.companion-hud-shell .player-status-panel__section { padding: 8px; }
.companion-hud-shell .player-status-panel__section > header, .companion-hud-shell .player-status-panel__empty { font-size: 12px; }
.companion-hud-shell .player-status-panel__section > header b { min-width: 22px; height: 22px; font-size: 12px; }
.companion-hud-shell .player-status-panel__icons { justify-content: flex-start; min-height: 44px; }
.companion-hud-shell .player-status-panel :is(.aura, .timed-effect) { width: 44px; height: 44px; }
.companion-hud-shell :is(.aura__timer, .timed-effect__timer) { font-size: 11px; }
.companion-hud-shell .player-status-tooltip { top: 100%; left: 0; width: 100%; font-size: 13px; }
.companion-hud-shell .player-status-tooltip > header strong { font-size: 14px; }
.companion-hud-shell .player-status-tooltip p { font-size: 13px; }
.companion-hud-shell .player-status-tooltip :is(footer, header > span) { font-size: 11px; }
.companion-hud-shell .hud-bottom-stack { bottom: 76px; width: calc(100% - 16px); }
.companion-hud-shell .hud-bottom-stack .action-bar { --action-slot-size: 44px; width: min(420px, 100%); margin-top: 12px; }
.companion-hud-shell .action-slot__binding { min-width: 18px; height: 18px; font-size: 10px; }
.companion-hud-shell .action-bar__layer-status span { font-size: 11px; }
.companion-hud-shell .action-bar__detail strong { font-size: 12px; }
.companion-hud-shell .action-bar__detail span { font-size: 10px; }
.companion-hud-shell .action-slot__target { font-size: 10px; }
.companion-hud-shell .party-frame { min-height: 48px; }
.companion-hud-shell .party-frame__identity strong { font-size: 13px; }
.companion-hud-shell .party-frame__identity small { font-size: 10px; }
.companion-hud-shell .party-frame__health { height: 14px; }
.companion-hud-shell .party-frame__health b, .companion-hud-shell .party-frames > header { font-size: 10px; }
.companion-hud-shell .party-command-bar button { min-height: 44px; padding-inline: 2px; }
.companion-hud-shell .party-command-bar strong { font-size: 11px; }
.companion-hud-shell .party-command-bar kbd { display: none; }
.companion-hud-shell .hud-minimap__caption strong { font-size: 13px; }
.companion-hud-shell .hud-minimap__caption span, .companion-hud-shell .target-frame small { font-size: 11px; }
.companion-hud-shell .dps-meter { position: absolute; top: 116px; left: 12px; bottom: auto; width: min(var(--dps-meter-width, 230px), calc(100% - 24px)); max-height: 64px; font-size: 12px; }
.companion-hud-shell .experience-bar { bottom: 56px; left: 12px; right: 12px; width: calc(100% - 24px); height: 16px; }
.companion-hud-shell .experience-bar__label { font-size: 10px; }
.companion-hud-menus { position: absolute; bottom: max(6px, env(safe-area-inset-bottom)); left: 8px; right: 8px; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 5px; pointer-events: auto; z-index: 30; }
.companion-hud-menus button { min-width: 0; min-height: 44px; padding: 4px; border: 1px solid var(--edge); border-radius: 6px; background: #172b20; color: var(--ink); font-size: 13px; }
.ui-layout-editor { font-size: 14px; }
.ui-layout-editor__body { min-height: 0; }
.ui-layout-editor :is(button, select, input:not([type=checkbox])) { min-height: 44px; }
.ui-layout-editor > header button { min-width: 44px; }
.ui-layout-editor__element { flex-wrap: wrap; padding: 6px 0; border-bottom: 1px solid var(--edge); }
.ui-layout-editor__element label { flex: 1 1 120px; margin: 0; }
.ui-layout-editor__element select { min-width: 78px; }
.editable-hud__handle { min-width: 44px; min-height: 44px; padding: 6px 8px; font-size: 12px; }
.editable-hud--editing { min-height: 44px; }
.editable-hud--editing.editable-hud--selected { z-index: 26; outline: 2px solid var(--gold); }
.companion-hud-shell .editable-hud--editing.editable-hud--selected { z-index: 40; }
.companion-hud-shell .dungeon-complete-banner { flex-wrap: wrap; }
.companion-hud-shell .dungeon-complete-banner p { flex-basis: calc(100% - 42px); }
.companion-hud-shell .dungeon-complete-banner .button { flex: 1; min-height: 44px; font-size: 13px; }
.companion-hud-shell .dungeon-complete-banner strong { font-size: 14px; }
.companion-hud-shell .dungeon-complete-banner small { font-size: 12px; }
/* Container queries also work in the stacked preview: the window is not a panel. */
@container (max-width: 650px) {
.front-surface--bottom { padding: 10px; font-size: 13px; overflow-y: auto; }
.companion-panel { grid-template-rows: 44px minmax(0, 1fr) auto; }
.companion-panel > header > b { font-size: 11px; }
.companion-panel .front-brand { gap: 6px; }
.companion-panel .front-brand small { font-size: 8px; }
.companion-panel .front-brand strong { font-size: 15px; }
.companion-panel > main { min-height: 0; overflow: auto; padding: 8px 0; gap: 8px; }
.companion-roster > main { grid-template-columns: minmax(0, 1fr); }
.companion-selection-card { display: none; }
.companion-mini-roster { overflow: auto; }
.companion-mini-roster button { min-height: 48px; padding: 8px; }
.companion-mini-roster button strong { font-size: 14px; }
.companion-mini-roster button small { font-size: 11px; }
.companion-actions { flex-wrap: wrap; padding-top: 6px; }
.companion-actions button { min-height: 44px; min-width: 100px; font-size: 12px; }
.companion-dungeons > main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; }
.companion-dungeon-role { display: block; }
.companion-dungeon-role .character-preview { display: none; }
.companion-dungeon-role h2 { font-size: 17px; }
.companion-role-options { display: flex; gap: 6px; }
.companion-role-options button { flex: 1; min-height: 44px; padding: 6px; }
.companion-role-options strong { font-size: 14px; }
.companion-role-options small { font-size: 10px; }
.companion-category-tabs button { min-height: 44px; }
.companion-category-tabs button strong { font-size: 13px; }
.companion-create > main { grid-template-columns: 55% minmax(0, 1fr); }
.companion-create main > .character-preview { min-width: 0; height: 240px; }
.companion-create h2 { font-size: 24px; }
.companion-create dl { font-size: 14px; }
.companion-panel dl dt, .companion-panel dl dd { font-size: 13px; }
.companion-feature-grid article strong { font-size: 13px; }
.companion-feature-grid article small { font-size: 11px; }
.companion-login > footer { font-size: 11px; }
}
@container (min-width: 700px) and (max-width: 1000px) {
.front-surface--top { padding: 12px; }
.front-surface--top :is(p, small, label, dt, dd) { font-size: max(11px, 0.7rem); }
.front-surface--top button { min-height: 44px; }
.login-hero h1 { font-size: 44px; }
.login-form { padding: 20px; }
.login-form h2 { font-size: 25px; }
.character-select-screen { grid-template-columns: 220px minmax(0, 1fr); }
.roster-header, .character-select-screen > .controller-legend { grid-column: 1 / 3; }
.roster-details { display: none; }
.roster-stage-continue { display: flex; }
.character-create-screen { grid-template-columns: 42% 58%; grid-template-rows: 47px 3px minmax(0, 1fr) 58px; }
.creator-header, .creator-progress, .creator-footer { grid-column: 1 / 3; }
.creator-races { grid-column: 1; }
.creator-preview-stage, .appearance-preview-stage { display: none; }
.creator-classes { grid-column: 2; }
.appearance-controls { grid-column: 1 / 3; }
.creator-footer { gap: 8px; }
.dungeon-select-screen { grid-template-columns: 260px minmax(0, 1fr); gap: 10px; }
.dungeon-select-detail { grid-template-columns: 1fr; gap: 10px; }
.dungeon-select-map { display: none; }
.dungeon-select-copy { padding: 8px; }
.dungeon-role-option { min-height: 48px; }
.front-surface--top :is(.race-option > span, .class-option strong, .roster-character small, .main-menu-mode small) { font-size: 12px; }
.race-option > b { font-size: 10px; }
.creator-classes { grid-template-rows: auto minmax(100px, 1fr) auto; overflow-y: auto; }
.class-summary { max-height: 155px; overflow-y: auto; }
.class-option small { font-size: 11px; }
.game-panel :is(p, small, label, dt, dd, em), .game-panel :is(button, select, input) { font-size: 12px; }
.game-panel :is(button, select, input), .pause-menu__actions .button { min-height: 44px; }
.game-panel .icon-button { min-width: 44px; }
.game-panel__footer { font-size: 11px; }
.game-panel :is(.party-roster strong, .spellbook-ability strong, .inventory-member strong) { font-size: 13px; }
.game-panel :is(.equipment-slot, .inventory-attributes-tabs button, .bindings-slot) { min-height: 44px; }
.party-frames { width: 230px; }
.party-frames > header { font-size: 11px; min-height: 18px; }
.party-frame { min-height: 48px; }
.party-frame__identity strong { font-size: 13px; }
.party-frame__identity small { font-size: 10px; }
.party-frame__health { height: 14px; }
.party-frame__health b { font-size: 10px; }
.party-command-bar strong { font-size: 11px; }
.party-command-bar kbd { display: none; }
.dungeon-complete-banner { width: min(610px, calc(100% - 24px)); }
.dungeon-complete-banner strong { font-size: 14px; }
.dungeon-complete-banner small { font-size: 11px; }
.dungeon-complete-banner .button, .defeated-prompt__panel .button { min-height: 44px; font-size: 12px; }
.defeated-prompt__panel > small { font-size: 11px; }
.dungeon-results :is(button, select) { min-height: 44px; font-size: 13px; }
.hud-minimap__caption span, .target-frame :is(small, header span) { font-size: 11px; }
.hud-minimap__caption strong { font-size: 13px; }
.target-frame header strong { font-size: 13px; }
.class-hall-screen { display: grid; grid-template-columns: 230px minmax(0, 1fr); grid-template-rows: 54px minmax(0, 1fr) 24px; gap: 10px; }
.class-hall-header { grid-column: 1 / 3; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--front-line); }
.class-hall-header > div:not(.front-brand) { flex: 1; }
.class-hall-header .front-brand { flex-direction: row; }
.class-hall-header > button { padding: 8px 14px; border: 1px solid var(--front-line); border-radius: 6px; color: var(--ink); background: #172b20; }
.class-hall-character { grid-column: 1; grid-row: 2; display: grid; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; overflow: hidden; }
.class-hall-character > .character-preview { height: 100%; min-height: 0; }
.class-hall-character .character-preview figcaption { display: none; }
.class-hall-character > div { padding: 10px; }
.class-hall-character h1 { margin: 4px 0; font-size: 25px; }
.class-hall-character small { display: block; }
.class-hall-content { grid-column: 2; grid-row: 2; min-height: 0; overflow: auto; }
.class-hall-screen > .controller-legend { grid-column: 1 / 3; grid-row: 3; }
.content-manager-screen { display: grid; grid-template-rows: 60px minmax(0, 1fr) 24px; gap: 12px; }
.content-manager-header { display: flex; align-items: center; gap: 16px; }
.content-manager-header .front-brand { flex-direction: row; }
.content-manager-header > div:not(.front-brand) { flex: 1; }
.content-manager-main { display: grid; grid-template-columns: 300px minmax(0, 1fr); min-height: 0; gap: 12px; }
.content-manager-summary, .content-manager-packs { padding: 12px; min-height: 0; overflow: auto; }
.content-manager-summary h1 { font-size: 26px; line-height: 1.15; }
.content-manager-summary__actions { flex-wrap: wrap; gap: 8px; }
}