Compare commits

...
3 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
phenom c7e8163790 Release Healer Man 0.1.42 2026-09-13 15:48:12 -04:00
95 changed files with 3775 additions and 286 deletions

No files matched your search

+264
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, evidence required before calling a visual bug fixed. Update it whenever scene,
camera, input, physics, mob-rendering, or asset-loading code changes. camera, input, physics, mob-rendering, or asset-loading code changes.
## 2026-09-14 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 ## 2026-09-10 Personal UI templates and desktop action bars
- Opened Wailing Caverns through the normal offline roster, dungeon, and role - Opened Wailing Caverns through the normal offline roster, dungeon, and role
@@ -3116,3 +3158,225 @@ Vite's existing large-chunk advisory remains informational.
`artifacts/boss-difficulty/wow-coa-balance.json`, and `artifacts/boss-difficulty/wow-coa-balance.json`, and
`artifacts/boss-difficulty/coa-healer-review.md`. Survival at the 60-second `artifacts/boss-difficulty/coa-healer-review.md`. Survival at the 60-second
cap does not establish a boss clear or indefinite resource sustainability. cap does not establish a boss clear or indefinite resource sustainability.
## WoW action bars in Ability controls — September 13, 2026
- Added Controller bars / WoW bars selection to Ability controls. On PC, the
dialog opens on WoW bars when that UI layout is enabled. The existing three
controller layers remain available through the same screen.
- The WoW editor exposes all five bars and twelve buttons per bar, including
inactive bars. Slots show their saved keyboard shortcuts and assigned
abilities. Assigning or leaving a slot empty saves immediately through the
existing profile UI persistence path and updates the gameplay action bar.
- Restore Bar resets that bar's abilities to the current character's class
defaults; Restore all WoW bars resets all sixty ability slots. Both use a
confirmation with cancellation. Keyboard shortcuts, bar geometry, PC/mobile
UI templates, and controller bindings remain independent of these resets.
- Long WoW ability lists scroll inside the picker. Controller navigation keeps
the selected control visible. Future abilities retain their unlock-level
labels and can be assigned ahead of time; passive abilities are excluded.
- Live 1280x720 production-preview QA used an isolated offline Human Priest
profile at port 5192. Verified first-slot assignment, inactive fifth-bar
button-twelve assignment and clearing, canceling a reset, restoring one bar
while retaining the fifth-bar assignment, and restoring all WoW bars.
Controller Cross retained Smite through the WoW edits and resets.
- Enabled the WoW layout through Edit UI & templates, confirmed Ability controls
opened directly on WoW bars, and changed the first button to Lesser Heal.
The gameplay bar updated immediately. After a full page reload and dungeon
reentry, both the gameplay bar and editor retained Lesser Heal and key 1.
The final picker capture shows access to the late-level abilities and Leave
empty while all bar controls stay visible. No browser UI errors were logged.
- Validation: all 55 UI-layout, action-binding, keybind, profile-sync, input,
HUD-layering, and panel-integration tests passed. The production build,
including TypeScript with the project's configured 8 GB heap, prebuild asset
audits, and postbuild release-bundle validation, passed. A separate manual
TypeScript attempt using Node's default heap exhausted memory; the standard
build completed successfully. Final release hygiene covers all changed paths.
- Evidence: `artifacts/ability-controls/wow-bars.png`,
`artifacts/ability-controls/controller-bars.png`,
`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); if (forceEnabled) scheduleEnsurePresentation(300L);
}); });
presentation.show(); presentation.show();
bridge.getWebView().getSettings().setUseWideViewPort(true);
bridge.getWebView().getSettings().setLoadWithOverviewMode(true);
bridge.getWebView().loadUrl(currentUrl); bridge.getWebView().loadUrl(currentUrl);
} }
@@ -279,6 +281,8 @@ public class ThorDualScreenPlugin extends Plugin implements DisplayManager.Displ
WebSettings settings = webView.getSettings(); WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true); settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true); settings.setDomStorageEnabled(true);
settings.setUseWideViewPort(true);
settings.setLoadWithOverviewMode(true);
settings.setDatabaseEnabled(true); settings.setDatabaseEnabled(true);
settings.setMediaPlaybackRequiresUserGesture(false); settings.setMediaPlaybackRequiresUserGesture(false);
settings.setAllowFileAccess(true); settings.setAllowFileAccess(true);
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.41", "version": "0.1.44",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.41", "version": "0.1.44",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.41", "version": "0.1.44",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.41", "versionName": "0.1.44",
"versionCode": 1041 "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 revision INTEGER NOT NULL DEFAULT 0
) STRICT; ) STRICT;
CREATE INDEX IF NOT EXISTS dungeon_reports_dungeon_status_idx ON dungeon_reports(dungeon_id, status); CREATE INDEX IF NOT EXISTS dungeon_reports_dungeon_status_idx ON dungeon_reports(dungeon_id, status);
CREATE TABLE IF NOT EXISTS boss_testing_reviews (
boss_key TEXT PRIMARY KEY,
dungeon_id TEXT NOT NULL,
boss_name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('ready', 'needs-testing', 'not-ready')),
reviewed_by TEXT NOT NULL,
reviewed_at INTEGER NOT NULL
) STRICT;
`); `);
const columns = new Set(database.prepare("PRAGMA table_info(dungeon_reports)").all().map((column) => column.name));
if (!columns.has("boss_key")) database.exec("ALTER TABLE dungeon_reports ADD COLUMN boss_key TEXT");
if (!columns.has("boss_name")) database.exec("ALTER TABLE dungeon_reports ADD COLUMN boss_name TEXT");
const insert = database.prepare(` const insert = database.prepare(`
INSERT INTO dungeon_reports ( INSERT INTO dungeon_reports (
id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at, boss_key, boss_name
) VALUES (?, ?, ?, 'Submitted', ?, ?, ?, ?) ) VALUES (?, ?, ?, 'Submitted', ?, ?, ?, ?, ?, ?)
`); `);
const byId = database.prepare("SELECT * FROM dungeon_reports WHERE id = ?"); const byId = database.prepare("SELECT * FROM dungeon_reports WHERE id = ?");
const list = database.prepare(` const list = database.prepare(`
@@ -45,6 +56,14 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
function validateStatus(status) { function validateStatus(status) {
if (!policy.statuses.includes(status)) throw new ErrorType("Choose a valid report status.", 400, "invalid_report_status"); if (!policy.statuses.includes(status)) throw new ErrorType("Choose a valid report status.", 400, "invalid_report_status");
} }
function validateBoss(input) {
validateDungeon(input?.dungeonId);
if (typeof input.bossKey !== "string" || !input.bossKey.startsWith(`${input.dungeonId}/`)
|| input.bossKey.length <= input.dungeonId.length + 1 || input.bossKey.length > 256
|| typeof input.bossName !== "string" || !input.bossName.trim() || input.bossName.length > 128) {
throw new ErrorType("Choose a valid boss.", 400, "invalid_boss");
}
}
function publicReport(row) { function publicReport(row) {
return { return {
id: row.id, id: row.id,
@@ -56,10 +75,30 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
updatedAt: Number(row.updated_at), updatedAt: Number(row.updated_at),
updatedBy: row.updated_by_username, updatedBy: row.updated_by_username,
revision: Number(row.revision), revision: Number(row.revision),
...(row.boss_key ? { bossKey: row.boss_key, bossName: row.boss_name } : {}),
}; };
} }
return { return {
listBossTestingReviews() {
return { reviews: database.prepare("SELECT * FROM boss_testing_reviews ORDER BY boss_key").all().map((row) => ({
bossKey: row.boss_key, dungeonId: row.dungeon_id, bossName: row.boss_name,
status: row.status, reviewedBy: row.reviewed_by, reviewedAt: Number(row.reviewed_at),
})) };
},
saveBossTestingReview(account, input) {
validateBoss(input);
if (!["ready", "needs-testing", "not-ready"].includes(input.status)) {
throw new ErrorType("Choose a valid boss readiness status.", 400, "invalid_boss_status");
}
const review = { bossKey: input.bossKey, dungeonId: input.dungeonId, bossName: input.bossName.trim(),
status: input.status, reviewedBy: account?.username ?? "Guest player", reviewedAt: Date.now() };
database.prepare(`INSERT INTO boss_testing_reviews VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(boss_key) DO UPDATE SET boss_name = excluded.boss_name, status = excluded.status,
reviewed_by = excluded.reviewed_by, reviewed_at = excluded.reviewed_at`).run(
review.bossKey, review.dungeonId, review.bossName, review.status, review.reviewedBy, review.reviewedAt);
return review;
},
listDungeonReports({ status = null, dungeonId = null } = {}) { listDungeonReports({ status = null, dungeonId = null } = {}) {
if (status !== null) validateStatus(status); if (status !== null) validateStatus(status);
if (dungeonId !== null) validateDungeon(dungeonId); if (dungeonId !== null) validateDungeon(dungeonId);
@@ -70,13 +109,14 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
}, },
submitDungeonReport(account, input) { submitDungeonReport(account, input) {
validateDungeon(input?.dungeonId); validateDungeon(input?.dungeonId);
if (input?.bossKey !== undefined || input?.bossName !== undefined) validateBoss(input);
if (typeof input?.comment !== "string" || !input.comment.trim() || input.comment.length > policy.commentMaxLength) { if (typeof input?.comment !== "string" || !input.comment.trim() || input.comment.length > policy.commentMaxLength) {
throw new ErrorType("Describe the issue in 1-500 characters.", 400, "invalid_report_comment"); throw new ErrorType("Describe the issue in 1-500 characters.", 400, "invalid_report_comment");
} }
const id = randomUUID(); const id = randomUUID();
const now = Date.now(); const now = Date.now();
insert.run(id, input.dungeonId, input.comment.replace(/\r\n?/g, "\n").trim(), account?.id ?? null, insert.run(id, input.dungeonId, input.comment.replace(/\r\n?/g, "\n").trim(), account?.id ?? null,
account?.username ?? "Guest player", now, now); account?.username ?? "Guest player", now, now, input.bossKey ?? null, input.bossName?.trim() ?? null);
return publicReport(byId.get(id)); return publicReport(byId.get(id));
}, },
updateDungeonReport(account, id, input) { updateDungeonReport(account, id, input) {
+29
View File
@@ -69,6 +69,30 @@ test("persists reports and status changes across database restarts", async () =>
} finally { database?.close(); rmSync(directory, { recursive: true, force: true }); } } finally { database?.close(); rmSync(directory, { recursive: true, force: true }); }
}); });
test("persists boss readiness and structured boss reports without changing ordinary reports", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-boss-feedback-"));
let database;
try {
database = openGameDatabase({ dataDir: directory });
const boss = { dungeonId: "wailing-caverns", bossKey: "wailing-caverns/lady-anacondra", bossName: "Lady Anacondra" };
for (const status of ["ready", "needs-testing", "not-ready"]) {
assert.equal(database.saveBossTestingReview(null, { ...boss, status }).status, status);
}
assert.throws(() => database.saveBossTestingReview(null, { ...boss, status: "Closed" }), { code: "invalid_boss_status" });
assert.throws(() => database.saveBossTestingReview(null, { ...boss, bossKey: "other/boss", status: "ready" }), { code: "invalid_boss" });
const report = database.submitDungeonReport(null, { ...boss, comment: "x".repeat(500) });
assert.equal(report.bossKey, boss.bossKey);
assert.equal(report.bossName, boss.bossName);
const reviews = database.listBossTestingReviews();
database.close();
database = openGameDatabase({ dataDir: directory });
assert.deepEqual(database.listBossTestingReviews(), reviews);
assert.deepEqual(database.listDungeonReports().reports, [report]);
const ordinary = database.submitDungeonReport(null, { dungeonId: boss.dungeonId, comment: "Door issue" });
assert.equal(ordinary.bossKey, undefined);
} finally { database?.close(); rmSync(directory, { recursive: true, force: true }); }
});
test("serves public reports and GM-only status API with shelved modes disabled", async () => { test("serves public reports and GM-only status API with shelved modes disabled", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-api-")); const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-api-"));
const runtime = createGameServer({ dataDir: directory, enableShelvedModes: false }); const runtime = createGameServer({ dataDir: directory, enableShelvedModes: false });
@@ -80,6 +104,11 @@ test("serves public reports and GM-only status API with shelved modes disabled",
}); });
try { try {
const player = await runtime.database.register("ApiPlayer", "report-player"); const player = await runtime.database.register("ApiPlayer", "report-player");
const boss = { dungeonId: "wailing-caverns", bossKey: "wailing-caverns/lady-anacondra", bossName: "Lady Anacondra", status: "ready" };
const saved = await fetch(`${origin}/api/boss-testing/reviews`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(boss) });
assert.equal(saved.status, 200);
const labels = await (await fetch(`${origin}/api/boss-testing/reviews`)).json();
assert.equal(labels.reviews[0].status, "ready");
const gm = await runtime.database.register("Phenom", "report-admin"); const gm = await runtime.database.register("Phenom", "report-admin");
const created = await request("", "POST", { dungeonId: "stormwind-stockade", comment: "Issue via API" }, player.token); const created = await request("", "POST", { dungeonId: "stormwind-stockade", comment: "Issue via API" }, player.token);
assert.equal(created.status, 201); assert.equal(created.status, 201);
+13
View File
@@ -261,6 +261,19 @@ export function createGameServer(options = {}) {
json(response, 200, gameDatabase.getDungeonRun(dungeonRunMatch[1])); json(response, 200, gameDatabase.getDungeonRun(dungeonRunMatch[1]));
return; return;
} }
if (url.pathname === "/api/boss-testing/reviews" && request.method === "GET") {
json(response, 200, gameDatabase.listBossTestingReviews());
return;
}
if (url.pathname === "/api/boss-testing/reviews" && request.method === "POST") {
const token = bearerToken(request);
const account = token ? gameDatabase.authenticate(token) : null;
if (!allowReportRequest(account?.id ?? request.socket.remoteAddress)) {
throw new GameDatabaseError("Too many updates. Please wait a minute and try again.", 429, "rate_limited");
}
json(response, 200, { review: gameDatabase.saveBossTestingReview(account, await readJson(request, 8 * 1024)) });
return;
}
if (url.pathname === "/api/dungeon-reports" && request.method === "GET") { if (url.pathname === "/api/dungeon-reports" && request.method === "GET") {
json(response, 200, gameDatabase.listDungeonReports({ json(response, 200, gameDatabase.listDungeonReports({
status: url.searchParams.get("status"), status: url.searchParams.get("status"),
+11 -5
View File
@@ -1,4 +1,5 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useShellStore } from "./app/shellStore";
import "./ui/uiLayout.css"; import "./ui/uiLayout.css";
import { DualDisplayFrame } from "./components/DualDisplayFrame"; import { DualDisplayFrame } from "./components/DualDisplayFrame";
import { installInput, releasePointerLock } from "./game/inputManager"; import { installInput, releasePointerLock } from "./game/inputManager";
@@ -7,7 +8,8 @@ import { connectDpsMeter } from "./game/dpsMeterStore";
import { useCombatStore } from "./game/combatStore"; import { useCombatStore } from "./game/combatStore";
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache"; import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset"; import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
import { requireDungeonDefinition } from "./game/dungeonRegistry"; import { bossTestingSourceDungeonId, clearBossTestingDungeons, requireDungeonDefinition } from "./game/dungeonRegistry";
import { BossTestingBridge } from "./game/BossTestingBridge";
import { PartyBridge } from "./game/PartyBridge"; import { PartyBridge } from "./game/PartyBridge";
import { usePartyStore } from "./game/partyStore"; import { usePartyStore } from "./game/partyStore";
import { useGameStore } from "./game/store"; import { useGameStore } from "./game/store";
@@ -24,9 +26,7 @@ import { DungeonRunBridge } from "./game/DungeonRunBridge";
import { DungeonRunResults } from "./ui/DungeonRunResults"; import { DungeonRunResults } from "./ui/DungeonRunResults";
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge"; import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction"; import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
import { UiLayoutEditor } from "./ui/UiLayoutEditor"; import { UiLayoutEditor } from "./ui/UiLayoutEditor";
import { EditableHudElement } from "./ui/EditableHudElement";
import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore"; import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore";
import { useUiLayoutStore } from "./game/uiLayoutStore"; import { useUiLayoutStore } from "./game/uiLayoutStore";
import { profileInputBindings } from "./game/keybindPreferences"; import { profileInputBindings } from "./game/keybindPreferences";
@@ -39,7 +39,7 @@ function InputBridge() {
useEffect(() => installInput({ useEffect(() => installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars, desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results"].includes(useGameStore.getState().overlay ?? ""), inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
getBindings: (device) => { getBindings: (device) => {
const preferences = useUiLayoutStore.getState().preferences; const preferences = useUiLayoutStore.getState().preferences;
const bindings = profileInputBindings(preferences, device); const bindings = profileInputBindings(preferences, device);
@@ -103,6 +103,12 @@ export default function GameRuntime() {
clearDungeonRuntimeCaches(); clearDungeonRuntimeCaches();
releaseDungeonGltfCache(activeDungeon); releaseDungeonGltfCache(activeDungeon);
releaseShelvedGameRuntime(); releaseShelvedGameRuntime();
const sourceDungeonId = bossTestingSourceDungeonId(activeDungeon.id);
if (sourceDungeonId) {
useGameStore.setState({ activeDungeonId: sourceDungeonId, bossTest: null, gmTestMode: false });
useShellStore.setState({ activeDungeonId: sourceDungeonId });
}
clearBossTestingDungeons();
}, []); }, []);
return ( return (
<DualDisplayFrame <DualDisplayFrame
@@ -118,10 +124,10 @@ export default function GameRuntime() {
<CombatBridge /> <CombatBridge />
<PartyBridge /> <PartyBridge />
<DungeonRunBridge /> <DungeonRunBridge />
<BossTestingBridge />
<ShelvedGameBridges /> <ShelvedGameBridges />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary> <SceneErrorBoundary><GameScene /></SceneErrorBoundary>
<Hud /> <Hud />
<EditableHudElement id="interaction"><DungeonInteractionPrompt /></EditableHudElement>
<MapPanel /> <MapPanel />
<GameplayPanels /> <GameplayPanels />
<PauseMenu /> <PauseMenu />
+49 -6
View File
@@ -5,6 +5,10 @@ import {
createLocalAccount, createLocalAccount,
createOfflineSession, createOfflineSession,
listCharacters, listCharacters,
loadAccountBossCards,
saveAccountBossCards,
replaceCharactersForOwner,
deleteCharacter,
loadCombatPresentationSettings, loadCombatPresentationSettings,
loginLocalAccount, loginLocalAccount,
restoreSession, restoreSession,
@@ -12,7 +16,6 @@ import {
updateCharacterEquipment, updateCharacterEquipment,
updateCharacterDungeonRunCheckpoint, updateCharacterDungeonRunCheckpoint,
updateCharacterInventory, updateCharacterInventory,
updateCharacterBossCards,
updateCharacterManastormProgress, updateCharacterManastormProgress,
updateCharacterProgression, updateCharacterProgression,
updateCharacterSettings, updateCharacterSettings,
@@ -213,7 +216,7 @@ describe("local profile repository", () => {
})); }));
expect(loadCombatPresentationSettings("offline-roster", storage)) expect(loadCombatPresentationSettings("offline-roster", storage))
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS); .toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6); expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
} }
const legacyV3Storage = new MemoryStorage(); const legacyV3Storage = new MemoryStorage();
@@ -382,18 +385,58 @@ describe("local profile repository", () => {
}); });
}); });
it("persists card copies and claim receipts per character across roster reloads", () => { it("persists account card copies independently of character creation and deletion", () => {
const storage = new MemoryStorage(); const storage = new MemoryStorage();
const offline = createOfflineSession(); const offline = createOfflineSession();
const draft = { ownerId: offline.ownerId, name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } }; const draft = { ownerId: offline.ownerId, name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } };
const character = createCharacter(draft, storage).character!; const character = createCharacter(draft, storage).character!;
const other = createCharacter({ ...draft, name: "Other" }, storage).character!; const other = createCharacter({ ...draft, name: "Other" }, storage).character!;
const bossCards = { counts: { "wow:lady anacondra": { normal: 7, epic: 1 } }, lastClaims: { spawn: "kill:7" } }; const bossCards = { counts: { "wow:lady anacondra": { normal: 7, epic: 1 } }, lastClaims: { spawn: "kill:7" } };
expect(updateCharacterBossCards(offline.ownerId, character.id, bossCards, storage)?.bossCards).toEqual(bossCards); expect(saveAccountBossCards(offline.ownerId, bossCards, storage)).toEqual(bossCards);
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
expect(loadAccountBossCards("different-account", storage)).toEqual({ counts: {}, lastClaims: {} });
const roster = listCharacters(offline.ownerId, storage); const roster = listCharacters(offline.ownerId, storage);
expect(roster.find((entry) => entry.id === character.id)?.bossCards).toEqual(bossCards); expect(roster.find((entry) => entry.id === character.id)?.bossCards).toBeUndefined();
expect(roster.find((entry) => entry.id === other.id)?.bossCards).toEqual({ counts: {}, lastClaims: {} }); expect(roster.find((entry) => entry.id === other.id)?.bossCards).toBeUndefined();
expect(roster.find((entry) => entry.id === character.id)?.inventory).toEqual([]); expect(roster.find((entry) => entry.id === character.id)?.inventory).toEqual([]);
deleteCharacter(offline.ownerId, character.id, storage);
deleteCharacter(offline.ownerId, other.id, storage);
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
createCharacter({ ...draft, name: "Newcomer" }, storage);
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
});
it("migrates and sums all legacy character logs once while retaining the newest receipts", () => {
const storage = new MemoryStorage();
const draft = { ownerId: "one", name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } };
const first = createCharacter(draft, storage).character!;
const second = createCharacter({ ...draft, name: "Other" }, storage).character!;
const separate = createCharacter({ ...draft, ownerId: "two" }, storage).character!;
const legacyCharacters = [
{ ...first, bossCards: { counts: { boss: { normal: 7, epic: 1 } }, lastClaims: { spawn: "100:1:spawn" } } },
{ ...second, bossCards: { counts: { boss: { normal: 3, rare: 2 } }, lastClaims: { spawn: "200:2:spawn" } } },
];
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
version: 6, accounts: [], rosters: { one: legacyCharacters, two: [{ ...separate, bossCards: { counts: { boss: { normal: 4 } }, lastClaims: {} } }] },
}));
const expected = { counts: { boss: { normal: 10, rare: 2, epic: 1 } }, lastClaims: { spawn: "200:2:spawn" } };
expect(loadAccountBossCards("one", storage)).toEqual(expected);
expect(loadAccountBossCards("one", storage)).toEqual(expected);
expect(loadAccountBossCards("two", storage).counts.boss?.normal).toBe(4);
const database = JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!);
expect(database.version).toBe(7);
expect(database.rosters.one.every((entry: { bossCards?: unknown }) => entry.bossCards === undefined)).toBe(true);
// An old cloud roster can be downloaded again without repeating the migration.
replaceCharactersForOwner("one", legacyCharacters, storage);
replaceCharactersForOwner("one", legacyCharacters, storage);
expect(loadAccountBossCards("one", storage)).toEqual(expected);
});
it("restores account logs from cloud without overwriting higher cached counts", () => {
const storage = new MemoryStorage();
saveAccountBossCards("one", { counts: { boss: { normal: 8 } }, lastClaims: { spawn: "200:2:spawn" } }, storage);
replaceCharactersForOwner("one", [], storage, { counts: { boss: { normal: 7, rare: 1 } }, lastClaims: { spawn: "100:1:spawn" } });
expect(loadAccountBossCards("one", storage)).toEqual({ counts: { boss: { normal: 8, rare: 1 } }, lastClaims: { spawn: "200:2:spawn" } });
}); });
it("persists threat-meter visibility and position per character", () => { it("persists threat-meter visibility and position per character", () => {
+50 -33
View File
@@ -1,5 +1,5 @@
import type { CharacterProfile, PlayerSession } from "./types"; import type { CharacterProfile, PlayerSession } from "./types";
import { normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards"; import { mergeBossCardCollections, normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout"; import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
import { import {
contentCategoryForClass, contentCategoryForClass,
@@ -62,9 +62,10 @@ interface LocalAccount {
} }
interface ProfileDatabase { interface ProfileDatabase {
version: 6; version: 7;
accounts: LocalAccount[]; accounts: LocalAccount[];
rosters: Record<string, CharacterProfile[]>; rosters: Record<string, CharacterProfile[]>;
bossCardsByOwner: Record<string, BossCardCollection>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>; presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
uiPreferencesByOwner?: Record<string, UiPreferences>; uiPreferencesByOwner?: Record<string, UiPreferences>;
} }
@@ -105,7 +106,7 @@ function browserSessionStorage(): StorageLike | null {
} }
function emptyDatabase(): ProfileDatabase { function emptyDatabase(): ProfileDatabase {
return { version: 6, accounts: [], rosters: {}, presentationSettingsByOwner: {} }; return { version: 7, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} };
} }
function isCharacterProfile(value: unknown): value is CharacterProfile { function isCharacterProfile(value: unknown): value is CharacterProfile {
@@ -121,6 +122,9 @@ function isCharacterProfile(value: unknown): value is CharacterProfile {
} }
function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile { function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
const profile = { ...value };
// Cards now belong to the owner; legacy fields are consumed before normalization.
delete profile.bossCards;
const legacy = value as CharacterProfile & { const legacy = value as CharacterProfile & {
actionBindings?: unknown; actionBindings?: unknown;
experience?: unknown; experience?: unknown;
@@ -180,7 +184,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
: undefined; : undefined;
const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint); const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint);
return { return {
...value, ...profile,
level, level,
experience, experience,
talentRanks, talentRanks,
@@ -191,7 +195,6 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
actionLoadouts, actionLoadouts,
inventory, inventory,
equipment, equipment,
bossCards: normalizeBossCardCollection(value.bossCards),
manastormProgress, manastormProgress,
dungeonRunCheckpoint, dungeonRunCheckpoint,
settings, settings,
@@ -205,10 +208,11 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
version?: number; version?: number;
accounts?: unknown; accounts?: unknown;
rosters?: unknown; rosters?: unknown;
bossCardsByOwner?: unknown;
presentationSettingsByOwner?: unknown; presentationSettingsByOwner?: unknown;
uiPreferencesByOwner?: unknown; uiPreferencesByOwner?: unknown;
}) | null; }) | null;
if (!parsed || ![1, 2, 3, 4, 5, 6].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") { if (!parsed || ![1, 2, 3, 4, 5, 6, 7].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
return emptyDatabase(); return emptyDatabase();
} }
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean( const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
@@ -220,12 +224,19 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
&& typeof account.verifier.salt === "string" && typeof account.verifier.salt === "string"
&& typeof account.verifier.digest === "string", && typeof account.verifier.digest === "string",
)); ));
const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record<string, unknown>).map(([ownerId, roster]) => [ const rawCards = parsed.bossCardsByOwner && typeof parsed.bossCardsByOwner === "object" && !Array.isArray(parsed.bossCardsByOwner)
ownerId, ? parsed.bossCardsByOwner : {};
Array.isArray(roster) const bossCardsByOwner = Object.fromEntries(Object.entries(rawCards).map(([owner, cards]) => [owner, normalizeBossCardCollection(cards)]));
? roster.filter(isCharacterProfile).map(normalizeCharacterProfile).slice(0, MAX_CHARACTERS) let cardsMigrated = false;
: [], const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record<string, unknown>).map(([ownerId, roster]) => {
])); const characters = Array.isArray(roster) ? roster.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : [];
if (characters.some((character) => character.bossCards !== undefined)) {
const legacyCards = mergeBossCardCollections(characters.map((character) => character.bossCards), "sum");
bossCardsByOwner[ownerId] = mergeBossCardCollections([bossCardsByOwner[ownerId], legacyCards]);
cardsMigrated = true;
}
return [ownerId, characters.map(normalizeCharacterProfile)];
}));
const rawPresentationSettings = parsed.presentationSettingsByOwner const rawPresentationSettings = parsed.presentationSettingsByOwner
&& typeof parsed.presentationSettingsByOwner === "object" && typeof parsed.presentationSettingsByOwner === "object"
&& !Array.isArray(parsed.presentationSettingsByOwner) && !Array.isArray(parsed.presentationSettingsByOwner)
@@ -259,8 +270,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
); );
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {}; const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)])); const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
const database: ProfileDatabase = { version: 6, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner }; const database: ProfileDatabase = { version: 7, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
if (parsed.version !== 6 || presentationSettingsMigrated) writeDatabase(database, storage); if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated) writeDatabase(database, storage);
return database; return database;
} catch { } catch {
return emptyDatabase(); return emptyDatabase();
@@ -270,7 +281,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void { function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void {
storage?.setItem(DATABASE_KEY, JSON.stringify(database)); storage?.setItem(DATABASE_KEY, JSON.stringify(database));
if (!storage || !rosterChangeListener) return; if (!storage || !rosterChangeListener) return;
for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.uiPreferencesByOwner ?? {})])) { for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.bossCardsByOwner), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
rosterChangeListener(ownerId, database.rosters[ownerId] ?? []); rosterChangeListener(ownerId, database.rosters[ownerId] ?? []);
} }
} }
@@ -452,15 +463,19 @@ export function replaceCharactersForOwner(
ownerId: string, ownerId: string,
characters: unknown, characters: unknown,
storage: StorageLike | null = browserLocalStorage(), storage: StorageLike | null = browserLocalStorage(),
accountBossCards?: unknown,
): CharacterProfile[] { ): CharacterProfile[] {
if (!storage) return []; if (!storage) return [];
const database = readDatabase(storage); const database = readDatabase(storage);
database.rosters[ownerId] = Array.isArray(characters) const roster = Array.isArray(characters) ? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : [];
? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS).map((character) => normalizeCharacterProfile({ const importedCards = accountBossCards === undefined
...character, ? mergeBossCardCollections(roster.map((character) => character.bossCards), "sum")
ownerId, : normalizeBossCardCollection(accountBossCards);
})) database.bossCardsByOwner[ownerId] = mergeBossCardCollections([database.bossCardsByOwner[ownerId], importedCards]);
: []; database.rosters[ownerId] = roster.map((character) => normalizeCharacterProfile({
...character,
ownerId,
}));
writeDatabase(database, storage); writeDatabase(database, storage);
return listCharacters(ownerId, storage); return listCharacters(ownerId, storage);
} }
@@ -730,22 +745,24 @@ export function updateCharacterEquipment(
return updated; return updated;
} }
export function updateCharacterBossCards( export function loadAccountBossCards(
ownerId: string,
storage: StorageLike | null = browserLocalStorage(),
): BossCardCollection {
return normalizeBossCardCollection(readDatabase(storage).bossCardsByOwner[ownerId]);
}
export function saveAccountBossCards(
ownerId: string, ownerId: string,
characterId: string,
bossCards: BossCardCollection, bossCards: BossCardCollection,
storage: StorageLike | null = browserLocalStorage(), storage: StorageLike | null = browserLocalStorage(),
): CharacterProfile | null { ): BossCardCollection {
if (!storage) return null; const normalized = normalizeBossCardCollection(bossCards);
if (!storage) return normalized;
const database = readDatabase(storage); const database = readDatabase(storage);
let updated: CharacterProfile | null = null; database.bossCardsByOwner[ownerId] = normalized;
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { writeDatabase(database, storage);
if (character.id !== characterId) return character; return normalized;
updated = normalizeCharacterProfile({ ...character, bossCards });
return updated;
});
if (updated) writeDatabase(database, storage);
return updated;
} }
export function updateCharacterSettings( export function updateCharacterSettings(
+104
View File
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PlayerSession } from "./types";
function storage() {
const values = new Map<string, string>();
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value); },
removeItem: (key: string) => { values.delete(key); },
};
}
const draft = {
ownerId: "account-one", name: "Collector", raceId: "human" as const,
classId: "priest" as const, specializationId: "priest:holy", gender: "female" as const,
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
};
const session: PlayerSession = { kind: "account", ownerId: draft.ownerId, displayName: "Collector", accessToken: "test-token" };
const cards = { counts: { "wow:lady anacondra": { normal: 7, rare: 1 } }, lastClaims: { spawn: "200:1:spawn" } };
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
describe("account boss card integration", () => {
it("keeps earned cards when switching characters and isolates account sign-in", async () => {
vi.stubGlobal("window", { localStorage: storage(), sessionStorage: storage() });
const repository = await import("./accountRepository");
const { useShellStore } = await import("./shellStore");
const { useCombatStore } = await import("../game/combatStore");
const first = repository.createCharacter(draft).character!;
const second = repository.createCharacter({ ...draft, name: "Other" }).character!;
const offlineSession: PlayerSession = { ...session, accessToken: undefined };
const shell = useShellStore.getState();
shell.beginSession(offlineSession);
useShellStore.setState({ phase: "game", activeCharacter: first });
shell.saveAccountBossCards(cards);
shell.returnToCharacters();
shell.selectCharacter(second.id);
expect(useShellStore.getState().bossCards).toEqual(cards);
useCombatStore.getState().initializeCharacter({ ...second, bossCards: useShellStore.getState().bossCards });
useCombatStore.getState().collectSharedBossCards({ bossKey: "wow:lady anacondra", spawnId: "spawn", claimId: "300:1:spawn", rarities: ["normal"] }, "Lady Anacondra", 300);
shell.saveAccountBossCards(useCombatStore.getState().bossCards);
shell.selectCharacter(first.id);
expect(useShellStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(8);
expect(repository.listCharacters(session.ownerId).every((character) => character.bossCards === undefined)).toBe(true);
shell.signOut();
expect(useShellStore.getState().bossCards.counts).toEqual({});
shell.beginSession({ ...offlineSession, ownerId: "account-two" });
expect(useShellStore.getState().bossCards.counts).toEqual({});
shell.beginSession(offlineSession);
expect(useShellStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(8);
});
it("uploads the account collection without characters and restores it on another device", async () => {
const firstDevice = storage();
const secondDevice = storage();
vi.stubGlobal("window", { localStorage: firstDevice });
let cloudData: unknown = { version: 1, characters: [] };
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const repository = await import("./accountRepository");
const client = await import("./onlineAccountClient");
client.activateOnlineSession(session);
repository.saveAccountBossCards(session.ownerId, cards);
await vi.advanceTimersByTimeAsync(501);
expect(cloudData).toMatchObject({ version: 2, characters: [], bossCards: cards });
expect(client.useCloudSaveStatus.getState().state).toBe("synced");
vi.stubGlobal("window", { localStorage: secondDevice });
await client.hydrateOnlineSession(session);
expect(repository.loadAccountBossCards(session.ownerId)).toEqual(cards);
expect(repository.loadAccountBossCards("different-account").counts).toEqual({});
const { collectBossCardDrop } = await import("../game/bossCards");
const restored = repository.loadAccountBossCards(session.ownerId);
expect(collectBossCardDrop(restored, { bossKey: "wow:lady anacondra", spawnId: "spawn", claimId: "200:1:spawn", rarities: ["normal", "rare"] })).toBe(restored);
await client.logoutOnlineSession(session);
});
it("publishes legacy cloud migration once and preserves it through repeated downloads", async () => {
vi.stubGlobal("window", { localStorage: storage() });
const repository = await import("./accountRepository");
const first = repository.createCharacter(draft).character!;
const second = repository.createCharacter({ ...draft, name: "Other" }).character!;
let cloudData: unknown = { version: 1, characters: [
{ ...first, bossCards: cards },
{ ...second, bossCards: { counts: { "wow:lady anacondra": { normal: 3 } }, lastClaims: {} } },
] };
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200 });
}));
const client = await import("./onlineAccountClient");
await client.hydrateOnlineSession(session);
expect(repository.loadAccountBossCards(session.ownerId).counts["wow:lady anacondra"]?.normal).toBe(10);
await vi.advanceTimersByTimeAsync(501);
expect(cloudData).toMatchObject({ version: 2, bossCards: { counts: { "wow:lady anacondra": { normal: 10, rare: 1 } } } });
expect((cloudData as { characters: { bossCards?: unknown }[] }).characters.every((character) => character.bossCards === undefined)).toBe(true);
await client.hydrateOnlineSession(session);
expect(repository.loadAccountBossCards(session.ownerId).counts["wow:lady anacondra"]?.normal).toBe(10);
await client.logoutOnlineSession(session);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { useBossTestingReviews } from "./bossTestingReviews";
import { submitDungeonReport } from "./dungeonReports";
afterEach(() => { vi.unstubAllGlobals(); useBossTestingReviews.setState({ reviews: {} }); });
describe("boss feedback", () => {
const boss = { key: "wailing-caverns/lady-anacondra", sourceDungeonId: "wailing-caverns", name: "Lady Anacondra" };
it("retains offline readiness and syncs it before refreshing server labels", async () => {
const storage = { setItem: vi.fn() };
vi.stubGlobal("window", { localStorage: storage });
const fetch = vi.fn().mockRejectedValueOnce(new Error("offline"));
vi.stubGlobal("fetch", fetch);
expect(await useBossTestingReviews.getState().save(boss, "not-ready", null)).toBe(false);
expect(useBossTestingReviews.getState().reviews[boss.key]).toMatchObject({ status: "not-ready", pending: true });
expect(storage.setItem).toHaveBeenCalled();
const review = { bossKey: boss.key, dungeonId: boss.sourceDungeonId, bossName: boss.name, status: "not-ready", reviewedAt: 1, reviewedBy: "Guest player" };
fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ review }) }).mockResolvedValueOnce({ ok: true, json: async () => ({ reviews: [review] }) });
await useBossTestingReviews.getState().refresh(null);
expect(useBossTestingReviews.getState().reviews[boss.key]).toEqual(review);
expect(fetch.mock.calls[1][1].method).toBe("POST");
expect(fetch.mock.calls[2][0]).toBe("/api/boss-testing/reviews");
});
it("submits boss identity as structured metadata without consuming report text", async () => {
const fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ report: {} }) });
vi.stubGlobal("fetch", fetch);
await submitDungeonReport(null, boss.sourceDungeonId, "x".repeat(500), { bossKey: boss.key, bossName: boss.name });
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ dungeonId: boss.sourceDungeonId, comment: "x".repeat(500), bossKey: boss.key, bossName: boss.name });
});
});
+76
View File
@@ -0,0 +1,76 @@
import { create } from "zustand";
import type { PlayerSession } from "./types";
import { requestOnlineJson } from "./onlineAccountClient";
export const BOSS_TESTING_STATUSES = ["ready", "needs-testing", "not-ready"] as const;
export type BossTestingStatus = (typeof BOSS_TESTING_STATUSES)[number];
export const BOSS_TESTING_STATUS_LABELS: Record<BossTestingStatus, string> = {
ready: "Ready", "needs-testing": "Needs testing", "not-ready": "Not ready",
};
export interface BossTestingReview {
readonly bossKey: string;
readonly dungeonId: string;
readonly bossName: string;
readonly status: BossTestingStatus;
readonly reviewedBy: string;
readonly reviewedAt: number;
readonly pending?: boolean;
}
const CACHE_KEY = "healer-man.boss-testing-reviews.v1";
function readReviews(): Record<string, BossTestingReview> {
try {
const reviews = JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? "{}");
if (!reviews || typeof reviews !== "object" || Array.isArray(reviews)) return {};
return Object.fromEntries(Object.entries(reviews).filter(([key, value]) => {
const review = value as BossTestingReview;
return review?.bossKey === key && typeof review.dungeonId === "string" && typeof review.bossName === "string"
&& BOSS_TESTING_STATUSES.includes(review.status) && Number.isFinite(review.reviewedAt);
})) as Record<string, BossTestingReview>;
} catch { return {}; }
}
function persist(reviews: Record<string, BossTestingReview>) {
try { window.localStorage.setItem(CACHE_KEY, JSON.stringify(reviews)); } catch { /* Storage may be disabled. */ }
}
function sendReview(review: BossTestingReview, session: PlayerSession | null) {
return requestOnlineJson<{ review: BossTestingReview }>("/api/boss-testing/reviews", {
method: "POST", body: JSON.stringify({ bossKey: review.bossKey, dungeonId: review.dungeonId, bossName: review.bossName, status: review.status }),
}, session?.kind === "account" ? session.accessToken : undefined);
}
interface ReviewState {
reviews: Record<string, BossTestingReview>;
refresh: (session: PlayerSession | null) => Promise<void>;
save: (boss: { readonly key: string; readonly sourceDungeonId: string; readonly name: string }, status: BossTestingStatus,
session: PlayerSession | null) => Promise<boolean>;
}
export const useBossTestingReviews = create<ReviewState>((set, get) => ({
reviews: readReviews(),
refresh: async (session) => {
try {
for (const pending of Object.values(get().reviews).filter((review) => review.pending)) {
const { review } = await sendReview(pending, session);
if (get().reviews[pending.bossKey] !== pending) continue;
const reviews = { ...get().reviews, [review.bossKey]: review };
persist(reviews); set({ reviews });
}
const payload = await requestOnlineJson<{ reviews: BossTestingReview[] }>("/api/boss-testing/reviews");
const reviews = { ...get().reviews, ...Object.fromEntries(payload.reviews
.filter((review) => !get().reviews[review.bossKey]?.pending
&& review.reviewedAt >= (get().reviews[review.bossKey]?.reviewedAt ?? 0)).map((review) => [review.bossKey, review])) };
persist(reviews); set({ reviews });
} catch { /* Keep cached labels and sync pending reviews when the connection returns. */ }
},
save: async (boss, status, session) => {
const pending: BossTestingReview = { bossKey: boss.key, dungeonId: boss.sourceDungeonId, bossName: boss.name, status,
reviewedAt: Date.now(), reviewedBy: session?.displayName ?? "Guest player", pending: true };
let reviews = { ...get().reviews, [boss.key]: pending };
persist(reviews); set({ reviews });
try {
const { review } = await sendReview(pending, session);
if (get().reviews[boss.key] === pending) {
reviews = { ...get().reviews, [boss.key]: review };
persist(reviews); set({ reviews });
}
return true;
} catch { return false; }
},
}));
+27
View File
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { prepareBossTesting } from "./bossTestingRuntime";
import { BOSS_TESTING_CATALOG } from "../game/bossTesting";
import * as content from "../content/contentManager";
afterEach(() => vi.restoreAllMocks());
describe("boss asset preparation", () => {
const boss = BOSS_TESTING_CATALOG[0];
it("automatically downloads and verifies only the selected boss's dungeon pack", async () => {
const usable = vi.spyOn(content, "dungeonContentIsUsable").mockReturnValueOnce(false).mockReturnValueOnce(false).mockReturnValue(true);
vi.spyOn(content, "initializeContentSystem").mockResolvedValue();
const install = vi.spyOn(content, "installContentPack").mockResolvedValue();
await prepareBossTesting(boss);
expect(install).toHaveBeenCalledExactlyOnceWith(`dungeon:${boss.dungeonId}`);
expect(usable).toHaveBeenCalledWith(boss.dungeonId);
});
it("reuses installed content and stops entry after failed verification", async () => {
const usable = vi.spyOn(content, "dungeonContentIsUsable").mockReturnValue(true);
vi.spyOn(content, "initializeContentSystem").mockResolvedValue();
const install = vi.spyOn(content, "installContentPack").mockResolvedValue();
await prepareBossTesting(boss);
expect(install).not.toHaveBeenCalled();
usable.mockReturnValue(false);
await expect(prepareBossTesting(boss)).rejects.toThrow(/verified/);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { useShellStore } from "./shellStore";
import { specializationById } from "../game/specializationCatalog";
import { defaultPartyRoleForClass, type PartyRole } from "../game/partyRoles";
import { createBossTestingRoom, type TestableBoss } from "../game/bossTesting";
import { clearBossTestingDungeons, registerBossTestingDungeon, requireDungeonDefinition } from "../game/dungeonRegistry";
import { activateDungeonSession } from "../game/dungeonSession";
import { dungeonContentIsUsable, initializeContentSystem, installContentPack } from "../content/contentManager";
let preparationQueue: Promise<void> = Promise.resolve();
export async function prepareBossTesting(boss: TestableBoss): Promise<void> {
if (dungeonContentIsUsable(boss.dungeonId)) return;
const preparation = preparationQueue.then(async () => {
await initializeContentSystem();
if (!dungeonContentIsUsable(boss.dungeonId)) await installContentPack(`dungeon:${boss.dungeonId}`);
if (!dungeonContentIsUsable(boss.dungeonId)) throw new Error("The boss assets could not be verified. Please retry the download.");
});
preparationQueue = preparation.catch(() => undefined);
await preparation;
}
export function enterBossTesting(boss: TestableBoss, requestedRole?: PartyRole): boolean {
const shell = useShellStore.getState();
const character = shell.characters.find((entry) => entry.id === shell.selectedCharacterId);
if (!shell.session || !character) throw new Error("Select a character first.");
const specialization = specializationById(character.specializationId);
if (character.categoryId !== "rom" && !specialization) throw new Error("Choose a specialization in the Class Hall first.");
const { definition, session } = createBossTestingRoom(requireDungeonDefinition(boss.dungeonId), boss);
registerBossTestingDungeon(definition, boss.dungeonId);
if (!activateDungeonSession(definition.id, undefined, { gmTestMode: true, bossTest: session })) {
clearBossTestingDungeons();
throw new Error("The boss testing room could not be started. Check the downloaded assets and retry.");
}
useShellStore.setState({ phase: "game", activeCharacter: character, activeDungeonId: definition.id,
activeDungeonRole: specialization?.role ?? requestedRole ?? defaultPartyRoleForClass(character.classId), notice: "" });
return true;
}
+5 -2
View File
@@ -16,6 +16,8 @@ export interface DungeonReport {
readonly updatedAt: number; readonly updatedAt: number;
readonly updatedBy: string | null; readonly updatedBy: string | null;
readonly revision: number; readonly revision: number;
readonly bossKey?: string;
readonly bossName?: string;
} }
export interface DungeonReportSnapshot { export interface DungeonReportSnapshot {
@@ -40,10 +42,11 @@ export function fetchDungeonReports(): Promise<DungeonReportSnapshot> {
return requestOnlineJson<DungeonReportSnapshot>("/api/dungeon-reports"); return requestOnlineJson<DungeonReportSnapshot>("/api/dungeon-reports");
} }
export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string): Promise<DungeonReport> { export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string,
boss?: { readonly bossKey: string; readonly bossName: string }): Promise<DungeonReport> {
const response = await requestOnlineJson<{ report: DungeonReport }>("/api/dungeon-reports", { const response = await requestOnlineJson<{ report: DungeonReport }>("/api/dungeon-reports", {
method: "POST", method: "POST",
body: JSON.stringify({ dungeonId, comment }), body: JSON.stringify({ dungeonId, comment, ...boss }),
}, session?.kind === "account" ? session.accessToken : undefined); }, session?.kind === "account" ? session.accessToken : undefined);
return response.report; return response.report;
} }
+113
View File
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createDefaultAppearance } from "./characterCatalog";
import {
createCharacter,
listCharacters,
updateCharacterProgression,
type StorageLike,
} from "./accountRepository";
import { beginMainMenuCharacterEditing } from "./mainMenuCharacterEditing";
import { selectedCharacter, useShellStore } from "./shellStore";
import { useCombatStore } from "../game/combatStore";
import { talentNodesForTree } from "../game/talentCatalog";
class MemoryStorage implements StorageLike {
values = new Map<string, string>();
getItem(key: string) { return this.values.get(key) ?? null; }
setItem(key: string, value: string) { this.values.set(key, value); }
removeItem(key: string) { this.values.delete(key); }
}
describe("main-menu character editing", () => {
let storage: MemoryStorage;
let stop: (() => void) | null;
const holyTalent = talentNodesForTree("priest-holy")[0];
beforeEach(() => {
storage = new MemoryStorage();
stop = null;
const created = createCharacter({
ownerId: "offline-roster", name: "Menuhealer", classId: "priest",
raceId: "human", gender: "female", appearance: createDefaultAppearance(),
specializationId: "priest:holy",
}, storage).character!;
const character = updateCharacterProgression(created.ownerId, created.id, {
level: 17, experience: 123, talentRanks: {},
}, storage)!;
useShellStore.setState({
phase: "main-menu", activeCharacter: null, selectedCharacterId: character.id,
session: { kind: "offline", ownerId: character.ownerId, displayName: "Offline" },
characters: [character], notice: "",
});
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
});
afterEach(() => { stop?.(); });
it("loads the selected character without rewriting their saved profile or starting a dungeon", () => {
const before = [...storage.values];
stop = beginMainMenuCharacterEditing(storage);
expect(stop).not.toBeNull();
expect(useCombatStore.getState()).toMatchObject({ classId: "priest", level: 17, xp: 123 });
expect([...storage.values]).toEqual(before);
expect(useShellStore.getState()).toMatchObject({ phase: "main-menu", activeCharacter: null });
});
it("persists talent allocation and reset, and reloads the saved build when reopened", () => {
stop = beginMainMenuCharacterEditing(storage);
expect(useCombatStore.getState().allocateTalent(holyTalent.id)).toBe(true);
expect(listCharacters("offline-roster", storage)[0].talentRanks[holyTalent.id]).toBe(1);
expect(selectedCharacter(useShellStore.getState())?.talentRanks[holyTalent.id]).toBe(1);
stop!();
stop = beginMainMenuCharacterEditing(storage);
expect(useCombatStore.getState().talentRanks[holyTalent.id]).toBe(1);
useCombatStore.getState().resetTalents();
expect(listCharacters("offline-roster", storage)[0].talentRanks).toEqual({});
expect(listCharacters("offline-roster", storage)[0]).toMatchObject({ level: 17, experience: 123 });
});
it("persists controller assignments and reloads them without touching progression or equipment", () => {
const profile = selectedCharacter(useShellStore.getState())!;
stop = beginMainMenuCharacterEditing(storage);
expect(useCombatStore.getState().setActionBinding("primary", "face-bottom", "priest-flash-heal")).toBe(true);
const saved = listCharacters("offline-roster", storage)[0];
expect(saved.actionBindings["primary:face-bottom"]).toBe("priest-flash-heal");
expect(saved).toMatchObject({ level: profile.level, experience: profile.experience,
talentRanks: profile.talentRanks, inventory: profile.inventory, equipment: profile.equipment });
stop!();
stop = beginMainMenuCharacterEditing(storage);
expect(useCombatStore.getState().actionBindings["primary:face-bottom"]).toBe("priest-flash-heal");
});
it.each(["phase", "owner", "selection", "closed"])("stops saving after the editing context changes: %s", (change) => {
stop = beginMainMenuCharacterEditing(storage);
const before = [...storage.values];
if (change === "phase") useShellStore.setState({ phase: "game" });
if (change === "owner") useShellStore.setState({ session: { kind: "offline", ownerId: "another-owner", displayName: "Other" } });
if (change === "selection") useShellStore.setState({ selectedCharacterId: null });
if (change === "closed") stop!();
useCombatStore.getState().allocateTalent(holyTalent.id);
useCombatStore.getState().setActionBinding("primary", "face-bottom", "priest-flash-heal");
expect([...storage.values]).toEqual(before);
});
it("never saves combat XP as a side effect of a talent edit", () => {
stop = beginMainMenuCharacterEditing(storage);
useCombatStore.setState({ xp: 999 });
useCombatStore.getState().allocateTalent(holyTalent.id);
expect(listCharacters("offline-roster", storage)[0].experience).toBe(123);
});
it("rejects editing without a selected, owned main-menu character", () => {
useShellStore.setState({ selectedCharacterId: null });
expect(beginMainMenuCharacterEditing(storage)).toBeNull();
expect(useCombatStore.getState().classId).toBe("mage");
});
it("reports storage failures instead of updating the roster as though the save succeeded", () => {
stop = beginMainMenuCharacterEditing(storage);
storage.setItem = () => { throw new Error("Storage full"); };
useCombatStore.getState().allocateTalent(holyTalent.id);
expect(useShellStore.getState().notice).toMatch(/could not save/i);
expect(selectedCharacter(useShellStore.getState())?.talentRanks).toEqual({});
});
});
+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, replaceCharactersForOwner,
setRosterChangeListener, setRosterChangeListener,
loadUiPreferences, loadUiPreferences,
loadAccountBossCards,
saveUiPreferences, saveUiPreferences,
type AccountResult, type AccountResult,
} from "./accountRepository"; } from "./accountRepository";
@@ -23,7 +24,7 @@ interface AuthResponse {
interface CloudSaveResponse { interface CloudSaveResponse {
revision: number; revision: number;
updatedAt: number | null; updatedAt: number | null;
data: { version?: number; characters?: unknown; uiPreferences?: unknown }; data: { version?: number; characters?: unknown; uiPreferences?: unknown; bossCards?: unknown };
} }
interface MeResponse { interface MeResponse {
@@ -106,11 +107,12 @@ function flushCloudSave(): void {
pendingCharacters = null; pendingCharacters = null;
if (!session?.accessToken || !characters) return; if (!session?.accessToken || !characters) return;
const uiPreferences = loadUiPreferences(session.ownerId); const uiPreferences = loadUiPreferences(session.ownerId);
const bossCards = loadAccountBossCards(session.ownerId);
const sequence = saveSequence; const sequence = saveSequence;
saveQueue = saveQueue.then(async () => { saveQueue = saveQueue.then(async () => {
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", { await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
method: "PUT", method: "PUT",
body: JSON.stringify({ data: { version: 1, characters, uiPreferences } }), body: JSON.stringify({ data: { version: 2, characters, uiPreferences, bossCards } }),
}, session.accessToken); }, session.accessToken);
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." }); if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." });
}).catch((error) => { }).catch((error) => {
@@ -149,12 +151,18 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise<Char
activateOnlineSession(session); activateOnlineSession(session);
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken); const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
suppressUpload = true; suppressUpload = true;
let characters: CharacterProfile[];
try { try {
saveUiPreferences(session.ownerId, cloud.data?.uiPreferences); saveUiPreferences(session.ownerId, cloud.data?.uiPreferences);
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []); characters = replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? [], undefined, cloud.data?.bossCards);
} finally { } finally {
suppressUpload = false; suppressUpload = false;
} }
// Publish migrated logs even if the player has not earned another card yet.
if (JSON.stringify(cloud.data?.bossCards) !== JSON.stringify(loadAccountBossCards(session.ownerId))) {
queueCloudSave(session.ownerId, characters);
}
return characters;
} }
export async function refreshOnlineSession(session: PlayerSession): Promise<PlayerSession> { export async function refreshOnlineSession(session: PlayerSession): Promise<PlayerSession> {
+1 -1
View File
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
"priest:none": { "primary:face-bottom": "priest-flash-heal" }, "priest:none": { "primary:face-bottom": "priest-flash-heal" },
}, },
}); });
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6); expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
}); });
it("locks selection to level ten and preserves separate bars through atomic swaps", () => { it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
+19 -15
View File
@@ -5,13 +5,14 @@ import {
createCharacter as persistCharacter, createCharacter as persistCharacter,
deleteCharacter as removeCharacter, deleteCharacter as removeCharacter,
listCharacters, listCharacters,
loadAccountBossCards,
loadCombatPresentationSettings, loadCombatPresentationSettings,
restoreSession as restoreStoredSession, restoreSession as restoreStoredSession,
saveSession, saveSession,
updateCharacterActionBindings, updateCharacterActionBindings,
updateCharacterEquipment, updateCharacterEquipment,
updateCharacterInventory, updateCharacterInventory,
updateCharacterBossCards, saveAccountBossCards as persistAccountBossCards,
updateCharacterManastormProgress, updateCharacterManastormProgress,
updateCharacterDungeonRunCheckpoint, updateCharacterDungeonRunCheckpoint,
updateCharacterProgression, updateCharacterProgression,
@@ -39,7 +40,7 @@ import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from
import type { DungeonId } from "../game/dungeonRegistry"; import type { DungeonId } from "../game/dungeonRegistry";
import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults"; import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults";
import type { InventoryItem } from "../game/lootTypes"; import type { InventoryItem } from "../game/lootTypes";
import type { BossCardCollection } from "../game/bossCards"; import { emptyBossCardCollection, type BossCardCollection } from "../game/bossCards";
import type { EquipmentAssignments } from "../game/equipment"; import type { EquipmentAssignments } from "../game/equipment";
import type { GameplaySettings } from "../game/combatStore"; import type { GameplaySettings } from "../game/combatStore";
import { import {
@@ -70,6 +71,7 @@ export interface ShellState {
phase: ShellPhase; phase: ShellPhase;
session: PlayerSession | null; session: PlayerSession | null;
characters: CharacterProfile[]; characters: CharacterProfile[];
bossCards: BossCardCollection;
selectedCharacterId: string | null; selectedCharacterId: string | null;
activeCharacter: CharacterProfile | null; activeCharacter: CharacterProfile | null;
draft: CharacterDraft; draft: CharacterDraft;
@@ -111,7 +113,7 @@ export interface ShellState {
saveActiveProgression: (level: number, experience: number, talentRanks: Record<string, number>) => void; saveActiveProgression: (level: number, experience: number, talentRanks: Record<string, number>) => void;
saveActiveActionBindings: (actionBindings: Readonly<Record<string, string | null>>) => void; saveActiveActionBindings: (actionBindings: Readonly<Record<string, string | null>>) => void;
saveActiveInventory: (inventory: readonly InventoryItem[]) => void; saveActiveInventory: (inventory: readonly InventoryItem[]) => void;
saveActiveBossCards: (bossCards: BossCardCollection) => void; saveAccountBossCards: (bossCards: BossCardCollection) => void;
saveActiveEquipment: (equipment: EquipmentAssignments) => void; saveActiveEquipment: (equipment: EquipmentAssignments) => void;
saveActiveSettings: (settings: GameplaySettings) => void; saveActiveSettings: (settings: GameplaySettings) => void;
updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => void; updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => void;
@@ -138,12 +140,13 @@ function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
}; };
} }
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "selectedCharacterId" | "combatPresentationSettings"> { function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "bossCards" | "selectedCharacterId" | "combatPresentationSettings"> {
const session = restoreStoredSession(); const session = restoreStoredSession();
if (!session) return { if (!session) return {
phase: "login", phase: "login",
session: null, session: null,
characters: [], characters: [],
bossCards: emptyBossCardCollection(),
selectedCharacterId: null, selectedCharacterId: null,
combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS, combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS,
}; };
@@ -152,6 +155,7 @@ function restoredState(): Pick<ShellState, "phase" | "session" | "characters" |
phase: "characters", phase: "characters",
session, session,
characters, characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters[0]?.id ?? null, selectedCharacterId: characters[0]?.id ?? null,
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId), combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
}; };
@@ -174,6 +178,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
phase: "characters", phase: "characters",
session, session,
characters, characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters[0]?.id ?? null, selectedCharacterId: characters[0]?.id ?? null,
activeCharacter: null, activeCharacter: null,
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId), combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
@@ -189,6 +194,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
phase: "login", phase: "login",
session: null, session: null,
characters: [], characters: [],
bossCards: emptyBossCardCollection(),
selectedCharacterId: null, selectedCharacterId: null,
activeCharacter: null, activeCharacter: null,
draft: defaultDraft(), draft: defaultDraft(),
@@ -276,6 +282,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ set({
phase: "characters", phase: "characters",
characters, characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: result.character.id, selectedCharacterId: result.character.id,
draft: defaultDraft(), draft: defaultDraft(),
notice: `${result.character.name} is ready for the expedition.`, notice: `${result.character.name} is ready for the expedition.`,
@@ -439,7 +446,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
return true; return true;
}, },
returnToMainMenu: () => { returnToMainMenu: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false }); useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame(); if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame();
const { session, characters, activeCharacter, selectedCharacterId } = get(); const { session, characters, activeCharacter, selectedCharacterId } = get();
if (!session) { if (!session) {
@@ -477,7 +484,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
}, },
enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID), enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID),
returnToCharacters: () => { returnToCharacters: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false }); useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
const session = get().session; const session = get().session;
if (!session) { if (!session) {
set({ phase: "login", activeCharacter: null }); set({ phase: "login", activeCharacter: null });
@@ -488,6 +495,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ set({
phase: "characters", phase: "characters",
characters, characters,
bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters.some((character) => character.id === currentId) selectedCharacterId: characters.some((character) => character.id === currentId)
? currentId ? currentId
: characters[0]?.id ?? null, : characters[0]?.id ?? null,
@@ -538,15 +546,10 @@ export const useShellStore = create<ShellState>((set, get) => ({
characters: state.characters.map((character) => character.id === updated.id ? updated : character), characters: state.characters.map((character) => character.id === updated.id ? updated : character),
})); }));
}, },
saveActiveBossCards: (bossCards) => { saveAccountBossCards: (bossCards) => {
const { session, activeCharacter } = get(); const { session } = get();
if (!session || !activeCharacter) return; if (!session) return;
const updated = updateCharacterBossCards(session.ownerId, activeCharacter.id, bossCards); set({ bossCards: persistAccountBossCards(session.ownerId, bossCards) });
if (!updated) return;
set((state) => ({
activeCharacter: updated,
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
}));
}, },
saveActiveEquipment: (equipment) => { saveActiveEquipment: (equipment) => {
const { session, activeCharacter } = get(); const { session, activeCharacter } = get();
@@ -651,6 +654,7 @@ if (restored.session?.accessToken) {
useShellStore.setState({ useShellStore.setState({
session: refreshedSession, session: refreshedSession,
characters, characters,
bossCards: loadAccountBossCards(refreshedSession.ownerId),
selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId) selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId)
? useShellStore.getState().selectedCharacterId ? useShellStore.getState().selectedCharacterId
: characters[0]?.id ?? null, : characters[0]?.id ?? null,
+1
View File
@@ -42,6 +42,7 @@ export interface CharacterProfile {
actionLoadouts?: Record<string, Record<string, string | null>>; actionLoadouts?: Record<string, Record<string, string | null>>;
inventory: InventoryItem[]; inventory: InventoryItem[];
equipment: EquipmentAssignments; equipment: EquipmentAssignments;
/** Legacy character log, read only during migration to the account collection. */
bossCards?: BossCardCollection; bossCards?: BossCardCollection;
/** Present on normalized and newly created profiles; optional for legacy callers. */ /** Present on normalized and newly created profiles; optional for legacy callers. */
manastormProgress?: ManastormProgress; manastormProgress?: ManastormProgress;
+6 -5
View File
@@ -5,6 +5,7 @@ import { controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime"; import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import { useInputBindingLabels } from "../input/useInputBindingLabels"; import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { resolveDisplayLayout } from "../platform/displayLayout"; import { resolveDisplayLayout } from "../platform/displayLayout";
import { THOR_DISPLAYS } from "../platform/thorDisplayProfile";
import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting"; import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
interface DualDisplayFrameProps { interface DualDisplayFrameProps {
@@ -136,12 +137,12 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
} }
return ( return (
<div className="thor-preview-frame"> <div className="thor-preview-frame" style={{ width: THOR_DISPLAYS.top.cssWidth + 80 }}>
<div className="thor-preview-label"><b>Main display</b><span>960 x 540 CSS / 1920 x 1080</span></div> <div className="thor-preview-label"><b>Top · 6 inches</b><span>1920 × 1080 · {THOR_DISPLAYS.top.widthMm.toFixed(1)} × {THOR_DISPLAYS.top.heightMm.toFixed(1)} mm</span></div>
<div className="thor-preview-top">{top}</div> <div className="thor-preview-top" style={{ width: THOR_DISPLAYS.top.cssWidth, height: THOR_DISPLAYS.top.cssHeight }}>{top}</div>
<div className="thor-preview-hinge"><i /><b>AYN THOR</b><i /></div> <div className="thor-preview-hinge"><i /><b>AYN THOR</b><i /></div>
<div className="thor-preview-label"><b>Companion display</b><span>620 x 540 CSS / 1240 x 1080</span></div> <div className="thor-preview-label"><b>Bottom · 3.92 inches</b><span>1240 × 1080 · {THOR_DISPLAYS.bottom.widthMm.toFixed(1)} × {THOR_DISPLAYS.bottom.heightMm.toFixed(1)} mm</span></div>
<div className="thor-preview-bottom">{bottom}</div> <div className="thor-preview-bottom" style={{ width: THOR_DISPLAYS.bottom.cssWidth, height: THOR_DISPLAYS.bottom.cssHeight }}>{bottom}</div>
</div> </div>
); );
} }
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from "react";
import { useCombatStore } from "./combatStore";
import { useGameStore } from "./store";
export function bossTestIsDefeated(): boolean {
const test = useGameStore.getState().bossTest;
const mobs = useCombatStore.getState().mobs;
return Boolean(test?.runtimeIds.length && test.runtimeIds.every((id) => mobs[id]?.dead));
}
export function BossTestingBridge() {
useEffect(() => {
let defeated = false;
const observe = () => {
const next = bossTestIsDefeated();
if (next && !defeated) useGameStore.getState().openOverlay("boss-test-review");
defeated = next;
};
const unsubscribeCombat = useCombatStore.subscribe(observe);
const unsubscribeGame = useGameStore.subscribe((state, previous) => {
if (state.encounterResetRevision !== previous.encounterResetRevision || state.bossTest !== previous.bossTest) defeated = false;
});
observe();
return () => { unsubscribeCombat(); unsubscribeGame(); };
}, []);
return null;
}
+7 -3
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import { useShellStore } from "../app/shellStore"; import { useShellStore } from "../app/shellStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore"; import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { useCombatStore } from "./combatStore"; import { useCombatStore } from "./combatStore";
import { activateCharacterUi } from "./uiLayoutStore";
import { advanceDungeonPartyCombat } from "./partyRuntime"; import { advanceDungeonPartyCombat } from "./partyRuntime";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout"; import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
@@ -40,7 +41,8 @@ export function CombatBridge() {
useEffect(() => { useEffect(() => {
if (!character) return; if (!character) return;
useCombatStore.getState().initializeCharacter({ ...character, role: activeRole }); useCombatStore.getState().initializeCharacter({ ...character, bossCards: useShellStore.getState().bossCards, role: activeRole });
activateCharacterUi(character);
useCombatStore.getState().synchronizeActors(); useCombatStore.getState().synchronizeActors();
if (gameMode === "manastorm") { if (gameMode === "manastorm") {
activateManastormActiveSpellLoadout( activateManastormActiveSpellLoadout(
@@ -66,12 +68,14 @@ export function CombatBridge() {
useEffect(() => { useEffect(() => {
if (!character) return; if (!character) return;
const characterId = character.id; const characterId = character.id;
const ownerId = useShellStore.getState().session?.ownerId;
const unsubscribe = useCombatStore.subscribe((state, previousState) => { const unsubscribe = useCombatStore.subscribe((state, previousState) => {
// Save APIs intentionally target the active character. Avoid persisting a // Save APIs intentionally target the active character. Avoid persisting a
// final combat notification during the render-to-effect-cleanup window // final combat notification during the render-to-effect-cleanup window
// after Character Select has already replaced the active character. // after Character Select has already replaced the active character.
const shell = useShellStore.getState(); const shell = useShellStore.getState();
if (shell.activeCharacter?.id !== characterId) return; if (shell.activeCharacter?.id !== characterId || shell.session?.ownerId !== ownerId) return;
if (useGameStore.getState().bossTest) return;
// Persisted collections are replaced immutably by combat actions. A // Persisted collections are replaced immutably by combat actions. A
// reference/primitive comparison therefore detects the same changes as // reference/primitive comparison therefore detects the same changes as
@@ -101,7 +105,7 @@ export function CombatBridge() {
shell.saveActiveInventory(state.inventory); shell.saveActiveInventory(state.inventory);
} }
if (state.bossCards !== previousState.bossCards) { if (state.bossCards !== previousState.bossCards) {
shell.saveActiveBossCards(state.bossCards); shell.saveAccountBossCards(state.bossCards);
} }
if (state.equipment !== previousState.equipment) { if (state.equipment !== previousState.equipment) {
shell.saveActiveEquipment(state.equipment); shell.saveActiveEquipment(state.equipment);
+3 -2
View File
@@ -246,6 +246,7 @@ function applyRelayEvent(
} }
export function OnlineSessionBridge() { export function OnlineSessionBridge() {
const bossTest = useGameStore((state) => state.bossTest);
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const activeActivity = useOnlineGroupStore((state) => state.activeActivity); const activeActivity = useOnlineGroupStore((state) => state.activeActivity);
const group = useOnlineGroupStore((state) => state.snapshot.group); const group = useOnlineGroupStore((state) => state.snapshot.group);
@@ -256,7 +257,7 @@ export function OnlineSessionBridge() {
const applyingRef = useRef(false); const applyingRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!session?.accessToken || !activeActivity || !group) { if (bossTest || !session?.accessToken || !activeActivity || !group) {
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null }); configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
reset(); reset();
return undefined; return undefined;
@@ -362,7 +363,7 @@ export function OnlineSessionBridge() {
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null }); configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
reset(); reset();
}; };
}, [accept, activeActivity?.id, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]); }, [accept, activeActivity?.id, bossTest, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]);
return null; return null;
} }
+1 -1
View File
@@ -28,7 +28,7 @@ export async function runDungeonBossBenchmark(
const dungeon = requireDungeonDefinition("ragefire-chasm"); const dungeon = requireDungeonDefinition("ragefire-chasm");
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)!; const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)!;
const entity = dungeon.entities[spawn.entityId]; 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 specs = specializationsForClass(build.classId).filter((spec) => spec.role === build.role);
const specialization = specs.find((spec) => spec.displayName === build.orientation) ?? specs[0]; const specialization = specs.find((spec) => spec.displayName === build.orientation) ?? specs[0];
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); resetCombatSpatialProvider(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); resetCombatSpatialProvider();
+29
View File
@@ -49,6 +49,35 @@ export function normalizeBossCardCollection(value: unknown): BossCardCollection
return { counts, lastClaims }; return { counts, lastClaims };
} }
/** Sum independent legacy character logs; max reconciles copies of the same account log. */
export function mergeBossCardCollections(collections: readonly unknown[], mode: "sum" | "max" = "max"): BossCardCollection {
const counts: Record<string, BossCardCounts> = Object.create(null);
const lastClaims: Record<string, string> = Object.create(null);
for (const value of collections) {
const collection = normalizeBossCardCollection(value);
for (const [bossKey, entry] of Object.entries(collection.counts)) {
const merged = counts[bossKey] ??= {};
for (const rarity of BOSS_CARD_RARITIES) {
const count = entry[rarity];
if (count === undefined) continue;
merged[rarity] = mode === "sum"
? Math.min(Number.MAX_SAFE_INTEGER, (merged[rarity] ?? 0) + count)
: Math.max(merged[rarity] ?? 0, count);
}
}
for (const [spawnId, claim] of Object.entries(collection.lastClaims)) {
const previous = lastClaims[spawnId];
if (!previous) { lastClaims[spawnId] = claim; continue; }
if (/^\d+:/.test(previous) && /^\d+:/.test(claim)) {
const [previousTime, previousRevision] = previous.split(":").map(Number);
const [time, revision] = claim.split(":").map(Number);
if (time > previousTime || (time === previousTime && revision > previousRevision)) lastClaims[spawnId] = claim;
} else if (claim > previous) lastClaims[spawnId] = claim;
}
}
return { counts, lastClaims };
}
/** Independent bonus rolls; Normal is always awarded, including with all three bonuses. */ /** Independent bonus rolls; Normal is always awarded, including with all three bonuses. */
export function rollBossCards(random: () => number = Math.random): readonly BossCardRarity[] { export function rollBossCards(random: () => number = Math.random): readonly BossCardRarity[] {
return BOSS_CARD_RARITIES.filter((rarity) => { return BOSS_CARD_RARITIES.filter((rarity) => {
+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 health = mob.health - applied;
const killed = health <= 0; const killed = health <= 0;
const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt; const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt;
const canAward = killed && !mob.experienceGranted; const testing = Boolean(useGameStore.getState().bossTest);
const canAward = killed && !mob.experienceGranted && !testing;
const beforeLevel = work.progression.level; const beforeLevel = work.progression.level;
const pendingLoot = killed && mob.lootSourceId const pendingLoot = testing ? [] : killed && mob.lootSourceId
? rollBossLoot(mob.lootSourceId, beforeLevel, { ? rollBossLoot(mob.lootSourceId, beforeLevel, {
now, now,
bonusItemChance: mob.bonusLootChance, bonusItemChance: mob.bonusLootChance,
}) })
: mob.pendingLoot; : mob.pendingLoot;
const card = killed && mob.boss const card = killed && mob.boss && !testing
? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name) ? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name)
: null; : null;
const bossCardDrop = card ? { const bossCardDrop = card ? {
@@ -2648,7 +2649,7 @@ function damageMobInWork(
claimId: `${now}:${mob.deathRevision + 1}:${mob.id}`, claimId: `${now}:${mob.deathRevision + 1}:${mob.id}`,
rarities: rollBossCards(), rarities: rollBossCards(),
} satisfies BossCardDrop : mob.bossCardDrop; } satisfies BossCardDrop : mob.bossCardDrop;
const hasLoot = killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot; const hasLoot = testing ? false : killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot;
const xpAwarded = canAward ? grantMobExperience(classId, work, mob.xpReward) : 0; const xpAwarded = canAward ? grantMobExperience(classId, work, mob.xpReward) : 0;
engageMobGroupInWork(work, id, now, source.actorId); engageMobGroupInWork(work, id, now, source.actorId);
const engagedMob = work.mobs[id] ?? mob; const engagedMob = work.mobs[id] ?? mob;
+5 -5
View File
@@ -1,6 +1,6 @@
import { afterAll, describe, expect, it } from "vitest"; import { afterAll, describe, expect, it } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs"; 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 { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
import { runDungeonBossBenchmark } from "./benchmark/dungeonBossBenchmark"; import { runDungeonBossBenchmark } from "./benchmark/dungeonBossBenchmark";
import { BENCHMARK_POLICY_ID } from "./benchmark/benchmarkController"; import { BENCHMARK_POLICY_ID } from "./benchmark/benchmarkController";
@@ -14,16 +14,16 @@ const samples: unknown[] = [];
afterAll(() => { afterAll(() => {
if (process.env.BOSS_BALANCE_REPORT !== "1") return; if (process.env.BOSS_BALANCE_REPORT !== "1") return;
mkdirSync("artifacts/boss-difficulty", { recursive: true }); 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, difficulty: 1, dungeon: dungeon.id, durationLimitSeconds: 60,
targets: { ordinary: 25, final: 45 }, policy: BENCHMARK_POLICY_ID, gear: "resolved starter heirlooms", samples, targets: { ordinary: 25, final: 45 }, policy: BENCHMARK_POLICY_ID, gear: "resolved starter heirlooms", samples,
}, null, 2)}\n`); }, 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]) { for (const level of [8, 20, 40, 80]) {
it(`benchmarks every class-role build at level ${level}`, async () => { 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[] = []; const tooFast: string[] = [];
for (const build of builds) { for (const build of builds) {
// Let the test worker deliver progress between CPU-heavy simulations. // 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]) { for (const level of [8, 80]) {
it(`records healer outcomes with the final boss attacking at level ${level}`, async () => { 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"); .filter((build) => build.role === "healer");
for (const build of builds) { for (const build of builds) {
const last = await runDungeonBossBenchmark(build, final.id, "class-balance-2", true); 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 { subscribeCombatEvents } from "./combatEvents";
import { bossDifficultyMultipliers } from "./dungeonDifficulty"; import { bossDifficultyMultipliers } from "./dungeonDifficulty";
import { abilitiesForClass } from "./abilityCatalog"; import { abilitiesForClass } from "./abilityCatalog";
import type { ContentCategoryId } from "../app/contentCategories";
const dungeon = requireDungeonDefinition("ragefire-chasm"); const dungeon = requireDungeonDefinition("ragefire-chasm");
const final = finalDungeonBoss(dungeon)!; const final = finalDungeonBoss(dungeon)!;
@@ -21,15 +22,16 @@ afterAll(() => {
writeFileSync("artifacts/boss-difficulty/ragefire-balance.json", `${JSON.stringify({ samples }, null, 2)}\n`); 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(); clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
const entity = dungeon.entities[dungeon.staticSpawns.find((spawn) => spawn.id === bossId)!.entityId]; const entity = encounterDungeon.entities[encounterDungeon.staticSpawns.find((spawn) => spawn.id === bossId)!.entityId];
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, level, "normal", difficulty); const scaling = dungeonEnemyScaling(encounterDungeon, entity, bossId, level, "normal", difficulty, characterSource);
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
const classId = playerDamage ? "rom-mage" : "rom-priest"; const classId = characterSource === "wow" ? (playerDamage ? "mage" : "priest") : (playerDamage ? "rom-mage" : "rom-priest");
combat.initializeCharacter({ classId, raceId: "rom-human", level }); combat.initializeCharacter({ classId, raceId: characterSource === "wow" ? "human" : "rom-human", level });
combat.setPlayerPosition([-18, 0, 0]); 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.synchronizeActors();
combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry, combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry,
...scaling, attacks: entity.combat?.attacks, xpReward: 0 }); ...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 firstDamage: number | undefined;
let incomingDamage = 0; let healing = 0; 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" const damage = abilitiesForClass(classId).find((ability) => ability.target === "hostile"
&& ability.effects.some((effect) => effect.kind === "damage"))!; && ability.effects.some((effect) => effect.kind === "damage"))!;
combat.selectMob(bossId); 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) const injured = usePartyStore.getState().members.filter((member) => member.health > 0)
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0]; .sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0];
if (injured && injured.health < injured.maxHealth * .8 && !useCombatStore.getState().activeCast) { 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]; 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", () => { 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", () => { it("increases boss health and damage at every slider step without changing trash", () => {
const spawn = dungeon.staticSpawns.find((entry) => entry.id === final.id)!; const spawn = dungeon.staticSpawns.find((entry) => entry.id === final.id)!;
const entity = dungeon.entities[spawn.entityId]; const entity = dungeon.entities[spawn.entityId];
+39 -7
View File
@@ -4,11 +4,41 @@ import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLe
import { finalDungeonBoss } from "./dungeonRunStore"; import { finalDungeonBoss } from "./dungeonRunStore";
import type { DungeonDefinition } from "./dungeonTypes"; import type { DungeonDefinition } from "./dungeonTypes";
import type { PopulationEntityDefinition } from "./mobPopulation"; 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, 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 boss = entity.kind === "boss";
const testing = dungeon.id.startsWith("boss-testing:");
const level = scaledDungeonEnemyLevel(characterLevel, boss); const level = scaledDungeonEnemyLevel(characterLevel, boss);
const authoredHealth = entity.combat?.healthMultiplier ?? 1; const authoredHealth = entity.combat?.healthMultiplier ?? 1;
const authoredDamage = entity.combat?.damageMultiplier ?? 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 native = entity.combat?.serverEntry ? creatureRuntimeBaseStats(entity.combat.serverEntry, level) : null;
const originalHealth = (baseline ?? native?.health ?? (boss ? 260 + level * 65 : 72 + level * 22)) * authoredHealth; const originalHealth = (baseline ?? native?.health ?? (boss ? 260 + level * 65 : 72 + level * 22)) * authoredHealth;
const multipliers = boss ? bossDifficultyMultipliers(bossDifficulty) : { health: 1, damage: 1 }; const multipliers = boss ? bossDifficultyMultipliers(bossDifficulty) : { health: 1, damage: 1 };
const finalBoss = boss && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId; const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
// Calibrated against active RoM, WoW and CoA class-role rotations with heirlooms. // Match durability to the character's game, including when crossing dungeon catalogs.
// Every class faces the same floor; the margin covers stronger CoA burst builds. const partyHealthFloor = partyBossHealthFloor(level, characterSource);
const bossFloor = (1_000 + level * 1_200 + level * level * 22) * 1.75 * (finalBoss ? 1.8 : 1); const bossFloor = partyHealthFloor * (finalBoss ? 1.8 : 1);
return { level, maxHealth: (boss ? Math.max(originalHealth, bossFloor) : originalHealth) * multipliers.health, // 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 }; 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)); export const DUNGEON_DEFINITIONS = Object.freeze(Object.values(DUNGEON_REGISTRY));
// Test rooms are session-only definitions; the shipped dungeon catalog stays immutable.
const bossTestingDefinitions = new Map<string, { definition: DungeonDefinition; sourceDungeonId: string }>();
export function registerBossTestingDungeon(definition: DungeonDefinition, sourceDungeonId: string): void {
if (!definition.id.startsWith("boss-testing:") || !DUNGEON_REGISTRY[sourceDungeonId]) {
throw new Error("Invalid boss testing dungeon.");
}
bossTestingDefinitions.clear();
bossTestingDefinitions.set(definition.id, { definition, sourceDungeonId });
}
export function bossTestingSourceDungeonId(id: string): string | null {
return bossTestingDefinitions.get(id)?.sourceDungeonId ?? null;
}
export function clearBossTestingDungeons(): void {
bossTestingDefinitions.clear();
}
export function dungeonDefinitionById(id: string): DungeonDefinition | null { export function dungeonDefinitionById(id: string): DungeonDefinition | null {
return DUNGEON_REGISTRY[id] ?? null; return bossTestingDefinitions.get(id)?.definition ?? DUNGEON_REGISTRY[id] ?? null;
} }
export function requireDungeonDefinition(id: string): DungeonDefinition { export function requireDungeonDefinition(id: string): DungeonDefinition {
+9 -3
View File
@@ -5,8 +5,8 @@ import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { resetPartyRuntimeClock } from "./partyRuntime"; import { resetPartyRuntimeClock } from "./partyRuntime";
import { usePartyStore } from "./partyStore"; import { usePartyStore } from "./partyStore";
import { useGameStore, type GameMode } from "./store"; import { useGameStore, type BossTestSession, type GameMode } from "./store";
import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry"; import { bossTestingSourceDungeonId, dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
import type { WorldSpawn } from "./dungeonTypes"; import type { WorldSpawn } from "./dungeonTypes";
import type { GmReviewTarget } from "./gmContentReview"; import type { GmReviewTarget } from "./gmContentReview";
import { shelvedDungeonSpawn } from "@shelvedDungeonSession"; import { shelvedDungeonSpawn } from "@shelvedDungeonSession";
@@ -27,6 +27,7 @@ export interface DungeonSessionOptions {
readonly gameMode?: GameMode; readonly gameMode?: GameMode;
readonly spawn?: WorldSpawn; readonly spawn?: WorldSpawn;
readonly gmTestMode?: boolean; readonly gmTestMode?: boolean;
readonly bossTest?: BossTestSession | null;
readonly gmReviewTarget?: GmReviewTarget | null; readonly gmReviewTarget?: GmReviewTarget | null;
/** Used by in-game encounter transitions so character progression and inventory survive. */ /** Used by in-game encounter transitions so character progression and inventory survive. */
readonly preserveCharacter?: boolean; readonly preserveCharacter?: boolean;
@@ -47,7 +48,10 @@ export function activateDungeonSession(
options: DungeonSessionOptions = {}, options: DungeonSessionOptions = {},
): boolean { ): boolean {
const definition = dungeonDefinitionById(dungeonId); const definition = dungeonDefinitionById(dungeonId);
if (!definition || !dungeonCanEnter(dungeonId) || !dungeonContentIsUsable(dungeonId)) { const testingSource = bossTestingSourceDungeonId(dungeonId);
const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource);
if (!definition || (!testing && !dungeonCanEnter(dungeonId))
|| !dungeonContentIsUsable(testing ? testingSource! : dungeonId)) {
return false; return false;
} }
if ((difficultyId !== undefined && !definition.difficultyVariants.some((variant) => variant.id === difficultyId)) if ((difficultyId !== undefined && !definition.difficultyVariants.some((variant) => variant.id === difficultyId))
@@ -107,6 +111,7 @@ export function activateDungeonSession(
gameMode: options.gameMode, gameMode: options.gameMode,
spawn: shelvedDungeonSpawn(dungeonId, options.spawn), spawn: shelvedDungeonSpawn(dungeonId, options.spawn),
gmTestMode: options.gmTestMode, gmTestMode: options.gmTestMode,
bossTest: options.bossTest,
gmReviewTarget: options.gmReviewTarget, gmReviewTarget: options.gmReviewTarget,
bossDifficulty, bossDifficulty,
}); });
@@ -128,6 +133,7 @@ export function restartActiveDungeonSession(): boolean {
gameMode: game.gameMode, gameMode: game.gameMode,
spawn: game.activeSpawn, spawn: game.activeSpawn,
gmTestMode: game.gmTestMode, gmTestMode: game.gmTestMode,
bossTest: game.bossTest,
gmReviewTarget: game.activeGmReviewTarget, gmReviewTarget: game.activeGmReviewTarget,
preserveCharacter: true, preserveCharacter: true,
bossDifficulty: game.activeBossDifficulty, bossDifficulty: game.activeBossDifficulty,
+14 -2
View File
@@ -9,7 +9,15 @@ import { validBossDifficulty } from "./dungeonDifficulty";
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error"; export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
export type InputMode = "keyboard" | "gamepad"; export type InputMode = "keyboard" | "gamepad";
export type GameMode = "dungeon" | "manastorm"; export type GameMode = "dungeon" | "manastorm";
export type GameOverlay = "pause" | "report-issue" | "party" | "inventory" | "boss-cards" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | "dungeon-results" | null; export type GameOverlay = "pause" | "boss-test-review" | "report-issue" | "party" | "inventory" | "boss-cards" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | "dungeon-results" | null;
export interface BossTestSession {
readonly key: string;
readonly name: string;
readonly sourceDungeonId: string;
readonly entityId: string;
readonly runtimeIds: readonly string[];
}
interface PlayerSnapshot { interface PlayerSnapshot {
position: Vector3Tuple; position: Vector3Tuple;
@@ -24,6 +32,7 @@ export interface GameState {
activeBossDifficulty: number; activeBossDifficulty: number;
gameMode: GameMode; gameMode: GameMode;
gmTestMode: boolean; gmTestMode: boolean;
bossTest: BossTestSession | null;
activeGmReviewTarget: GmReviewTarget | null; activeGmReviewTarget: GmReviewTarget | null;
activeSpawn: WorldSpawn; activeSpawn: WorldSpawn;
sessionRevision: number; sessionRevision: number;
@@ -55,6 +64,7 @@ export interface GameState {
readonly gameMode?: GameMode; readonly gameMode?: GameMode;
readonly spawn?: WorldSpawn; readonly spawn?: WorldSpawn;
readonly gmTestMode?: boolean; readonly gmTestMode?: boolean;
readonly bossTest?: BossTestSession | null;
readonly gmReviewTarget?: GmReviewTarget | null; readonly gmReviewTarget?: GmReviewTarget | null;
readonly bossDifficulty?: number; readonly bossDifficulty?: number;
}, },
@@ -88,6 +98,7 @@ export const useGameStore = create<GameState>((set) => ({
activeBossDifficulty: 1, activeBossDifficulty: 1,
gameMode: "dungeon", gameMode: "dungeon",
gmTestMode: false, gmTestMode: false,
bossTest: null,
activeGmReviewTarget: null, activeGmReviewTarget: null,
activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw }, activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw },
sessionRevision: 0, sessionRevision: 0,
@@ -125,6 +136,7 @@ export const useGameStore = create<GameState>((set) => ({
activeBossDifficulty: validBossDifficulty(options?.bossDifficulty) ? options.bossDifficulty : 1, activeBossDifficulty: validBossDifficulty(options?.bossDifficulty) ? options.bossDifficulty : 1,
gameMode: options?.gameMode ?? "dungeon", gameMode: options?.gameMode ?? "dungeon",
gmTestMode: options?.gmTestMode ?? false, gmTestMode: options?.gmTestMode ?? false,
bossTest: options?.bossTest ?? null,
activeGmReviewTarget: options?.gmReviewTarget ?? null, activeGmReviewTarget: options?.gmReviewTarget ?? null,
activeSpawn, activeSpawn,
sessionRevision: state.sessionRevision + 1, sessionRevision: state.sessionRevision + 1,
@@ -279,7 +291,7 @@ export const useGameStore = create<GameState>((set) => ({
const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw }; const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw };
return { return {
gameMode: "dungeon", gameMode: "dungeon",
gmTestMode: false, gmTestMode: Boolean(state.bossTest),
activeGmReviewTarget: null, activeGmReviewTarget: null,
activeSpawn, activeSpawn,
overlay: null, overlay: null,
+106 -2
View File
@@ -1,10 +1,113 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
actionKeyForEvent, bindDesktopSlot, defaultUiLayout, desktopSlotForKey, actionKeyForEvent, assignDesktopAbility, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resolveDpsMeterDisplay, exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resetDesktopAbilities, resolveDpsMeterDisplay,
HUD_ELEMENTS, resolveHudScreen,
activateDesktopAbilityLoadout, assignedActionBarAbilityIds,
} from "./uiLayout"; } from "./uiLayout";
describe("personal UI layouts", () => { 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 };
const original = exportUiPreferences(ui);
ui.current.pc = assignDesktopAbility(ui.current.pc, 4, 11, "test-heal");
const saved = importUiPreferences(exportUiPreferences(ui));
expect(saved.current.pc.bars[4].slots[11]).toEqual({ abilityId: "test-heal", key: null });
expect(saved.current.pc.bars[0]).toEqual(JSON.parse(original).current.pc.bars[0]);
expect(saved.current.mobile).toEqual(ui.current.mobile);
expect(saved.slots.pc[0]?.layout.bars[4].slots[11].abilityId).toBeNull();
expect(assignDesktopAbility(saved.current.pc, 4, 11, null).bars[4].slots[11].abilityId).toBeNull();
});
it("restores one or all WoW ability bars with the correct offset and retains custom keys and geometry", () => {
let layout = defaultUiLayout();
layout.wowBars = true; layout.barCount = 2; layout.bars[1].columns = 3;
layout = bindDesktopSlot(layout, 1, 0, "Ctrl+KeyQ");
layout = assignDesktopAbility(assignDesktopAbility(layout, 0, 0, "custom-one"), 1, 0, "custom-two");
const defaults = Array.from({ length: 14 }, (_, i) => `spell-${i}`);
const bar = resetDesktopAbilities(layout, defaults, 1);
expect(bar.bars[0]).toEqual(layout.bars[0]);
expect(bar.bars[1].slots[0]).toEqual({ abilityId: "spell-12", key: "Ctrl+KeyQ" });
expect(bar.bars[1].slots[2].abilityId).toBeNull();
const all = resetDesktopAbilities(bar, defaults);
expect(all.bars[0].slots[0]).toEqual({ abilityId: "spell-0", key: "Digit1" });
expect(all.bars[1].columns).toBe(3);
expect(all.wowBars).toBe(true); expect(all.barCount).toBe(2);
});
it("defaults DPS to PC lower-left flow and the Thor companion, with saved overrides", () => { it("defaults DPS to PC lower-left flow and the Thor companion, with saved overrides", () => {
const ui = normalizeUiPreferences(null); const ui = normalizeUiPreferences(null);
expect(ui.current.pc.elements.dps).toEqual({ visible: true, position: null, width: null }); expect(ui.current.pc.elements.dps).toEqual({ visible: true, position: null, width: null });
@@ -69,6 +172,7 @@ describe("personal UI layouts", () => {
["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "NotAKey"; }], ["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"; }], ["duplicate chords", (data: any) => { data.current.pc.bars[0].slots[1].key = "Digit1"; }],
["missing buttons", (data: any) => { data.current.pc.bars[0].slots.pop(); }], ["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) => { ])("rejects %s without mutating the original preferences", (_, mutate) => {
const ui = normalizeUiPreferences(null); const ui = normalizeUiPreferences(null);
const original = exportUiPreferences(ui); const original = exportUiPreferences(ui);
+75 -2
View File
@@ -6,7 +6,7 @@ export const UI_DEVICES: readonly UiDevice[] = ["mobile", "pc"];
export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const; export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const;
export const HUD_ELEMENTS = { export const HUD_ELEMENTS = {
party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap", party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap",
player: "Player status", timeline: "Encounter timeline", target: "Enemy target", player: "Buffs and debuffs", timeline: "Encounter timeline", target: "Enemy target",
friendly: "Friendly target", threat: "Threat meter", dps: "DPS meter", cast: "Cast bar", friendly: "Friendly target", threat: "Threat meter", dps: "DPS meter", cast: "Cast bar",
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar", hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt", reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
@@ -16,8 +16,10 @@ export const HUD_ELEMENTS = {
"bar-4": "Action bar 4", "bar-5": "Action bar 5", "bar-4": "Action bar 4", "bar-5": "Action bar 5",
} as const; } as const;
export type HudElementId = keyof typeof HUD_ELEMENTS; export type HudElementId = keyof typeof HUD_ELEMENTS;
export type HudScreen = "auto" | "top" | "bottom" | "hidden";
export interface HudElementLayout { export interface HudElementLayout {
visible: boolean; visible: boolean;
screen?: HudScreen;
position: NormalizedHudPosition | null; position: NormalizedHudPosition | null;
width: number | null; width: number | null;
} }
@@ -34,6 +36,8 @@ export interface UiTemplate { name: string; layout: UiLayout }
export interface UiPreferences { export interface UiPreferences {
version: 1; version: 1;
keybinds: InputPreferences; 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>; current: Record<UiDevice, UiLayout>;
slots: Record<UiDevice, (UiTemplate | null)[]>; slots: Record<UiDevice, (UiTemplate | null)[]>;
} }
@@ -87,6 +91,7 @@ export function normalizeUiLayout(value: unknown): UiLayout {
const element = record(elements[id]); const element = record(elements[id]);
defaults.elements[id] = { defaults.elements[id] = {
visible: element.visible !== false, visible: element.visible !== false,
...(["auto", "top", "bottom", "hidden"].includes(String(element.screen)) ? { screen: element.screen as HudScreen } : {}),
position: element.position ? normalizeHudPosition(element.position, { x: 0.5, y: 0.5 }) : null, position: element.position ? normalizeHudPosition(element.position, { x: 0.5, y: 0.5 }) : null,
width: typeof element.width === "number" ? bounded(element.width, 200, 20, 2000) : null, width: typeof element.width === "number" ? bounded(element.width, 200, 20, 2000) : null,
}; };
@@ -114,8 +119,21 @@ export function normalizeUiLayout(value: unknown): UiLayout {
} }
export function resolveDpsMeterDisplay(layout: UiLayout, dualScreen: boolean): "main" | "companion" { export function resolveDpsMeterDisplay(layout: UiLayout, dualScreen: boolean): "main" | "companion" {
const screen = layout.elements.dps.screen;
if (screen === "top" || screen === "bottom") return !dualScreen || screen === "top" ? "main" : "companion";
return layout.dpsMeterDisplay === "auto" ? dualScreen ? "companion" : "main" : layout.dpsMeterDisplay; return layout.dpsMeterDisplay === "auto" ? dualScreen ? "companion" : "main" : layout.dpsMeterDisplay;
} }
/** One owner per widget; single-display layouts keep all visible widgets available. */
export function resolveHudScreen(id: HudElementId, layout: UiLayout, dualScreen: boolean): "top" | "bottom" | "hidden" {
const element = layout.elements[id];
if (!element.visible || element.screen === "hidden") return "hidden";
if (!dualScreen) return "top";
if (element.screen === "top" || element.screen === "bottom") return element.screen;
if (id === "dps") return resolveDpsMeterDisplay(layout, true) === "main" ? "top" : "bottom";
if (["player", "actions", "cast", "experience", "bar-1", "bar-2", "bar-3", "bar-4", "bar-5"].includes(id)) return "bottom";
if (id === "hints") return "hidden";
return "top";
}
export function normalizeUiPreferences(value: unknown): UiPreferences { export function normalizeUiPreferences(value: unknown): UiPreferences {
const source = record(value); const source = record(value);
const current = record(source.current); const current = record(source.current);
@@ -123,6 +141,12 @@ export function normalizeUiPreferences(value: unknown): UiPreferences {
return { return {
version: 1, version: 1,
keybinds: normalizeInputPreferences(source.keybinds), 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) }, current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) },
slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => { slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => {
const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null); const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null);
@@ -139,6 +163,48 @@ export function bindDesktopSlot(layout: UiLayout, barIndex: number, slotIndex: n
...slot, key: b === barIndex && s === slotIndex ? key : key && slot.key === key ? null : slot.key, ...slot, key: b === barIndex && s === slotIndex ? key : key && slot.key === key ? null : slot.key,
})) })) }; })) })) };
} }
export function assignDesktopAbility(layout: UiLayout, barIndex: number, slotIndex: number, abilityId: string | null): UiLayout {
return { ...layout, bars: layout.bars.map((bar, b) => b !== barIndex ? bar : {
...bar, slots: bar.slots.map((slot, s) => s === slotIndex ? { ...slot, abilityId } : slot),
}) };
}
/** Restore abilities only; key chords and the arrangement belong to the UI template. */
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 { export function desktopSlotForKey(layout: UiLayout, key: string | null): DesktopActionSlot | null {
if (!layout.wowBars || !key) return null; if (!layout.wowBars || !key) return null;
// Hidden bars retain their bindings, like WoW; reducing the bar count deactivates them. // Hidden bars retain their bindings, like WoW; reducing the bar count deactivates them.
@@ -152,6 +218,13 @@ export function importUiPreferences(text: string): UiPreferences {
let source: Record<string, unknown>; let source: Record<string, unknown>;
try { source = record(JSON.parse(text)); } catch { throw new Error("This file is not valid JSON."); } 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.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 validateLayout = (value: unknown) => {
const layout = record(value); const layout = record(value);
if (layout.dpsMeterDisplay !== undefined && !["auto", "main", "companion"].includes(String(layout.dpsMeterDisplay))) throw new Error("Invalid DPS meter display."); if (layout.dpsMeterDisplay !== undefined && !["auto", "main", "companion"].includes(String(layout.dpsMeterDisplay))) throw new Error("Invalid DPS meter display.");
@@ -162,7 +235,7 @@ export function importUiPreferences(text: string): UiPreferences {
if (id === "dps" && record(layout.elements)[id] === undefined) continue; if (id === "dps" && record(layout.elements)[id] === undefined) continue;
const element = record(record(layout.elements)[id]); const element = record(record(layout.elements)[id]);
const position = record(element.position); const position = record(element.position);
if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1)) if ((element.screen !== undefined && !["auto", "top", "bottom", "hidden"].includes(String(element.screen))) || typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
|| (element.width !== null && (typeof element.width !== "number" || !Number.isFinite(element.width) || element.width < 20 || element.width > 2000))) throw new Error("Invalid HUD position or visibility."); || (element.width !== null && (typeof element.width !== "number" || !Number.isFinite(element.width) || element.width < 20 || element.width > 2000))) throw new Error("Invalid HUD position or visibility.");
} }
const keys = new Set<string>(); const keys = new Set<string>();
+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 { Capacitor } from "@capacitor/core";
import { loadUiPreferences, saveUiPreferences } from "../app/accountRepository"; import { loadUiPreferences, saveUiPreferences } from "../app/accountRepository";
import { useShellStore } from "../app/shellStore"; 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"; import { publishInputPreferences } from "../input/keybindRuntime";
export function currentUiDevice(): UiDevice { export function currentUiDevice(): UiDevice {
return Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) return (typeof window !== "undefined" && /(?:[?&]layout=(?:thor-preview|dual)|[?&]display=(?:top|bottom))\b/.test(window.location.search))
|| Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent))
|| (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches && window.innerWidth < 1100) || (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches && window.innerWidth < 1100)
? "mobile" : "pc"; ? "mobile" : "pc";
} }
interface UiLayoutState { interface UiLayoutState {
ownerId: string | null; ownerId: string | null;
desktopLoadoutKey: string | null;
preferences: UiPreferences; preferences: UiPreferences;
draft: UiLayout | null; draft: UiLayout | null;
device: UiDevice; device: UiDevice;
@@ -24,7 +28,7 @@ interface UiLayoutState {
finish: (save: boolean) => void; finish: (save: boolean) => void;
} }
export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({ 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, mode: "layout", selectedButton: null, selectedElement: null,
begin: () => { begin: () => {
const device = currentUiDevice(); const device = currentUiDevice();
@@ -35,7 +39,11 @@ export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
const ownerId = get().ownerId; const ownerId = get().ownerId;
if (!ownerId) throw new Error("Sign in or choose an offline profile before saving your UI."); 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. // 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 }); set({ preferences: saved });
}, },
finish: (save) => { finish: (save) => {
@@ -47,7 +55,7 @@ export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
function activateOwner() { function activateOwner() {
const ownerId = useShellStore.getState().session?.ownerId ?? null; 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(); activateOwner();
publishInputPreferences(useUiLayoutStore.getState().preferences.keybinds); publishInputPreferences(useUiLayoutStore.getState().preferences.keybinds);
@@ -56,6 +64,18 @@ useShellStore.subscribe((state, previous) => {
if (state.session !== previous.session) activateOwner(); 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 { export function activeUiLayout(): UiLayout {
const state = useUiLayoutStore.getState(); const state = useUiLayoutStore.getState();
return state.draft ?? state.preferences.current[currentUiDevice()]; 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 type { CharacterProfile } from "../app/types";
import { useShellStore } from "../app/shellStore"; import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "./combatStore"; import { useCombatStore } from "./combatStore";
import { activateCharacterUi } from "./uiLayoutStore";
import { usePartyStore } from "./partyStore"; import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store"; import { useGameStore } from "./store";
import { useArenaStore } from "./arenaStore"; import { useArenaStore } from "./arenaStore";
@@ -16,6 +17,7 @@ export function useArenaCombatView(character: CharacterProfile | null, actor: Ar
const previousParty = usePartyStore.getState(); const previousParty = usePartyStore.getState();
const previousGame = useGameStore.getState(); const previousGame = useGameStore.getState();
useCombatStore.getState().initializeCharacter(character); useCombatStore.getState().initializeCharacter(character);
activateCharacterUi(character);
usePartyStore.setState({ members: [] }); usePartyStore.setState({ members: [] });
useGameStore.setState({ overlay: null, paused: false, companionOpen: false }); useGameStore.setState({ overlay: null, paused: false, companionOpen: false });
const unsubscribe = useCombatStore.subscribe((state, before) => { const unsubscribe = useCombatStore.subscribe((state, before) => {
+6
View File
@@ -4,15 +4,21 @@ import { Capacitor } from "@capacitor/core";
import App from "./App"; import App from "./App";
import { startControllerInput } from "./input/controller"; import { startControllerInput } from "./input/controller";
import { currentDisplayLayout } from "./platform/displayLayout"; import { currentDisplayLayout } from "./platform/displayLayout";
import { THOR_DISPLAYS } from "./platform/thorDisplayProfile";
import { initializeContentSystem } from "./content/contentManager"; import { initializeContentSystem } from "./content/contentManager";
import "./styles.css"; import "./styles.css";
import "./ui/dungeonReports.css"; import "./ui/dungeonReports.css";
import "./ui/thorUi.css";
const BottomDisplayApp = lazy(() => import("./platform/BottomDisplayApp").then((module) => ({ default: module.BottomDisplayApp }))); const BottomDisplayApp = lazy(() => import("./platform/BottomDisplayApp").then((module) => ({ default: module.BottomDisplayApp })));
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const displayMode = params.get("display"); const displayMode = params.get("display");
const nativeLayoutRequested = params.has("nativeLayout"); const nativeLayoutRequested = params.has("nativeLayout");
if (Capacitor.isNativePlatform() && (displayMode === "top" || displayMode === "bottom")) {
// Let each WebView fit the same physical UI density, independently of Android's shared display density.
document.querySelector('meta[name="viewport"]')?.setAttribute("content", `width=${THOR_DISPLAYS[displayMode].cssWidth}, viewport-fit=cover`);
}
if (Capacitor.isNativePlatform() || nativeLayoutRequested) document.documentElement.classList.add("native-platform"); if (Capacitor.isNativePlatform() || nativeLayoutRequested) document.documentElement.classList.add("native-platform");
if (displayMode === "top" || displayMode === "bottom") document.documentElement.dataset.displaySurface = displayMode; if (displayMode === "top" || displayMode === "bottom") document.documentElement.dataset.displaySurface = displayMode;
document.documentElement.dataset.displayLayout = currentDisplayLayout(); document.documentElement.dataset.displayLayout = currentDisplayLayout();
+4 -1
View File
@@ -15,6 +15,7 @@ import {
import { normalizeInputPreferences } from "../input/keybinds"; import { normalizeInputPreferences } from "../input/keybinds";
import { publishInputPreferences } from "../input/keybindRuntime"; import { publishInputPreferences } from "../input/keybindRuntime";
import { useForcedThorDisplays } from "./useThorDualScreen"; import { useForcedThorDisplays } from "./useThorDualScreen";
import { RemoteDungeonHud } from "./RemoteDungeonHud";
export function BottomDisplayApp() { export function BottomDisplayApp() {
useForcedThorDisplays(); useForcedThorDisplays();
@@ -78,5 +79,7 @@ export function BottomDisplayApp() {
}; };
}, []); }, []);
return <main className="bottom-display-root"><CompanionPanel snapshot={snapshot} send={send} preferControllerBinds /></main>; return <main className="bottom-display-root">{snapshot.phase === "game"
? <RemoteDungeonHud snapshot={snapshot} send={send} />
: <CompanionPanel snapshot={snapshot} send={send} preferControllerBinds />}</main>;
} }
+53
View File
@@ -0,0 +1,53 @@
import { useLayoutEffect } from "react";
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store";
import { clearBossTestingDungeons, registerBossTestingDungeon } from "../game/dungeonRegistry";
import { usePartyStore } from "../game/partyStore";
import { useManastormStore } from "../game/manastormStore";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useShellStore } from "../app/shellStore";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore";
import { DungeonCompanionHud } from "../ui/DungeonCompanionHud";
import type { CompanionCommand, CompanionSnapshot } from "./dualScreenSync";
export function RemoteDungeonHud({ snapshot, send }: {
snapshot: CompanionSnapshot;
send: (command: CompanionCommand) => void;
}) {
const hud = snapshot.game.hud;
useLayoutEffect(() => {
if (hud.bossTestingDungeon) registerBossTestingDungeon(hud.bossTestingDungeon.definition, hud.bossTestingDungeon.sourceDungeonId);
const session = useShellStore.getState().session;
useShellStore.setState({ ...hud.shell,
session: session?.ownerId === snapshot.session?.ownerId ? session : snapshot.session,
});
useUiLayoutStore.setState(hud.ui);
useGameStore.setState({ ...hud.game,
toggleMap: () => send({ name: "toggleMap" }),
openOverlay: (overlay) => send({ name: "openOverlay", overlay }),
});
if (!hud.bossTestingDungeon) clearBossTestingDungeons();
useCombatStore.setState({ ...hud.combat,
castActionBinding: (layer, control) => { send({ name: "castActionBinding", layer, control }); return { ok: false, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 }; },
selectFriendlyActor: (id) => { send({ name: "selectFriendlyActor", id }); return true; },
clearTarget: () => send({ name: "clearTarget" }),
updateSettings: (patch) => { if (patch.threatMeterPosition) send({ name: "moveThreatMeter", position: patch.threatMeterPosition }); },
});
usePartyStore.setState({ ...hud.party,
selectMember: (memberId) => { if (memberId) send({ name: "selectPartyMember", memberId }); else send({ name: "clearPartySelection" }); return true; },
selectSelf: () => send({ name: "selectPlayer" }),
clearSelection: () => send({ name: "clearPartySelection" }),
setCommand: (command) => send({ name: "setPartyCommand", command }),
recallParty: () => send({ name: "recallParty" }),
});
useManastormStore.setState(hud.manastorm);
useDungeonRunStore.setState({ progress: hud.run });
useOnlineSessionStore.setState({ snapshot: hud.onlineSession });
useOnlineGroupStore.setState(hud.onlineGroup);
useDpsMeterStore.setState(hud.dps);
}, [hud, send, snapshot.session]);
return <DungeonCompanionHud send={send} remote completion={hud.completion} interaction={hud.interaction} />;
}
@@ -7,6 +7,10 @@ import { currentCompanionSnapshot, executeCompanionCommand, type DualScreenMessa
import { createRateLimitedPublisher } from "./rateLimitedPublisher"; import { createRateLimitedPublisher } from "./rateLimitedPublisher";
import { useUiLayoutStore } from "../game/uiLayoutStore"; import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore"; import { useDpsMeterStore } from "../game/dpsMeterStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import { subscribeDungeonEncounterSnapshots } from "../game/dungeonEncounterRuntime";
export function connectAuthoritativeDualScreen( export function connectAuthoritativeDualScreen(
channel: BroadcastChannel, channel: BroadcastChannel,
@@ -38,6 +42,11 @@ export function connectAuthoritativeDualScreen(
const unsubscribeDps = useDpsMeterStore.subscribe(() => { const unsubscribeDps = useDpsMeterStore.subscribe(() => {
if (companionReady) gamePublisher.request(); if (companionReady) gamePublisher.request();
}); });
const request = () => { if (companionReady) gamePublisher.request(); };
const unsubscribeOnline = useOnlineSessionStore.subscribe(request);
const unsubscribeGroup = useOnlineGroupStore.subscribe(request);
const unsubscribeRun = useDungeonRunStore.subscribe(request);
const unsubscribeEncounters = subscribeDungeonEncounterSnapshots(request);
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => { channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
if (event.data.type === "companion-ready") { if (event.data.type === "companion-ready") {
companionReady = true; companionReady = true;
@@ -62,6 +71,10 @@ export function connectAuthoritativeDualScreen(
unsubscribeParty(); unsubscribeParty();
unsubscribeUi(); unsubscribeUi();
unsubscribeDps(); unsubscribeDps();
unsubscribeOnline();
unsubscribeGroup();
unsubscribeRun();
unsubscribeEncounters();
gamePublisher.dispose(); gamePublisher.dispose();
}; };
} }
+33 -1
View File
@@ -32,6 +32,10 @@ import { activeUiLayout, useUiLayoutStore } from "../game/uiLayoutStore";
import { normalizeHudPosition } from "../game/hudLayout"; import { normalizeHudPosition } from "../game/hudLayout";
import { resolveDpsMeterDisplay, type HudElementLayout } from "../game/uiLayout"; import { resolveDpsMeterDisplay, type HudElementLayout } from "../game/uiLayout";
import { currentDisplayLayout } from "./displayLayout"; import { currentDisplayLayout } from "./displayLayout";
import { currentHudSnapshot, type HudSnapshot } from "./hudSnapshot";
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
import { interactWithActiveDungeonObject } from "../game/dungeonWorldInteraction";
import { resurrectDeadPartyMembers } from "../game/partyResurrection";
export { createDualScreenChannel } from "./dualScreenChannel"; export { createDualScreenChannel } from "./dualScreenChannel";
@@ -95,6 +99,7 @@ export interface CompanionPartyObjectiveSnapshot {
} }
export interface CompanionGameSnapshot { export interface CompanionGameSnapshot {
hud: HudSnapshot;
dpsMeter: { dpsMeter: {
values: DpsMeterSnapshot; values: DpsMeterSnapshot;
layout: HudElementLayout; layout: HudElementLayout;
@@ -172,7 +177,15 @@ export type CompanionCommand =
| { name: "setPartyCommand"; command: PartyCommand } | { name: "setPartyCommand"; command: PartyCommand }
| { name: "recallParty" } | { name: "recallParty" }
| { name: "resetAtEntrance" } | { name: "resetAtEntrance" }
| { name: "editDpsMeter"; update: Partial<HudElementLayout> }; | { name: "editDpsMeter"; update: Partial<HudElementLayout> }
| { name: "editHudElement"; id: HudElementId; update: Partial<HudElementLayout> }
| { name: "selectFriendlyActor"; id: string | null }
| { name: "selectPlayer" }
| { name: "clearPartySelection" }
| { name: "resurrectParty"; checkpointId: string }
| { name: "interactDungeonObject" }
| { name: "finishDungeon" }
| { name: "moveThreatMeter"; position: { x: number; y: number } };
export type DualScreenMessage = export type DualScreenMessage =
| { type: "companion-ready" } | { type: "companion-ready" }
@@ -265,6 +278,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
draft: shell.draft, draft: shell.draft,
notice: shell.notice, notice: shell.notice,
game: { game: {
hud: currentHudSnapshot(),
dpsMeter: { dpsMeter: {
values: currentDpsMeterSnapshot(), values: currentDpsMeterSnapshot(),
layout: uiLayout.elements.dps, layout: uiLayout.elements.dps,
@@ -342,6 +356,24 @@ export function executeCompanionCommand(command: CompanionCommand): void {
const game = useGameStore.getState(); const game = useGameStore.getState();
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
switch (command.name) { switch (command.name) {
case "editHudElement": {
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft || !(command.id in HUD_ELEMENTS)) break;
const update: Partial<HudElementLayout> = {};
if (command.update.position) update.position = normalizeHudPosition(command.update.position, { x: .5, y: .5 });
if (typeof command.update.width === "number" && Number.isFinite(command.update.width)) update.width = Math.max(20, Math.min(2000, command.update.width));
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
[command.id]: { ...layout.elements[command.id], ...update },
} }));
useUiLayoutStore.setState({ selectedElement: command.id });
break;
}
case "selectFriendlyActor": combat.selectFriendlyActor(command.id); break;
case "selectPlayer": combat.clearTarget(); usePartyStore.getState().selectSelf(); break;
case "clearPartySelection": usePartyStore.getState().clearSelection(); break;
case "resurrectParty": resurrectDeadPartyMembers(command.checkpointId); break;
case "interactDungeonObject": if (!game.overlay) interactWithActiveDungeonObject(); break;
case "finishDungeon": if (currentHudSnapshot().completion) { shell.saveActiveDungeonRunCheckpoint(null); shell.returnToMainMenu(); } break;
case "moveThreatMeter": combat.updateSettings({ threatMeterPosition: normalizeHudPosition(command.position, { x: .8, y: .3 }) }); break;
case "editDpsMeter": { case "editDpsMeter": {
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft) break; if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft) break;
const update: Partial<HudElementLayout> = {}; const update: Partial<HudElementLayout> = {};
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { currentHudSnapshot } from "./hudSnapshot";
import { executeCompanionCommand } from "./dualScreenSync";
import { useGameStore } from "../game/store";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { normalizeUiPreferences } from "../game/uiLayout";
import { BOSS_TESTING_CATALOG, createBossTestingRoom } from "../game/bossTesting";
import { clearBossTestingDungeons, registerBossTestingDungeon, requireDungeonDefinition } from "../game/dungeonRegistry";
describe("remote HUD presentation", () => {
it("carries the isolated boss room to the lower display without simulation methods", () => {
const original = useGameStore.getState();
const boss = BOSS_TESTING_CATALOG.find((entry) => entry.dungeonId === "wailing-caverns")!;
const room = createBossTestingRoom(requireDungeonDefinition(boss.dungeonId), boss);
try {
registerBossTestingDungeon(room.definition, boss.dungeonId);
useGameStore.getState().activateDungeon(room.definition.id, undefined, { bossTest: room.session, gmTestMode: true });
const snapshot = structuredClone(currentHudSnapshot());
expect(snapshot.bossTestingDungeon?.definition).toEqual(room.definition);
expect(snapshot.game.bossTest).toEqual(room.session);
clearBossTestingDungeons();
registerBossTestingDungeon(snapshot.bossTestingDungeon!.definition, snapshot.bossTestingDungeon!.sourceDungeonId);
expect(requireDungeonDefinition(snapshot.game.activeDungeonId).staticSpawns).toHaveLength(1);
} finally {
useGameStore.setState(original);
clearBossTestingDungeons();
}
});
it("contains cloneable presentation data without simulation methods", () => {
const snapshot = currentHudSnapshot();
expect(() => structuredClone(snapshot)).not.toThrow();
expect(snapshot.combat).not.toHaveProperty("tick");
expect(snapshot.game).not.toHaveProperty("activateDungeon");
expect(snapshot.party).not.toHaveProperty("recallParty");
});
it("accepts bounded moves only while the authoritative editor is open", () => {
const originalUi = useUiLayoutStore.getState();
const originalGame = useGameStore.getState();
try {
const draft = normalizeUiPreferences(null).current.mobile;
useUiLayoutStore.setState({ draft });
useGameStore.setState({ overlay: null });
executeCompanionCommand({ name: "editHudElement", id: "party", update: { position: { x: .8, y: .7 } } });
expect(useUiLayoutStore.getState().draft).toBe(draft);
useGameStore.setState({ overlay: "ui-layout" });
executeCompanionCommand({ name: "editHudElement", id: "party", update: { position: { x: 5, y: -1 }, width: 99999, screen: "hidden" } });
expect(useUiLayoutStore.getState().draft?.elements.party).toMatchObject({ position: { x: 1, y: 0 }, width: 2000, visible: true });
expect(useUiLayoutStore.getState().draft?.elements.party.screen).toBeUndefined();
} finally {
useUiLayoutStore.setState(originalUi);
useGameStore.setState(originalGame);
}
});
});
+40
View File
@@ -0,0 +1,40 @@
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store";
import { usePartyStore } from "../game/partyStore";
import { useManastormStore } from "../game/manastormStore";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { useShellStore } from "../app/shellStore";
import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore";
import { activeDungeonRunIsComplete } from "../game/dungeonEncounterRuntime";
import { activeDungeonInteractionCandidate } from "../game/dungeonWorldInteraction";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
function pick<T, K extends keyof T>(state: T, keys: readonly K[]): Pick<T, K> {
return Object.fromEntries(keys.map((key) => [key, state[key]])) as Pick<T, K>;
}
/** Presentation data only. The lower WebView never starts another simulation. */
export function currentHudSnapshot() {
const game = useGameStore.getState();
const run = useDungeonRunStore.getState().progress;
const candidate = game.overlay ? null : activeDungeonInteractionCandidate();
return {
game: pick(game, ["activeDungeonId", "gameMode", "gmTestMode", "bossTest", "areaName", "distanceTravelled", "playerPosition", "cameraYaw", "hasMoved", "cameraLookActive", "inputMode", "actionLayer", "assetStatus", "assetMessage", "overlay", "paused", "companionOpen"]),
bossTestingDungeon: game.bossTest ? { definition: requireDungeonDefinition(game.activeDungeonId), sourceDungeonId: game.bossTest.sourceDungeonId } : null,
combat: pick(useCombatStore.getState(), ["classId", "secondaryClassId", "specializationId", "level", "xp", "xpToNext", "health", "maxHealth", "shield", "shieldExpiresAt", "resource", "maxResource", "resourcePools", "maxResourcePools", "resourceName", "abilities", "talentRanks", "talentModifiers", "auras", "effects", "summons", "actors", "actionBindings", "cooldowns", "globalCooldownEndsAt", "activeCast", "pendingGroundTarget", "feedback", "selectedTargetId", "selectedFriendlyActorId", "mobs", "settings"]),
party: pick(usePartyStore.getState(), ["members", "selectedMemberId", "selfSelected", "playerRole", "command", "routeBlocked"]),
manastorm: pick(useManastormStore.getState(), ["currentEncounter", "resurrectionCharges"]),
shell: pick(useShellStore.getState(), ["activeCharacter", "activeDungeonRole"]),
onlineSession: useOnlineSessionStore.getState().snapshot,
onlineGroup: pick(useOnlineGroupStore.getState(), ["snapshot", "activeActivity"]),
ui: pick(useUiLayoutStore.getState(), ["preferences", "draft", "device", "selectedElement"]),
run,
dps: useDpsMeterStore.getState(),
completion: game.gameMode === "dungeon" && (activeDungeonRunIsComplete(game.activeDungeonId) || (run?.dungeonId === game.activeDungeonId && run.finishedAt !== null)),
interaction: candidate ? { prompt: candidate.object.interaction!.prompt, name: candidate.object.name, label: candidate.state.label } : null,
};
}
export type HudSnapshot = ReturnType<typeof currentHudSnapshot>;
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { THOR_DISPLAYS, THOR_UI_PIXELS_PER_INCH } from "./thorDisplayProfile";
describe("Thor physical display sizing", () => {
it("uses millimetres for relative panel size, with equal physical touch targets", () => {
const { top, bottom } = THOR_DISPLAYS;
expect(top.widthMm).toBeCloseTo(132.83, 1);
expect(bottom.widthMm).toBeCloseTo(75.12, 1);
expect(bottom.cssWidth / top.cssWidth).toBeCloseTo(bottom.widthMm / top.widthMm, 2);
expect(bottom.deviceScaleFactor).toBeGreaterThan(top.deviceScaleFactor);
for (const panel of [top, bottom]) {
expect(44 * panel.deviceScaleFactor / panel.pixelsPerInch * 25.4).toBeCloseTo(44 / THOR_UI_PIXELS_PER_INCH * 25.4);
}
});
});
+17
View File
@@ -0,0 +1,17 @@
/** AYN Thor panel dimensions, at a common physical UI density (160 CSS px/in). */
export const THOR_UI_PIXELS_PER_INCH = 160;
export function physicalDisplayProfile(width: number, height: number, diagonalInches: number) {
const pixelsPerInch = Math.hypot(width, height) / diagonalInches;
return {
width, height, diagonalInches, pixelsPerInch,
widthMm: width / pixelsPerInch * 25.4,
heightMm: height / pixelsPerInch * 25.4,
cssWidth: Math.round(width / pixelsPerInch * THOR_UI_PIXELS_PER_INCH),
cssHeight: Math.round(height / pixelsPerInch * THOR_UI_PIXELS_PER_INCH),
deviceScaleFactor: pixelsPerInch / THOR_UI_PIXELS_PER_INCH,
};
}
export const THOR_DISPLAYS = {
top: physicalDisplayProfile(1920, 1080, 6),
bottom: physicalDisplayProfile(1240, 1080, 3.92),
};
+30
View File
@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { RigidBody } from "@react-three/rapier";
import type { DungeonDefinition } from "../game/dungeonTypes";
import { DUNGEON_PHYSICAL_COLLISION_GROUPS, DUNGEON_WALL_COLLISION_GROUPS } from "./dungeonLineOfSight";
export function BossTestingRoom({ definition, onReady }: { readonly definition: DungeonDefinition; readonly onReady?: () => void }) {
const { min, max } = definition.bounds;
const x = (min[0] + max[0]) / 2;
const z = (min[2] + max[2]) / 2;
const y = definition.bosses[0].position[1];
const width = max[0] - min[0];
const depth = max[2] - min[2];
useEffect(() => {
onReady?.();
}, [onReady]);
return <group name="boss-testing-room">
<RigidBody type="fixed" colliders="cuboid" collisionGroups={DUNGEON_PHYSICAL_COLLISION_GROUPS} position={[x, y - 0.5, z]}>
<mesh receiveShadow><boxGeometry args={[width, 1, depth]} /><meshStandardMaterial color="#535c68" roughness={0.9} /></mesh>
</RigidBody>
{([
[min[0] - 0.5, y + 4, z, 1, 8, depth + 2], [max[0] + 0.5, y + 4, z, 1, 8, depth + 2],
[x, y + 4, min[2] - 0.5, width, 8, 1], [x, y + 4, max[2] + 0.5, width, 8, 1],
] as const).map(([px, py, pz, w, h, d], index) =>
<RigidBody key={index} type="fixed" colliders="cuboid" collisionGroups={DUNGEON_WALL_COLLISION_GROUPS} position={[px, py, pz]}>
<mesh receiveShadow><boxGeometry args={[w, h, d]} /><meshStandardMaterial color="#293441" roughness={0.95} /></mesh>
</RigidBody>
)}
<gridHelper args={[Math.max(width, depth), Math.ceil(Math.max(width, depth) / 5), "#8794a5", "#626d7b"]} position={[x, y + 0.015, z]} />
</group>;
}
+5 -1
View File
@@ -5,10 +5,12 @@ import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { ProductionDungeon } from "./ProductionDungeon"; import { ProductionDungeon } from "./ProductionDungeon";
import { PrototypeDungeon } from "./PrototypeDungeon"; import { PrototypeDungeon } from "./PrototypeDungeon";
import { BossTestingRoom } from "./BossTestingRoom";
export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void }) { export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void }) {
const activeDungeonId = useGameStore((state) => state.activeDungeonId); const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const gameMode = useGameStore((state) => state.gameMode); const gameMode = useGameStore((state) => state.gameMode);
const bossTest = useGameStore((state) => state.bossTest);
const sessionRevision = useGameStore((state) => state.sessionRevision); const sessionRevision = useGameStore((state) => state.sessionRevision);
const definition = requireDungeonDefinition(activeDungeonId); const definition = requireDungeonDefinition(activeDungeonId);
const manastormPackage = gameMode === "manastorm" const manastormPackage = gameMode === "manastorm"
@@ -31,6 +33,7 @@ export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void
&& assets.collision.length > 0; && assets.collision.length > 0;
useEffect(() => { useEffect(() => {
if (bossTest) return;
if (hasProductionAssets) return; if (hasProductionAssets) return;
const missing = [ const missing = [
...definition.assets.visual.filter((_, index) => !assets.visual[index]).map((asset) => asset.fileName), ...definition.assets.visual.filter((_, index) => !assets.visual[index]).map((asset) => asset.fileName),
@@ -38,8 +41,9 @@ export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void
]; ];
useGameStore.getState().setAssetStatus("proxy", `Waiting for ${missing.join(" and ") || "generated dungeon assets"}.`); useGameStore.getState().setAssetStatus("proxy", `Waiting for ${missing.join(" and ") || "generated dungeon assets"}.`);
onReady?.(); onReady?.();
}, [assets.collision, assets.visual, definition, hasProductionAssets, onReady]); }, [assets.collision, assets.visual, bossTest, definition, hasProductionAssets, onReady]);
if (bossTest) return <BossTestingRoom definition={definition} onReady={onReady} />;
if (!hasProductionAssets) return <PrototypeDungeon definition={definition} />; if (!hasProductionAssets) return <PrototypeDungeon definition={definition} />;
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
+4 -1
View File
@@ -47,6 +47,7 @@ import { combatHasLineOfSight } from "../game/combatSpatial";
import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset"; import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset";
import { scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling"; import { scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling";
import { dungeonEnemyScaling } from "../game/dungeonBossScaling"; import { dungeonEnemyScaling } from "../game/dungeonBossScaling";
import { contentCategoryForClass } from "../app/characterCatalog";
import { requireDungeonDefinition } from "../game/dungeonRegistry"; import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { stepFearedMobAway } from "../game/mobMotion"; import { stepFearedMobAway } from "../game/mobMotion";
import { import {
@@ -891,6 +892,7 @@ function ProxyMob({
const difficultyId = useGameStore((state) => state.activeDifficultyId); const difficultyId = useGameStore((state) => state.activeDifficultyId);
const bossDifficulty = useGameStore((state) => state.activeBossDifficulty); const bossDifficulty = useGameStore((state) => state.activeBossDifficulty);
const characterLevel = useCombatStore((state) => state.level); const characterLevel = useCombatStore((state) => state.level);
const characterSource = useCombatStore((state) => contentCategoryForClass(state.classId));
const manastormLevel = useManastormStore((state) => state.level); const manastormLevel = useManastormStore((state) => state.level);
const manastormPartySize = useManastormStore((state) => state.partySize); const manastormPartySize = useManastormStore((state) => state.partySize);
const manastormModeId = useManastormStore((state) => state.modeId); const manastormModeId = useManastormStore((state) => state.modeId);
@@ -933,7 +935,7 @@ function ProxyMob({
? definition.combat?.level ? definition.combat?.level
: scaledDungeonEnemyLevel(characterLevel, boss); : scaledDungeonEnemyLevel(characterLevel, boss);
const scaling = gameMode === "dungeon" const scaling = gameMode === "dungeon"
? dungeonEnemyScaling(dungeon, definition, instanceId, characterLevel, difficultyId, bossDifficulty) ? dungeonEnemyScaling(dungeon, definition, instanceId, characterLevel, difficultyId, bossDifficulty, characterSource)
: null; : null;
const healthMultiplier = scaling?.healthMultiplier ?? baseHealthMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1); const healthMultiplier = scaling?.healthMultiplier ?? baseHealthMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1);
useCombatStore.getState().registerMob(instanceId, { useCombatStore.getState().registerMob(instanceId, {
@@ -965,6 +967,7 @@ function ProxyMob({
}, [ }, [
boss, boss,
characterLevel, characterLevel,
characterSource,
definition.hasLoot, definition.hasLoot,
definition.id, definition.id,
definition.name, definition.name,
+43 -3
View File
@@ -28,6 +28,30 @@ html, body, #root {
body { color: var(--ink); } body { color: var(--ink); }
button { font: inherit; } button { font: inherit; }
.ability-tooltip {
position: fixed;
z-index: 10000;
width: min(340px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
overflow: hidden;
padding: 12px;
border: 1px solid var(--gold);
border-radius: 8px 3px;
background: var(--panel-solid);
box-shadow: var(--shadow);
color: var(--ink);
font-size: 12px;
line-height: 1.5;
text-align: left;
pointer-events: none;
}
.ability-tooltip header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.ability-tooltip header strong { color: var(--gold); font: 600 16px Georgia, serif; }
.ability-tooltip kbd { color: var(--muted); font-size: 10px; }
.ability-tooltip p { margin: 8px 0; white-space: pre-line; overflow-wrap: anywhere; }
.ability-tooltip footer { display: flex; flex-wrap: wrap; gap: 4px 12px; padding-top: 8px; border-top: 1px solid var(--edge); color: var(--moss-bright); font-size: 11px; }
.ability-tooltip .ability-tooltip__lock { margin-bottom: 0; color: var(--gold); }
.game-shell, .scene-shell { .game-shell, .scene-shell {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -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 { 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 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__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 { 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: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); } .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); }
@@ -1408,7 +1432,19 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.option-audio-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; } .option-audio-controls { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.ability-bindings-panel { width: min(980px, 100%); max-height: calc(100vh - 28px); } .ability-bindings-panel { width: min(980px, 100%); max-height: calc(100vh - 28px); }
.ability-bindings__layouts { display: flex; gap: 6px; margin-top: 10px; }
.ability-bindings__layouts button { flex: 1; min-height: 34px; padding: 6px 10px; border: 1px solid rgba(193,211,160,.18); border-radius: 8px; background: rgba(255,255,255,.025); color: var(--ink); cursor: pointer; }
.ability-bindings__layouts button.is-active { border-color: rgba(196,221,144,.4); background: rgba(103,148,78,.25); }
.ability-bindings__layers { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin: 10px 0; } .ability-bindings__layers { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin: 10px 0; }
.ability-bindings__bars { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.ability-bindings__wow { display: flex; min-width: 0; flex-direction: column; gap: 12px; padding: 12px; border: 1px solid rgba(190,210,157,.14); border-radius: 12px; background: rgba(4,12,8,.58); }
.ability-bindings__wow-slots { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.ability-bindings__wow-slots .ability-binding-slot { min-height: 76px; }
.ability-bindings__wow-slots .ability-binding-slot strong { font-size: 10px; }
.ability-bindings__wow-slots .ability-binding-slot small { font-size: 8px; }
.ability-bindings__wow-note { margin: 0; color: var(--muted); font-size: 10px; line-height: 1.5; }
.ability-bindings__wow .ability-bindings__restore { margin-top: auto; }
.ability-bindings__wow + .ability-bindings__picker .ability-bindings__choices { max-height: min(280px, 36vh); overflow: auto; padding: 2px; }
.ability-bindings__layers button { display: grid; min-width: 0; min-height: 42px; grid-template-columns: 31px minmax(0, 1fr); align-items: center; gap: 7px; padding: 5px 8px; border: 1px solid rgba(193,211,160,.13); border-radius: 8px; background: rgba(255,255,255,.025); color: var(--ink); cursor: pointer; text-align: left; } .ability-bindings__layers button { display: grid; min-width: 0; min-height: 42px; grid-template-columns: 31px minmax(0, 1fr); align-items: center; gap: 7px; padding: 5px 8px; border: 1px solid rgba(193,211,160,.13); border-radius: 8px; background: rgba(255,255,255,.025); color: var(--ink); cursor: pointer; text-align: left; }
.ability-bindings__layers button.is-active { border-color: rgba(196,221,144,.4); background: rgba(103,148,78,.25); } .ability-bindings__layers button.is-active { border-color: rgba(196,221,144,.4); background: rgba(103,148,78,.25); }
.ability-bindings__layers kbd { display: grid; width: 29px; height: 27px; place-items: center; border: 1px solid rgba(229,210,138,.24); border-radius: 7px; background: rgba(0,0,0,.28); color: #ecd98c; } .ability-bindings__layers kbd { display: grid; width: 29px; height: 27px; place-items: center; border: 1px solid rgba(229,210,138,.24); border-radius: 7px; background: rgba(0,0,0,.28); color: #ecd98c; }
@@ -1445,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 { 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: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); } .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-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.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; } .ability-bindings__choices button > span:last-child { min-width: 0; }
@@ -1604,6 +1643,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.ability-bindings__layers button { grid-template-columns: 1fr; justify-items: center; padding: 4px; text-align: center; } .ability-bindings__layers button { grid-template-columns: 1fr; justify-items: center; padding: 4px; text-align: center; }
.ability-bindings__layers kbd, .ability-bindings__layers small { display: none; } .ability-bindings__layers kbd, .ability-bindings__layers small { display: none; }
.ability-bindings__diagram { display: block; min-height: 0; padding: 8px; border-radius: 16px; } .ability-bindings__diagram { display: block; min-height: 0; padding: 8px; border-radius: 16px; }
.ability-bindings__wow-slots { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ability-bindings__shoulders { display: grid; grid-template-columns: repeat(2, 1fr); margin-bottom: 7px; } .ability-bindings__shoulders { display: grid; grid-template-columns: repeat(2, 1fr); margin-bottom: 7px; }
.ability-bindings__dpad { display: grid; max-width: 230px; margin: 0 auto 7px; } .ability-bindings__dpad { display: grid; max-width: 230px; margin: 0 auto 7px; }
.ability-bindings__face { transform: scale(.9); margin-block: -10px; } .ability-bindings__face { transform: scale(.9); margin-block: -10px; }
@@ -2220,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 b { overflow: hidden; font: 9px Georgia, serif; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }
.manastorm-spell-option small { color: var(--front-dim); font-size: 6px; } .manastorm-spell-option small { color: var(--front-dim); font-size: 6px; }
.manastorm-spell-option.is-active { border-color: rgba(142,231,239,.72); background: rgba(81,126,143,.28); color: #efffff; box-shadow: inset 0 0 12px rgba(100,210,225,.11); } .manastorm-spell-option.is-active { border-color: rgba(142,231,239,.72); background: rgba(81,126,143,.28); color: #efffff; box-shadow: inset 0 0 12px rgba(100,210,225,.11); }
.manastorm-spell-option:disabled { opacity: .42; } .manastorm-spell-option:is(:disabled, [aria-disabled="true"]) { opacity: .42; }
.manastorm-role-label { display: block; margin-top: 11px; } .manastorm-role-label { display: block; margin-top: 11px; }
.manastorm-role-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin-top: 5px; } .manastorm-role-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin-top: 5px; }
.manastorm-role-grid .dungeon-role-option { min-height: 47px; padding: 6px; } .manastorm-role-grid .dungeon-role-option { min-height: 47px; padding: 6px; }
@@ -3759,7 +3799,7 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
display: grid; justify-items: center; gap: .1rem; padding: .2rem; display: grid; justify-items: center; gap: .1rem; padding: .2rem;
border: 1px solid #9dad8277; background: #08130feb; color: #fff; border: 1px solid #9dad8277; background: #08130feb; color: #fff;
} }
.arena-personal-bars button:disabled { opacity: .55; } .arena-personal-bars button:is(:disabled, [aria-disabled="true"]) { opacity: .55; }
.arena-personal-bars button kbd { position: absolute; top: .1rem; right: .2rem; z-index: 1; color: #fff5aa; font-size: .7rem; text-shadow: 1px 1px #000; } .arena-personal-bars button kbd { position: absolute; top: .1rem; right: .2rem; z-index: 1; color: #fff5aa; font-size: .7rem; text-shadow: 1px 1px #000; }
.arena-personal-bars button .action-slot__icon { width: 1.9rem; height: 1.9rem; } .arena-personal-bars button .action-slot__icon { width: 1.9rem; height: 1.9rem; }
.arena-personal-bars button > span:last-of-type { font-size: .58rem; line-height: 1.15; overflow: hidden; } .arena-personal-bars button > span:last-of-type { font-size: .58rem; line-height: 1.15; overflow: hidden; }
+204 -64
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { classById } from "../app/characterCatalog"; import { classById } from "../app/characterCatalog";
import { import {
ACTION_CONTROLS, ACTION_CONTROLS,
@@ -10,13 +10,17 @@ import {
type ActionBindingLayer, type ActionBindingLayer,
} from "../game/actionBindings"; } from "../game/actionBindings";
import { useCombatStore } from "../game/combatStore"; import { useCombatStore } from "../game/combatStore";
import { abilityAtLevel, defaultActionBarForCharacter, type AbilityDefinition } from "../game/abilityCatalog";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { actionKeyLabel, assignedActionBarAbilityIds, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller"; import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller";
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "../input/keybinds"; import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime"; import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
import { useInputBindingLabels } from "../input/useInputBindingLabels"; import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useMenuController, type MenuAction } from "../input/useMenuController"; import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton"; import { ControllerButton } from "./ControllerButton";
import { AbilityTooltipTarget, type AbilityTooltipData } from "./AbilityTooltip";
function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: string; color: string }) { function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: string; color: string }) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
@@ -29,27 +33,38 @@ function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: stri
} }
type RestoreScope = "layer" | "all"; type RestoreScope = "layer" | "all";
type BindingLayout = "controller" | "wow";
type BindingControl = ActionBindingControl | number;
interface RestoreRequest { interface RestoreRequest {
scope: RestoreScope; scope: RestoreScope;
layout: BindingLayout;
layer: ActionBindingLayer; layer: ActionBindingLayer;
bar: number;
cancelFocusId: string; cancelFocusId: string;
completeFocusId: string; completeFocusId: string;
} }
const layerControlId = (layer: ActionBindingLayer) => `binding-layer-${layer}`; const layerControlId = (layer: ActionBindingLayer) => `binding-layer-${layer}`;
const slotControlId = (control: ActionBindingControl) => `binding-slot-${control}`; const barControlId = (bar: number) => `binding-bar-${bar}`;
const slotControlId = (control: BindingControl) => typeof control === "number" ? `binding-wow-slot-${control}` : `binding-slot-${control}`;
const choiceControlId = (abilityId: string) => `binding-choice-${abilityId}`; const choiceControlId = (abilityId: string) => `binding-choice-${abilityId}`;
const MAIN_ACTION_NEIGHBORS: Readonly<Record<string, NonNullable<MenuAction["neighbors"]>>> = { const MAIN_ACTION_NEIGHBORS: Readonly<Record<string, NonNullable<MenuAction["neighbors"]>>> = {
"binding-layout-controller": {
up: "binding-restore-layer", down: "binding-layer-primary", left: "binding-layout-wow", right: "binding-layout-wow",
},
"binding-layout-wow": {
up: "binding-restore-all", down: "binding-layer-tertiary", left: "binding-layout-controller", right: "binding-layout-controller",
},
"binding-layer-primary": { "binding-layer-primary": {
up: "binding-restore-layer", down: "binding-slot-dpad-left", left: "binding-layer-tertiary", right: "binding-layer-secondary", up: "binding-layout-controller", down: "binding-slot-dpad-left", left: "binding-layer-tertiary", right: "binding-layer-secondary",
}, },
"binding-layer-secondary": { "binding-layer-secondary": {
up: "binding-restore-layer", down: "binding-slot-r1", left: "binding-layer-primary", right: "binding-layer-tertiary", up: "binding-layout-controller", down: "binding-slot-r1", left: "binding-layer-primary", right: "binding-layer-tertiary",
}, },
"binding-layer-tertiary": { "binding-layer-tertiary": {
up: "binding-restore-all", down: "binding-slot-r2", left: "binding-layer-secondary", right: "binding-layer-primary", up: "binding-layout-wow", down: "binding-slot-r2", left: "binding-layer-secondary", right: "binding-layer-primary",
}, },
"binding-slot-r1": { "binding-slot-r1": {
up: "binding-layer-secondary", down: "binding-slot-face-top", left: "binding-slot-dpad-right", right: "binding-slot-r2", up: "binding-layer-secondary", down: "binding-slot-face-top", left: "binding-slot-dpad-right", right: "binding-slot-r2",
@@ -83,20 +98,48 @@ 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 keyLabel = useInputBindingLabels();
const bindingChord = (layer: ActionBindingLayer, control: ActionBindingControl) => `${keyLabel("controller", `ability:${layer}:${control}`)} / ${keyLabel("pc", `ability:${layer}:${control}`)}`; const bindingChord = (layer: ActionBindingLayer, control: ActionBindingControl) => `${keyLabel("controller", `ability:${layer}:${control}`)} / ${keyLabel("pc", `ability:${layer}:${control}`)}`;
const openOverlay = useGameStore((state) => state.openOverlay); const openOverlay = useGameStore((state) => state.openOverlay);
const classId = useCombatStore((state) => state.classId); const classId = useCombatStore((state) => state.classId);
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
const level = useCombatStore((state) => state.level); const level = useCombatStore((state) => state.level);
const abilities = useCombatStore((state) => state.abilities); const abilities = useCombatStore((state) => state.abilities);
const talentRanks = useCombatStore((state) => state.talentRanks);
const tooltipData = (definition: AbilityDefinition | null | undefined): AbilityTooltipData | null => {
if (!definition) return null;
const talentRank = definition.talentEntryId === undefined ? undefined : talentRanks[`coa-entry-${definition.talentEntryId}`] ?? 0;
const ability = abilityAtLevel(definition, level, talentRank);
return {
name: ability.name, description: ability.description, target: ability.target,
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000,
range: ability.range.max, lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
talentLocked: ability.talentEntryId !== undefined && !talentRank,
};
};
const bindings = useCombatStore((state) => state.actionBindings); const bindings = useCombatStore((state) => state.actionBindings);
const pcLayout = useUiLayoutStore((state) => state.preferences.current.pc);
const [layout, setLayout] = useState<BindingLayout>(() => currentUiDevice() === "pc" && pcLayout.wowBars ? "wow" : "controller");
const [bar, setBar] = useState(0);
const [layer, setLayer] = useState<ActionBindingLayer>("primary"); const [layer, setLayer] = useState<ActionBindingLayer>("primary");
const [editingControl, setEditingControl] = useState<ActionBindingControl | null>(null); const [editingControl, setEditingControl] = useState<BindingControl | null>(null);
const [restoreRequest, setRestoreRequest] = useState<RestoreRequest | null>(null); const [restoreRequest, setRestoreRequest] = useState<RestoreRequest | null>(null);
const [notice, setNotice] = useState("Choose a button, then choose the ability it should use."); const [notice, setNotice] = useState("Choose a button, then choose the ability it should use.");
const classColor = classById(classId).color; const classColor = classById(classId).color;
const editingAbilityId = editingControl ? bindings[actionBindingId(layer, editingControl)] : null; 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"
? `Bar ${bar + 1} · Button ${control + 1}${pcLayout.bars[bar].slots[control].key ? ` · ${actionKeyLabel(pcLayout.bars[bar].slots[control].key)}` : ""}`
: bindingChord(layer, control);
const editingAbilityId = editingControl !== null ? assignment(editingControl) : null;
const pickerAbilities = useMemo(() => { const pickerAbilities = useMemo(() => {
const ordered = abilities const ordered = abilities
.filter((ability) => !ability.passive) .filter((ability) => !ability.passive)
@@ -108,7 +151,32 @@ function AbilityBindingsDialog() {
] ]
: ordered; : ordered;
}, [abilities, editingAbilityId]); }, [abilities, editingAbilityId]);
const close = () => openOverlay("pause"); const close = onClose ?? (() => openOverlay("pause"));
const saveWowLayout = (update: (current: UiLayout) => UiLayout) => {
try {
const ui = useUiLayoutStore.getState();
ui.persist({ ...ui.preferences, current: { ...ui.preferences.current, pc: update(ui.preferences.current.pc) } });
return true;
} catch (error) {
setNotice(error instanceof Error ? error.message : "Could not save WoW ability bindings.");
return false;
}
};
const setBinding = (control: BindingControl, abilityId: string | null) => {
if (typeof control === "number") return saveWowLayout((current) => assignDesktopAbility(current, bar, control, abilityId));
useCombatStore.getState().setActionBinding(layer, control, abilityId);
return true;
};
const chooseLayout = (next: BindingLayout) => {
setLayout(next); setEditingControl(null); setRestoreRequest(null);
setNotice(`Choose a ${next === "wow" ? "WoW bar" : "controller"} button, then choose its ability.`);
controller.select(`binding-layout-${next}`);
};
const chooseBar = (next: number) => {
setBar(next); setEditingControl(null); setRestoreRequest(null);
setNotice(`Bar ${next + 1} selected.`);
controller.select(barControlId(next));
};
const chooseLayer = (next: ActionBindingLayer) => { const chooseLayer = (next: ActionBindingLayer) => {
setLayer(next); setLayer(next);
@@ -117,40 +185,42 @@ function AbilityBindingsDialog() {
setNotice(`${ACTION_LAYER_LABELS[next]} layer selected.`); setNotice(`${ACTION_LAYER_LABELS[next]} layer selected.`);
controller.select(layerControlId(next)); controller.select(layerControlId(next));
}; };
const beginEditing = (control: ActionBindingControl) => { const beginEditing = (control: BindingControl) => {
const abilityId = bindings[actionBindingId(layer, control)]; const abilityId = assignment(control);
setEditingControl(control); setEditingControl(control);
setRestoreRequest(null); setRestoreRequest(null);
controller.select(abilityId ? choiceControlId(abilityId) : "binding-choice-empty"); controller.select(abilityId ? choiceControlId(abilityId) : "binding-choice-empty");
}; };
const cancelEditing = () => { const cancelEditing = () => {
if (!editingControl) return; if (editingControl === null) return;
const control = editingControl; const control = editingControl;
setEditingControl(null); setEditingControl(null);
controller.select(slotControlId(control)); controller.select(slotControlId(control));
}; };
const assign = (abilityId: string | null) => { const assign = (abilityId: string | null) => {
if (!editingControl) return; if (editingControl === null) return;
const control = editingControl; const control = editingControl;
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null; const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
useCombatStore.getState().setActionBinding(layer, control, abilityId); if (!setBinding(control, abilityId)) return;
setNotice(`${bindingChord(layer, control)}: ${ability?.name ?? "Empty"}.`); setNotice(`${controlLabel(control)}: ${ability?.name ?? "Empty"}. Saved.`);
setEditingControl(null); setEditingControl(null);
controller.select(slotControlId(control)); controller.select(slotControlId(control));
}; };
const clearSelected = (control: ActionBindingControl | null = editingControl) => { const clearSelected = (control: BindingControl | null = editingControl) => {
if (!control) return; if (control === null) return;
useCombatStore.getState().setActionBinding(layer, control, null); if (!setBinding(control, null)) return;
setNotice(`${bindingChord(layer, control)} cleared.`); setNotice(`${controlLabel(control)} cleared. Saved.`);
setEditingControl(null); setEditingControl(null);
controller.select(slotControlId(control)); controller.select(slotControlId(control));
}; };
const requestRestore = (scope: RestoreScope, focusId: string) => { const requestRestore = (scope: RestoreScope, focusId: string) => {
setRestoreRequest({ setRestoreRequest({
scope, scope,
layout,
layer, layer,
bar,
cancelFocusId: focusId, cancelFocusId: focusId,
completeFocusId: editingControl ? slotControlId(editingControl) : focusId, completeFocusId: editingControl !== null ? slotControlId(editingControl) : focusId,
}); });
controller.select("binding-confirm-cancel"); controller.select("binding-confirm-cancel");
}; };
@@ -163,7 +233,11 @@ function AbilityBindingsDialog() {
const confirmRestore = () => { const confirmRestore = () => {
if (!restoreRequest) return; if (!restoreRequest) return;
const request = restoreRequest; const request = restoreRequest;
if (request.scope === "layer") { if (request.layout === "wow") {
const defaults = defaultActionBarForCharacter(classId, secondaryClassId, level);
if (!saveWowLayout((current) => resetDesktopAbilities(current, defaults, request.scope === "layer" ? request.bar : undefined))) return;
setNotice(request.scope === "layer" ? `Bar ${request.bar + 1} abilities restored. Saved.` : "All WoW bar abilities restored. Saved.");
} else if (request.scope === "layer") {
useCombatStore.getState().resetActionBindingLayer(request.layer); useCombatStore.getState().resetActionBindingLayer(request.layer);
setNotice(`${ACTION_LAYER_LABELS[request.layer]} layer restored.`); setNotice(`${ACTION_LAYER_LABELS[request.layer]} layer restored.`);
} else { } else {
@@ -190,53 +264,72 @@ function AbilityBindingsDialog() {
}, },
]; ];
} }
if (editingControl) { if (editingControl !== null) {
return [ return [
...pickerAbilities.map((ability) => ({ id: choiceControlId(ability.id), run: () => assign(ability.id) })), ...pickerAbilities.map((ability) => ({ id: choiceControlId(ability.id), run: () => assign(ability.id) })),
{ id: "binding-choice-empty", run: () => assign(null) }, { id: "binding-choice-empty", run: () => assign(null) },
]; ];
} }
return [ return [
...ACTION_LAYERS.map((nextLayer) => { ...(["controller", "wow"] as const).map((next) => ({
const id = layerControlId(nextLayer); id: `binding-layout-${next}`, run: () => chooseLayout(next),
return { id, run: () => chooseLayer(nextLayer), neighbors: MAIN_ACTION_NEIGHBORS[id] }; neighbors: isWow ? { up: "binding-restore-all", down: barControlId(bar), left: `binding-layout-${next === "wow" ? "controller" : "wow"}`, right: `binding-layout-${next === "wow" ? "controller" : "wow"}` } : MAIN_ACTION_NEIGHBORS[`binding-layout-${next}`],
}), })),
...ACTION_CONTROLS.map((control) => { ...(isWow ? [
const id = slotControlId(control); ...pcLayout.bars.map((_, index) => ({ id: barControlId(index), run: () => chooseBar(index), neighbors: {
return { id, run: () => beginEditing(control), neighbors: MAIN_ACTION_NEIGHBORS[id] }; up: "binding-layout-wow", down: slotControlId(index), left: barControlId((index + 4) % 5), right: barControlId((index + 1) % 5),
}), } })),
...pcLayout.bars[bar].slots.map((_, slot) => ({ id: slotControlId(slot), run: () => beginEditing(slot), neighbors: {
up: slot < 4 ? barControlId(bar) : slotControlId(slot - 4),
down: slot >= 8 ? "binding-restore-layer" : slotControlId(slot + 4),
left: slotControlId((slot + 11) % 12), right: slotControlId((slot + 1) % 12),
} })),
] : [
...ACTION_LAYERS.map((nextLayer) => {
const id = layerControlId(nextLayer);
return { id, run: () => chooseLayer(nextLayer), neighbors: MAIN_ACTION_NEIGHBORS[id] };
}),
...ACTION_CONTROLS.map((control) => {
const id = slotControlId(control);
return { id, run: () => beginEditing(control), neighbors: MAIN_ACTION_NEIGHBORS[id] };
}),
]),
{ {
id: "binding-restore-layer", id: "binding-restore-layer",
run: () => requestRestore("layer", "binding-restore-layer"), run: () => requestRestore("layer", "binding-restore-layer"),
neighbors: MAIN_ACTION_NEIGHBORS["binding-restore-layer"], neighbors: isWow ? { up: slotControlId(8), down: "binding-layout-controller", left: "binding-restore-all", right: "binding-restore-all" } : MAIN_ACTION_NEIGHBORS["binding-restore-layer"],
}, },
{ {
id: "binding-restore-all", id: "binding-restore-all",
run: () => requestRestore("all", "binding-restore-all"), run: () => requestRestore("all", "binding-restore-all"),
neighbors: MAIN_ACTION_NEIGHBORS["binding-restore-all"], neighbors: isWow ? { up: slotControlId(11), down: "binding-layout-wow", left: "binding-restore-layer", right: "binding-restore-layer" } : MAIN_ACTION_NEIGHBORS["binding-restore-all"],
}, },
]; ];
}, [abilities, bindings, editingControl, layer, pickerAbilities, restoreRequest]); }, [abilities, bindings, classId, secondaryClassId, level, editingControl, layer, layout, bar, pcLayout, pickerAbilities, restoreRequest]);
const controller = useMenuController(actions, { const controller = useMenuController(actions, {
columns: restoreRequest ? 2 : editingControl ? 1 : 3, columns: restoreRequest ? 2 : editingControl !== null ? 1 : isWow ? 4 : 3,
initialId: restoreRequest initialId: restoreRequest
? "binding-confirm-cancel" ? "binding-confirm-cancel"
: editingControl : editingControl !== null
? editingAbilityId ? choiceControlId(editingAbilityId) : "binding-choice-empty" ? editingAbilityId ? choiceControlId(editingAbilityId) : "binding-choice-empty"
: layerControlId(layer), : isWow ? barControlId(bar) : layerControlId(layer),
onBack: restoreRequest ? cancelRestore : editingControl ? cancelEditing : close, onBack: restoreRequest ? cancelRestore : editingControl !== null ? cancelEditing : close,
}); });
useEffect(() => {
panel.current?.querySelector(".is-controller-selected")?.scrollIntoView({ block: "nearest" });
}, [controller.selectedId]);
useEffect(() => { useEffect(() => {
const run = (id: string | undefined) => { const run = (id: string | undefined) => {
if (restoreRequest) return; if (restoreRequest) return;
if (id === "editor-layer-2") chooseLayer("secondary"); if (id === "editor-layer-2") { if (isWow) chooseBar(1); else chooseLayer("secondary"); }
else if (id === "editor-layer-3") chooseLayer("tertiary"); else if (id === "editor-layer-3") { if (isWow) chooseBar(2); else chooseLayer("tertiary"); }
else if (id === "editor-clear") { else if (id === "editor-clear") {
if (editingControl) clearSelected(editingControl); if (editingControl !== null) clearSelected(editingControl);
else { else {
const prefix = "binding-slot-"; const prefix = isWow ? "binding-wow-slot-" : "binding-slot-";
if (controller.selectedId.startsWith(prefix)) clearSelected(controller.selectedId.slice(prefix.length) as ActionBindingControl); if (controller.selectedId.startsWith(prefix)) clearSelected(isWow ? Number(controller.selectedId.slice(prefix.length)) : controller.selectedId.slice(prefix.length) as ActionBindingControl);
} }
} else if (id === "editor-reset") requestRestore("layer", controller.selectedId); } else if (id === "editor-reset") requestRestore("layer", controller.selectedId);
}; };
@@ -252,7 +345,7 @@ function AbilityBindingsDialog() {
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
return () => { unsubscribe(); window.removeEventListener("keydown", onKey); }; return () => { unsubscribe(); window.removeEventListener("keydown", onKey); };
}, [controller.selectedId, editingControl, layer, restoreRequest]); }, [controller.selectedId, editingControl, layer, layout, bar, pcLayout, restoreRequest]);
const bindingSlot = (control: ActionBindingControl) => { const bindingSlot = (control: ActionBindingControl) => {
const definition = ACTION_CONTROL_DEFINITIONS[control]; const definition = ACTION_CONTROL_DEFINITIONS[control];
@@ -260,6 +353,7 @@ function AbilityBindingsDialog() {
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null; const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
const locked = Boolean(ability && ability.unlockLevel > level); const locked = Boolean(ability && ability.unlockLevel > level);
return ( return (
<AbilityTooltipTarget key={control} ability={tooltipData(ability)} bindingLabel={bindingChord(layer, control)}>
<ControllerButton <ControllerButton
key={control} key={control}
controlId={slotControlId(control)} controlId={slotControlId(control)}
@@ -277,21 +371,38 @@ function AbilityBindingsDialog() {
: <span className="ability-bindings__empty" aria-hidden="true">+</span>} : <span className="ability-bindings__empty" aria-hidden="true">+</span>}
<span><strong>{definition.label}</strong><small>{locked ? `${ability!.name} · Level ${ability!.unlockLevel}` : ability?.name ?? "Empty"}</small></span> <span><strong>{definition.label}</strong><small>{locked ? `${ability!.name} · Level ${ability!.unlockLevel}` : ability?.name ?? "Empty"}</small></span>
</ControllerButton> </ControllerButton>
</AbilityTooltipTarget>
); );
}; };
return ( return (
<div className="modal-backdrop game-panel-backdrop" role="presentation"> <div className="modal-backdrop game-panel-backdrop" role="presentation">
<section className="game-panel ability-bindings-panel" role="dialog" aria-modal="true" aria-labelledby="ability-bindings-title"> <section ref={panel} className="game-panel ability-bindings-panel" role="dialog" aria-modal="true" aria-labelledby="ability-bindings-title">
<header className="game-panel__header"> <header className="game-panel__header">
<div> <div>
<p className="eyebrow">Controller loadout · 24 customizable slots</p> <p className="eyebrow">{isWow ? "WoW loadout · 5 bars · 60 customizable slots" : "Controller loadout · 24 customizable slots"}</p>
<h2 id="ability-bindings-title">Ability controls</h2> <h2 id="ability-bindings-title">Ability controls</h2>
</div> </div>
<button type="button" className="icon-button" onClick={close} aria-label="Return to pause menu">×</button> <button type="button" className="icon-button" onClick={close} aria-label="Close button assignments">×</button>
</header> </header>
<nav className="ability-bindings__layers" aria-label="Ability binding layers"> <nav className="ability-bindings__layouts" aria-label="Action bar style">
{(["controller", "wow"] as const).map((next) => (
<ControllerButton key={next} controlId={`binding-layout-${next}`} selectedId={controller.selectedId} select={controller.select}
type="button" aria-pressed={layout === next} className={layout === next ? "is-active" : ""} onClick={() => chooseLayout(next)}>
{next === "wow" ? "WoW bars" : "Controller bars"}
</ControllerButton>
))}
</nav>
{isWow ? <nav className="ability-bindings__layers ability-bindings__bars" aria-label="WoW action bars">
{pcLayout.bars.map((_, index) => (
<ControllerButton key={index} controlId={barControlId(index)} selectedId={controller.selectedId} select={controller.select}
type="button" aria-pressed={bar === index} className={bar === index ? "is-active" : ""} onClick={() => chooseBar(index)}>
<kbd>{index + 1}</kbd><span><strong>Bar {index + 1}</strong><small>{pcLayout.wowBars && index < pcLayout.barCount ? "Enabled" : "Inactive"}</small></span>
</ControllerButton>
))}
</nav> : <nav className="ability-bindings__layers" aria-label="Ability binding layers">
{ACTION_LAYERS.map((nextLayer) => ( {ACTION_LAYERS.map((nextLayer) => (
<ControllerButton <ControllerButton
key={nextLayer} key={nextLayer}
@@ -307,10 +418,32 @@ function AbilityBindingsDialog() {
<span><strong>{ACTION_LAYER_LABELS[nextLayer]}</strong><small>{nextLayer === "primary" ? "No modifier" : `Hold ${ACTION_LAYER_LABELS[nextLayer]}`}</small></span> <span><strong>{ACTION_LAYER_LABELS[nextLayer]}</strong><small>{nextLayer === "primary" ? "No modifier" : `Hold ${ACTION_LAYER_LABELS[nextLayer]}`}</small></span>
</ControllerButton> </ControllerButton>
))} ))}
</nav> </nav>}
<div className="ability-bindings__layout"> <div className="ability-bindings__layout">
<section className="ability-bindings__diagram" aria-label={`${ACTION_LAYER_LABELS[layer]} controller layout`}> {isWow ? <section className="ability-bindings__wow" aria-label={`Bar ${bar + 1} ability assignments`}>
<div className="ability-bindings__wow-slots">
{pcLayout.bars[bar].slots.map((slot, index) => {
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 <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></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>
<div className="ability-bindings__restore">
<ControllerButton controlId="binding-restore-layer" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("layer", "binding-restore-layer")}>Restore Bar {bar + 1}</ControllerButton>
<ControllerButton controlId="binding-restore-all" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("all", "binding-restore-all")}>Restore all WoW bars</ControllerButton>
</div>
</section> : <section className="ability-bindings__diagram" aria-label={`${ACTION_LAYER_LABELS[layer]} controller layout`}>
<div className="ability-bindings__shoulders"> <div className="ability-bindings__shoulders">
{bindingSlot("r1")} {bindingSlot("r1")}
{bindingSlot("r2")} {bindingSlot("r2")}
@@ -330,16 +463,20 @@ function AbilityBindingsDialog() {
<ControllerButton controlId="binding-restore-layer" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("layer", "binding-restore-layer")}>Restore {ACTION_LAYER_LABELS[layer]}</ControllerButton> <ControllerButton controlId="binding-restore-layer" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("layer", "binding-restore-layer")}>Restore {ACTION_LAYER_LABELS[layer]}</ControllerButton>
<ControllerButton controlId="binding-restore-all" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("all", "binding-restore-all")}>Restore all</ControllerButton> <ControllerButton controlId="binding-restore-all" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("all", "binding-restore-all")}>Restore all</ControllerButton>
</div> </div>
</section> </section>}
<aside className="ability-bindings__picker" aria-live="polite"> <aside className="ability-bindings__picker" aria-live="polite">
{restoreRequest ? ( {restoreRequest ? (
<> <>
<p className="eyebrow">Confirm controller reset</p> <p className="eyebrow">Confirm {restoreRequest.layout === "wow" ? "WoW bar" : "controller"} reset</p>
<h3>{restoreRequest.scope === "all" <h3>{restoreRequest.layout === "wow"
? restoreRequest.scope === "all" ? "Restore all WoW bar abilities?" : `Restore Bar ${restoreRequest.bar + 1} abilities?`
: restoreRequest.scope === "all"
? "Restore all ability controls?" ? "Restore all ability controls?"
: `Restore the ${ACTION_LAYER_LABELS[restoreRequest.layer]} layer?`}</h3> : `Restore the ${ACTION_LAYER_LABELS[restoreRequest.layer]} layer?`}</h3>
<p className="ability-bindings__confirm-warning">{restoreRequest.scope === "all" <p className="ability-bindings__confirm-warning">{restoreRequest.layout === "wow"
? `This replaces ${restoreRequest.scope === "all" ? "all 60 WoW slots" : "this bar's 12 slots"} with the class defaults. Keyboard shortcuts and bar layout are kept.`
: restoreRequest.scope === "all"
? "This replaces every customized controller slot with the class defaults." ? "This replaces every customized controller slot with the class defaults."
: `This replaces all eight ${ACTION_LAYER_LABELS[restoreRequest.layer]} slots with their class defaults.`}</p> : `This replaces all eight ${ACTION_LAYER_LABELS[restoreRequest.layer]} slots with their class defaults.`}</p>
<div className="ability-bindings__choices ability-bindings__choices--confirm"> <div className="ability-bindings__choices ability-bindings__choices--confirm">
@@ -353,16 +490,19 @@ function AbilityBindingsDialog() {
</ControllerButton> </ControllerButton>
</div> </div>
</> </>
) : editingControl ? ( ) : editingControl !== null ? (
<> <>
<p className="eyebrow">Assign {bindingChord(layer, editingControl)}</p> <p className="eyebrow">Assign {controlLabel(editingControl)}</p>
<h3>Choose an ability</h3> <h3>Choose an ability</h3>
<p className="action-bar-missing-legend">Light blue: missing from action bars</p>
<div className="ability-bindings__choices"> <div className="ability-bindings__choices">
{pickerAbilities.map((ability) => ( {pickerAbilities.map((ability) => (
<ControllerButton key={ability.id} controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""}`} onClick={() => assign(ability.id)}> <AbilityTooltipTarget key={ability.id} ability={tooltipData(ability)}>
<ControllerButton controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""} ${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} /> <AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} />
<span><strong>{ability.name}</strong><small>{ability.unlockLevel > level ? `Unlocks at level ${ability.unlockLevel}` : ability.id === editingAbilityId ? "Current assignment" : ability.target === "friendly" ? "Friendly / self" : ability.target}</small></span> <span><strong>{ability.name}</strong><small>{ability.unlockLevel > level ? `Unlocks at level ${ability.unlockLevel}` : ability.id === editingAbilityId ? "Current assignment" : ability.target === "friendly" ? "Friendly / self" : ability.target}</small></span>
</ControllerButton> </ControllerButton>
</AbilityTooltipTarget>
))} ))}
<ControllerButton controlId="binding-choice-empty" selectedId={controller.selectedId} select={controller.select} type="button" className={editingAbilityId === null ? "is-current" : ""} onClick={() => assign(null)}> <ControllerButton controlId="binding-choice-empty" selectedId={controller.selectedId} select={controller.select} type="button" className={editingAbilityId === null ? "is-current" : ""} onClick={() => assign(null)}>
<span className="ability-bindings__empty" aria-hidden="true">×</span> <span className="ability-bindings__empty" aria-hidden="true">×</span>
@@ -372,26 +512,26 @@ function AbilityBindingsDialog() {
</> </>
) : ( ) : (
<> <>
<p className="eyebrow">{ACTION_LAYER_LABELS[layer]} layer</p> <p className="eyebrow">{isWow ? `Bar ${bar + 1}` : `${ACTION_LAYER_LABELS[layer]} layer`}</p>
<h3>Select a controller button</h3> <h3>Select a {isWow ? "WoW bar" : "controller"} button</h3>
<p>Each layer has the four face buttons, R1, R2, and both horizontal D-pad directions. Abilities may be reused on more than one button.</p> <p>{isWow ? "Each WoW bar has 12 buttons. Assign abilities on any of the five bars, including inactive bars. Change keyboard shortcuts in Options → Keybinds." : "Each layer has the four face buttons, R1, R2, and both horizontal D-pad directions. Abilities may be reused on more than one button."}</p>
<p className="ability-bindings__notice" role="status">{notice}</p>
</> </>
)} )}
</aside> </aside>
</div> </div>
<p className="ability-bindings__notice" role="status">{notice}</p>
<footer className="game-panel__footer"> <footer className="game-panel__footer">
{restoreRequest {restoreRequest
? <><span><kbd>{keyLabel("controller", "menu-confirm")}</kbd> Choose</span><span><kbd>{keyLabel("controller", "menu-back")}</kbd> Cancel</span></> ? <><span><kbd>{keyLabel("controller", "menu-confirm")}</kbd> Choose</span><span><kbd>{keyLabel("controller", "menu-back")}</kbd> Cancel</span></>
: <><span><kbd>{keyLabel("controller", "menu-confirm")}</kbd> Assign</span><span><kbd>{keyLabel("controller", "editor-clear")}</kbd> Clear</span><span><kbd>{keyLabel("controller", "menu-back")}</kbd> Back</span><span><kbd>{keyLabel("controller", "editor-reset")}</kbd> Restore layer</span></>} : <><span><kbd>{keyLabel("controller", "menu-confirm")}</kbd> Assign</span><span><kbd>{keyLabel("controller", "editor-clear")}</kbd> Clear</span><span><kbd>{keyLabel("controller", "menu-back")}</kbd> Back</span><span><kbd>{keyLabel("controller", "editor-reset")}</kbd> Restore {isWow ? "bar" : "layer"}</span></>}
</footer> </footer>
</section> </section>
</div> </div>
); );
} }
export function AbilityBindingsPanel() { export function AbilityBindingsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
const open = useGameStore((state) => state.overlay === "bindings"); const storeOpen = useGameStore((state) => state.overlay === "bindings");
return open ? <AbilityBindingsDialog /> : null; return (open ?? storeOpen) ? <AbilityBindingsDialog onClose={onClose} /> : null;
} }
+74
View File
@@ -0,0 +1,74 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import {
AbilityTooltipContent, AbilityTooltipTarget, abilityTooltipFacts, abilityTooltipPosition,
type AbilityTooltipData,
} from "./AbilityTooltip";
const ability: AbilityTooltipData = {
name: "Healing Wave", description: "Heals a friendly target for 120 health.",
resourceCost: 15, resourceLabel: "mana", cooldownSeconds: 6,
castSeconds: 1.5, range: 40, target: "friendly",
};
describe("ability tooltips", () => {
it("shows the ability's description, binding, and gameplay facts", () => {
const markup = renderToStaticMarkup(createElement(AbilityTooltipContent, { ability, bindingLabel: "Shift+1" }));
for (const text of [ability.name, ability.description!, "Shift+1", "15 mana", "1.5 sec cast", "6 sec cooldown", "40 m range", "Selected ally or self"]) {
expect(markup).toContain(text);
}
});
it("explains free, instant abilities and distinguishes no cooldown from missing data", () => {
expect(abilityTooltipFacts({ name: "Free", resourceCost: 0, castSeconds: 0, cooldownSeconds: 0 }))
.toEqual(["No cost", "Instant", "No cooldown"]);
expect(abilityTooltipFacts({ name: "Unknown" })).toEqual([]);
});
it("shows unlock and talent requirements without hiding the description", () => {
const markup = renderToStaticMarkup(createElement(AbilityTooltipContent, {
ability: { ...ability, lockedUntilLevel: 20, talentLocked: true },
}));
expect(markup).toContain(ability.description);
expect(markup).toContain("Unlocks at level 20");
expect(markup).toContain("Learn in talents");
});
it("does not present active cast facts for passive abilities", () => {
expect(abilityTooltipFacts({ ...ability, passive: true, rank: 2 })).toEqual(["Passive", "Rank 2"]);
});
it("preserves inspectable unavailable controls and creates no layout wrapper", () => {
const markup = renderToStaticMarkup(createElement(AbilityTooltipTarget, {
ability,
children: createElement("button", { "aria-disabled": true, title: "Old browser tooltip" }, "Healing Wave"),
}));
expect(markup).toBe('<button aria-disabled="true">Healing Wave</button>');
});
it("keeps the empty-slot browser hint without creating an ability tooltip", () => {
const markup = renderToStaticMarkup(createElement(AbilityTooltipTarget, {
ability: null,
children: createElement("button", { title: "Empty binding" }, "Empty"),
}));
expect(markup).toBe('<button title="Empty binding">Empty</button>');
});
it("places a bottom-bar tooltip above its control", () => {
expect(abilityTooltipPosition({ left: 400, right: 440, top: 700, bottom: 740 }, { width: 300, height: 180 }, { width: 1000, height: 800 }))
.toEqual({ left: 270, top: 512 });
});
it("flips below top-edge controls and clamps to either horizontal edge", () => {
expect(abilityTooltipPosition({ left: 0, right: 40, top: 20, bottom: 60 }, { width: 300, height: 180 }, { width: 1000, height: 800 }))
.toEqual({ left: 12, top: 68 });
expect(abilityTooltipPosition({ left: 960, right: 1000, top: 20, bottom: 60 }, { width: 300, height: 180 }, { width: 1000, height: 800 }))
.toEqual({ left: 688, top: 68 });
});
it("keeps a tall tooltip inside a narrow viewport", () => {
expect(abilityTooltipPosition({ left: 200, right: 240, top: 120, bottom: 160 }, { width: 296, height: 400 }, { width: 320, height: 480 }))
.toEqual({ left: 12, top: 68 });
});
});
+146
View File
@@ -0,0 +1,146 @@
import {
cloneElement, useEffect, useId, useLayoutEffect, useRef, useState,
type ButtonHTMLAttributes, type ReactElement,
} from "react";
import { createPortal } from "react-dom";
import type { AbilityTarget } from "../game/abilityCatalog";
const INSPECT_EVENT = "healer-man:ability-tooltip-inspect";
export interface AbilityTooltipData {
name: string;
description?: string;
resourceCost?: number;
resourceLabel?: string;
cooldownSeconds?: number;
castSeconds?: number;
range?: number;
target?: AbilityTarget;
passive?: boolean;
rank?: number;
lockedUntilLevel?: number;
talentLocked?: boolean;
}
export function abilityTooltipFacts(ability: AbilityTooltipData): string[] {
const facts: string[] = [];
if (ability.passive) facts.push("Passive");
if (ability.rank !== undefined) facts.push(`Rank ${ability.rank}`);
if (!ability.passive) {
if (ability.resourceCost !== undefined) facts.push(ability.resourceCost > 0
? `${Number(ability.resourceCost.toFixed(2))} ${ability.resourceLabel ?? "resource"}` : "No cost");
if (ability.castSeconds !== undefined) facts.push(ability.castSeconds > 0 ? `${ability.castSeconds} sec cast` : "Instant");
if (ability.cooldownSeconds !== undefined) facts.push(ability.cooldownSeconds > 0 ? `${ability.cooldownSeconds} sec cooldown` : "No cooldown");
if (ability.range !== undefined && ability.range > 0) facts.push(`${ability.range} m range`);
if (ability.target) facts.push({ friendly: "Selected ally or self", hostile: "Enemy target", any: "Enemy or ally", self: "Self" }[ability.target]);
}
return facts;
}
export function abilityTooltipPosition(
anchor: { left: number; right: number; top: number; bottom: number },
size: { width: number; height: number },
viewport: { width: number; height: number },
): { left: number; top: number } {
const margin = 12;
const gap = 8;
const clamp = (value: number, maximum: number) => Math.max(margin, Math.min(value, maximum - margin));
const above = anchor.top - size.height - gap;
return {
left: clamp((anchor.left + anchor.right - size.width) / 2, viewport.width - size.width),
top: clamp(above >= margin ? above : anchor.bottom + gap, viewport.height - size.height),
};
}
export function AbilityTooltipContent({ ability, bindingLabel }: { ability: AbilityTooltipData; bindingLabel?: string }) {
return <>
<header><strong>{ability.name}</strong>{bindingLabel && <kbd>{bindingLabel}</kbd>}</header>
<p>{ability.description || "No description available for this ability."}</p>
<footer>{abilityTooltipFacts(ability).map((fact) => <span key={fact}>{fact}</span>)}</footer>
{ability.lockedUntilLevel !== undefined && <p className="ability-tooltip__lock">Unlocks at level {ability.lockedUntilLevel}</p>}
{ability.talentLocked && <p className="ability-tooltip__lock">Learn in talents</p>}
</>;
}
/** Adds inspection without a layout wrapper or changing the control's activation behavior. */
export function AbilityTooltipTarget({ ability, bindingLabel, children }: {
ability: AbilityTooltipData | null | undefined;
bindingLabel?: string;
children: ReactElement<ButtonHTMLAttributes<HTMLButtonElement>>;
}) {
const id = useId();
const [anchor, setAnchor] = useState<HTMLButtonElement | null>(null);
const [position, setPosition] = useState({ left: 12, top: 12 });
const tooltip = useRef<HTMLDivElement>(null);
const hovering = useRef(false);
const keyboardFocused = useRef(false);
const visible = Boolean(anchor && ability);
const inspect = (element: HTMLButtonElement) => {
window.dispatchEvent(new CustomEvent(INSPECT_EVENT, { detail: id }));
setAnchor(element);
};
useLayoutEffect(() => {
if (!anchor || !tooltip.current || !ability) return;
setPosition(abilityTooltipPosition(anchor.getBoundingClientRect(), tooltip.current.getBoundingClientRect(), {
width: window.innerWidth, height: window.innerHeight,
}));
}, [anchor, ability, bindingLabel]);
useEffect(() => {
if (!visible) return;
const dismiss = () => {
hovering.current = false;
keyboardFocused.current = false;
setAnchor(null);
};
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") dismiss(); };
const onInspect = (event: Event) => { if ((event as CustomEvent<string>).detail !== id) dismiss(); };
window.addEventListener(INSPECT_EVENT, onInspect);
window.addEventListener("keydown", onKey);
window.addEventListener("blur", dismiss);
window.addEventListener("resize", dismiss);
window.addEventListener("scroll", dismiss, true);
return () => {
window.removeEventListener(INSPECT_EVENT, onInspect);
window.removeEventListener("keydown", onKey);
window.removeEventListener("blur", dismiss);
window.removeEventListener("resize", dismiss);
window.removeEventListener("scroll", dismiss, true);
};
}, [visible, id]);
const original = children.props;
return <>
{cloneElement(children, {
title: ability ? undefined : original.title,
"aria-describedby": [original["aria-describedby"], visible ? id : undefined].filter(Boolean).join(" ") || undefined,
onPointerEnter: (event) => {
original.onPointerEnter?.(event);
if (event.pointerType === "touch" || !ability) return;
hovering.current = true;
inspect(event.currentTarget);
},
onPointerLeave: (event) => {
original.onPointerLeave?.(event);
hovering.current = false;
if (!keyboardFocused.current) setAnchor(null);
},
onFocus: (event) => {
original.onFocus?.(event);
keyboardFocused.current = event.currentTarget.matches(":focus-visible");
if (keyboardFocused.current && ability) inspect(event.currentTarget);
},
onBlur: (event) => {
original.onBlur?.(event);
keyboardFocused.current = false;
if (!hovering.current) setAnchor(null);
},
})}
{visible && createPortal(
<div ref={tooltip} id={id} role="tooltip" className="ability-tooltip" style={position}>
<AbilityTooltipContent ability={ability!} bindingLabel={bindingLabel} />
</div>, document.body,
)}
</>;
}
+7
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, type CSSProperties } from "react"; import { useEffect, useState, type CSSProperties } from "react";
import type { AbilityTarget } from "../game/abilityCatalog"; import type { AbilityTarget } from "../game/abilityCatalog";
import { AbilityTooltipTarget } from "./AbilityTooltip";
import { useInputBindingLabels } from "../input/useInputBindingLabels"; import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { import {
ACTION_LAYER_LABELS, ACTION_LAYER_LABELS,
@@ -21,6 +22,10 @@ export interface ActionBarItem {
disabled?: boolean; disabled?: boolean;
lockedUntilLevel?: number; lockedUntilLevel?: number;
target?: AbilityTarget; target?: AbilityTarget;
cooldownSeconds?: number;
castSeconds?: number;
range?: number;
talentLocked?: boolean;
} }
export type ActionBarItems = Readonly<Record<ActionBindingControl, ActionBarItem | null>>; export type ActionBarItems = Readonly<Record<ActionBindingControl, ActionBarItem | null>>;
@@ -94,6 +99,7 @@ function ActionSlot({
: ""; : "";
return ( return (
<AbilityTooltipTarget ability={item} bindingLabel={chord}>
<button <button
type="button" type="button"
className={`action-slot action-slot--${control} ${item ? "" : "action-slot--empty"} ${unavailable ? "action-slot--disabled" : ""} ${item?.target === "friendly" || item?.target === "any" ? "action-slot--friendly" : ""} ${cooldown > 0 ? "action-slot--cooldown" : ""}`} className={`action-slot action-slot--${control} ${item ? "" : "action-slot--empty"} ${unavailable ? "action-slot--disabled" : ""} ${item?.target === "friendly" || item?.target === "any" ? "action-slot--friendly" : ""} ${cooldown > 0 ? "action-slot--cooldown" : ""}`}
@@ -124,6 +130,7 @@ function ActionSlot({
<em>{item!.cooldownRemaining! < 10 ? item!.cooldownRemaining!.toFixed(1) : Math.ceil(item!.cooldownRemaining!)}</em> <em>{item!.cooldownRemaining! < 10 ? item!.cooldownRemaining!.toFixed(1) : Math.ceil(item!.cooldownRemaining!)}</em>
)} )}
</button> </button>
</AbilityTooltipTarget>
); );
} }
+6 -3
View File
@@ -8,6 +8,7 @@ import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { ActionBar, ActionIcon, type ActionBarItem, type ActionBarItems } from "./ActionBar"; import { ActionBar, ActionIcon, type ActionBarItem, type ActionBarItems } from "./ActionBar";
import { useInputBindingLabels } from "../input/useInputBindingLabels"; import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useArenaStore } from "../game/arenaStore"; import { useArenaStore } from "../game/arenaStore";
import { AbilityTooltipTarget } from "./AbilityTooltip";
const PC_ACTION_CONTROLS = ["face-bottom", "face-left", "face-top", "face-right", "r1", "r2", "dpad-left", "dpad-right"] as const; const PC_ACTION_CONTROLS = ["face-bottom", "face-left", "face-top", "face-right", "r1", "r2", "dpad-left", "dpad-right"] as const;
@@ -38,6 +39,7 @@ export function ArenaActionBar({ character, actor, layer, inputMode, blocked, on
resourceCost: ability.cost.amount, resourceLabel: kit.resource.name, resourceCost: ability.cost.amount, resourceLabel: kit.resource.name,
cooldownDuration: Math.max(ability.cooldownMs, ability.gcdMs) / 1000, cooldownDuration: Math.max(ability.cooldownMs, ability.gcdMs) / 1000,
cooldownRemaining: remaining, cooldownRemaining: remaining,
cooldownSeconds: ability.cooldownMs / 1000, castSeconds: ability.castTimeMs / 1000, range: ability.range.max,
disabled: blocked || !actor?.alive || !learned || remaining > 0 || actor.effects.some((effect) => effect.id.startsWith("casting:")), disabled: blocked || !actor?.alive || !learned || remaining > 0 || actor.effects.some((effect) => effect.id.startsWith("casting:")),
lockedUntilLevel: learned ? undefined : ability.unlockLevel, lockedUntilLevel: learned ? undefined : ability.unlockLevel,
}]; }];
@@ -65,12 +67,13 @@ export function ArenaActionBar({ character, actor, layer, inputMode, blocked, on
{desktopBars.map((bar, index) => <section key={index} aria-label={`Action bar ${index + 1}`} style={{ gridTemplateColumns: `repeat(${bar.columns}, minmax(0, 1fr))` }}> {desktopBars.map((bar, index) => <section key={index} aria-label={`Action bar ${index + 1}`} style={{ gridTemplateColumns: `repeat(${bar.columns}, minmax(0, 1fr))` }}>
{bar.slots.map((slot, slotIndex) => { {bar.slots.map((slot, slotIndex) => {
const item = slot.abilityId ? items[slot.abilityId] : null; const item = slot.abilityId ? items[slot.abilityId] : null;
return <button type="button" key={slotIndex} disabled={!item || item.disabled} return <AbilityTooltipTarget key={slotIndex} ability={item} bindingLabel={slot.label}>
<button type="button" aria-disabled={!item || item.disabled}
aria-label={`${item?.name ?? "Empty"}, ${slot.label}`} title={item?.description ?? "Empty binding"} aria-label={`${item?.name ?? "Empty"}, ${slot.label}`} title={item?.description ?? "Empty binding"}
onClick={() => item && onCast(item.id)}> onClick={() => item && !item.disabled && onCast(item.id)}>
<kbd>{slot.label}</kbd><ActionIcon item={item ?? null} /><span>{item?.name ?? "Empty"}</span> <kbd>{slot.label}</kbd><ActionIcon item={item ?? null} /><span>{item?.name ?? "Empty"}</span>
{item && (item.cooldownRemaining ?? 0) > 0 && <em>{item.cooldownRemaining!.toFixed(1)}</em>} {item && (item.cooldownRemaining ?? 0) > 0 && <em>{item.cooldownRemaining!.toFixed(1)}</em>}
</button>; </button></AbilityTooltipTarget>;
})} })}
</section>)} </section>)}
</nav> </nav>
+2 -1
View File
@@ -8,12 +8,13 @@ describe("boss card display", () => {
it("shows per-rarity copy totals and every dungeon for the visible boss", () => { it("shows per-rarity copy totals and every dungeon for the visible boss", () => {
const boss = BOSS_CARD_CATALOG[0]; const boss = BOSS_CARD_CATALOG[0];
const markup = renderToStaticMarkup(createElement(BossCardCollection, { const markup = renderToStaticMarkup(createElement(BossCardCollection, {
onClose: () => {}, characterName: "Collector", onClose: () => {},
collection: { counts: { [boss.key]: { normal: 7, rare: 1 } }, lastClaims: {} }, collection: { counts: { [boss.key]: { normal: 7, rare: 1 } }, lastClaims: {} },
})); }));
expect(markup).toContain("7 copies"); expect(markup).toContain("7 copies");
expect(markup).toContain("1 copy"); expect(markup).toContain("1 copy");
expect(markup).toContain("8 total copies"); expect(markup).toContain("8 total copies");
expect(markup).toContain("Account-wide collection log");
expect(markup).toContain("Group by dungeon"); expect(markup).toContain("Group by dungeon");
expect(markup).toContain("Group by rarity"); expect(markup).toContain("Group by rarity");
for (const dungeon of boss.dungeons) expect(markup).toContain(dungeon.title); for (const dungeon of boss.dungeons) expect(markup).toContain(dungeon.title);
+6 -13
View File
@@ -1,9 +1,8 @@
import { lazy, Suspense, useMemo, useState, type CSSProperties } from "react"; import { lazy, Suspense, useMemo, useState, type CSSProperties } from "react";
import { selectedCharacter, useShellStore } from "../app/shellStore"; import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { BOSS_CARD_CATALOG, groupBossCards, type BossCardGrouping } from "../game/bossCardCatalog"; import { BOSS_CARD_CATALOG, groupBossCards, type BossCardGrouping } from "../game/bossCardCatalog";
import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, emptyBossCardCollection, type BossCardCollection as Collection } from "../game/bossCards"; import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, type BossCardCollection as Collection } from "../game/bossCards";
import { useMenuController, type MenuAction } from "../input/useMenuController"; import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton"; import { ControllerButton } from "./ControllerButton";
import { dungeonContentIsUsable } from "../content/contentManager"; import { dungeonContentIsUsable } from "../content/contentManager";
@@ -11,13 +10,11 @@ import { useContentManagerSnapshot } from "../content/useContentManager";
import "./bossCards.css"; import "./bossCards.css";
const BossCardPortrait = lazy(() => import("./BossCardPortrait")); const BossCardPortrait = lazy(() => import("./BossCardPortrait"));
const EMPTY_COLLECTION = emptyBossCardCollection();
const PAGE_SIZE = 8; const PAGE_SIZE = 8;
export function BossCardCollection({ onClose, collection, characterName }: { export function BossCardCollection({ onClose, collection }: {
readonly onClose: () => void; readonly onClose: () => void;
readonly collection: Collection; readonly collection: Collection;
readonly characterName: string;
}) { }) {
useContentManagerSnapshot(); useContentManagerSnapshot();
const [earnedOnly, setEarnedOnly] = useState(false); const [earnedOnly, setEarnedOnly] = useState(false);
@@ -52,7 +49,7 @@ export function BossCardCollection({ onClose, collection, characterName }: {
return <div className="modal-backdrop boss-collection-backdrop" role="presentation" onPointerDown={(event) => event.stopPropagation()}> return <div className="modal-backdrop boss-collection-backdrop" role="presentation" onPointerDown={(event) => event.stopPropagation()}>
<section className="boss-collection" role="dialog" aria-modal="true" aria-labelledby="boss-collection-title"> <section className="boss-collection" role="dialog" aria-modal="true" aria-labelledby="boss-collection-title">
<header className="boss-collection__header"> <header className="boss-collection__header">
<div><p className="eyebrow">{characterName}’s collection log</p><h2 id="boss-collection-title">Boss Cards</h2></div> <div><p className="eyebrow">Account-wide collection log</p><h2 id="boss-collection-title">Boss Cards</h2></div>
<ControllerButton {...controlProps} controlId="cards-close" className="icon-button" onClick={onClose} aria-label="Close boss card collection">×</ControllerButton> <ControllerButton {...controlProps} controlId="cards-close" className="icon-button" onClick={onClose} aria-label="Close boss card collection">×</ControllerButton>
</header> </header>
<div className="boss-collection__summary"> <div className="boss-collection__summary">
@@ -100,11 +97,7 @@ export function BossCardCollection({ onClose, collection, characterName }: {
} }
export default function BossCardCollectionScreen({ onClose }: { readonly onClose?: () => void }) { export default function BossCardCollectionScreen({ onClose }: { readonly onClose?: () => void }) {
const savedCharacter = useShellStore(selectedCharacter); const collection = useShellStore((state) => state.bossCards);
const activeCharacter = useShellStore((state) => state.activeCharacter);
const phase = useShellStore((state) => state.phase);
const liveCollection = useCombatStore((state) => state.bossCards);
const character = activeCharacter ?? savedCharacter;
return <BossCardCollection onClose={onClose ?? (() => useGameStore.getState().closeOverlay())} return <BossCardCollection onClose={onClose ?? (() => useGameStore.getState().closeOverlay())}
collection={phase === "game" ? liveCollection : character?.bossCards ?? EMPTY_COLLECTION} characterName={character?.name ?? "Adventurer"} />; collection={collection} />;
} }
+3 -2
View File
@@ -10,11 +10,12 @@ const GUIDE_CATALOG = buildBossGuideCatalog(DUNGEON_DEFINITIONS, BOSS_GUIDE_OVER
function BossGuideDialog() { function BossGuideDialog() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId); const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const bossTest = useGameStore((state) => state.bossTest);
const openOverlay = useGameStore((state) => state.openOverlay); const openOverlay = useGameStore((state) => state.openOverlay);
const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === activeDungeonId) ?? GUIDE_CATALOG[0]; const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === (bossTest?.sourceDungeonId ?? activeDungeonId)) ?? GUIDE_CATALOG[0];
const [selectedDungeonId, setSelectedDungeonId] = useState(initialDungeon?.id ?? ""); const [selectedDungeonId, setSelectedDungeonId] = useState(initialDungeon?.id ?? "");
const selectedDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon; const selectedDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon;
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses[0]?.id ?? ""); const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses.find((boss) => boss.entityId === bossTest?.entityId)?.id ?? selectedDungeon?.bosses[0]?.id ?? "");
const selectedBoss = selectedDungeon?.bosses.find((boss) => boss.id === selectedBossId) const selectedBoss = selectedDungeon?.bosses.find((boss) => boss.id === selectedBossId)
?? selectedDungeon?.bosses[0]; ?? selectedDungeon?.bosses[0];
const close = () => openOverlay("pause"); const close = () => openOverlay("pause");
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from "react";
import { useShellStore } from "../app/shellStore";
import { BOSS_TESTING_STATUSES, BOSS_TESTING_STATUS_LABELS, useBossTestingReviews, type BossTestingStatus } from "../app/bossTestingReviews";
import { bossTestIsDefeated } from "../game/BossTestingBridge";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import "./bossTesting.css";
export function BossTestingReviewPanel() {
const boss = useGameStore((state) => state.bossTest);
const session = useShellStore((state) => state.session);
const review = useBossTestingReviews((state) => boss ? state.reviews[boss.key] : undefined);
const save = useBossTestingReviews((state) => state.save);
const refresh = useBossTestingReviews((state) => state.refresh);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const busy = useRef(false);
const mark = async (status: BossTestingStatus) => {
if (!boss || busy.current) return;
busy.current = true; setSaving(true); setMessage("");
const synced = await save(boss, status, session);
setMessage(synced ? `Marked ${BOSS_TESTING_STATUS_LABELS[status].toLowerCase()}.` : "Saved on this device. This label will sync when you reconnect or reopen Boss Testing.");
busy.current = false; setSaving(false);
};
useEffect(() => {
void refresh(session);
const reconnect = () => void refresh(session);
window.addEventListener("online", reconnect);
return () => window.removeEventListener("online", reconnect);
}, [refresh, session]);
const defeated = bossTestIsDefeated();
const back = () => { if (!busy.current) useGameStore.getState().openOverlay("pause"); };
const retry = () => useGameStore.getState().resetAtEntrance();
const leave = () => useShellStore.getState().returnToMainMenu();
const actions: MenuAction[] = [
...BOSS_TESTING_STATUSES.map((status) => ({ id: `boss-review-${status}`, run: () => void mark(status), enabled: !saving })),
{ id: "boss-review-report", run: () => useGameStore.getState().openOverlay("report-issue"), enabled: !saving },
{ id: "boss-review-retry", run: retry, enabled: !saving },
{ id: "boss-review-leave", run: leave, enabled: !saving },
{ id: "boss-review-back", run: back, enabled: !saving },
];
const controller = useMenuController(actions, { initialId: "boss-review-needs-testing", onBack: back });
if (!boss) return null;
const button = (controlId: string, label: string, run: () => void, pressed?: boolean) =>
<ControllerButton controlId={controlId} selectedId={controller.selectedId} select={controller.select}
type="button" className="button" onClick={run} disabled={saving} aria-pressed={pressed}>{label}</ControllerButton>;
return <div className="modal-backdrop" role="presentation">
<section className="pause-menu boss-testing-review" role="dialog" aria-modal="true" aria-labelledby="boss-review-title">
<p className="eyebrow">{defeated ? "Boss defeated" : "Boss Testing paused"}</p><h2 id="boss-review-title">{boss.name}</h2>
<p>Current readiness: <strong>{BOSS_TESTING_STATUS_LABELS[review?.status ?? "needs-testing"]}</strong></p>
<div className="boss-testing-actions" aria-label="Boss readiness">{BOSS_TESTING_STATUSES.map((status) =>
<span key={status}>{button(`boss-review-${status}`, `Mark ${BOSS_TESTING_STATUS_LABELS[status].toLowerCase()}`, () => void mark(status), review?.status === status)}</span>
)}</div>
{message && <p role="status">{message}</p>}
<div className="pause-menu__actions">
{button("boss-review-report", "Submit a boss report", () => useGameStore.getState().openOverlay("report-issue"))}
{button("boss-review-retry", "Fight this boss again", retry)}
{button("boss-review-leave", "Return to main menu", leave)}
{button("boss-review-back", "Back to pause menu", back)}
</div>
</section>
</div>;
}
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { enterBossTesting, prepareBossTesting } from "../app/bossTestingRuntime";
import { BOSS_TESTING_STATUS_LABELS, useBossTestingReviews } from "../app/bossTestingReviews";
import { BOSS_TESTING_CATALOG, randomTestingBoss, type TestableBoss } from "../game/bossTesting";
import { defaultPartyRoleForClass, partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyRoles";
import { specializationById } from "../game/specializationCatalog";
import { useContentManagerSnapshot } from "../content/useContentManager";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { FrontSurface, BrandMark, ControllerLegend } from "./FrontSurface";
import { ControllerButton } from "./ControllerButton";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import "./bossTesting.css";
export function BossTestingScreen({ onClose }: { readonly onClose: () => void }) {
const character = useShellStore(selectedCharacter);
const session = useShellStore((state) => state.session);
const reviews = useBossTestingReviews((state) => state.reviews);
const refresh = useBossTestingReviews((state) => state.refresh);
const content = useContentManagerSnapshot();
const [query, setQuery] = useState("");
const [category, setCategory] = useState("all");
const [status, setStatus] = useState("all");
const [selectedKey, setSelectedKey] = useState("");
const [preparing, setPreparing] = useState(false);
const [prepared, setPrepared] = useState(false);
const [error, setError] = useState("");
const [role, setRole] = useState<PartyRole>(character ? defaultPartyRoleForClass(character.classId) : "damage");
const busy = useRef(false);
const alive = useRef(true);
const bosses = useMemo(() => BOSS_TESTING_CATALOG.filter((boss) =>
(category === "all" || boss.category === category)
&& (status === "all" || (reviews[boss.key]?.status ?? "needs-testing") === status)
&& `${boss.name} ${boss.dungeonTitle}`.toLowerCase().includes(query.trim().toLowerCase())
), [category, query, reviews, status]);
const selected = BOSS_TESTING_CATALOG.find((boss) => boss.key === selectedKey);
const specialization = specializationById(character?.specializationId);
const roles = character ? specialization ? [specialization.role] : partyRolesForClass(character.classId) : [];
const effectiveRole = specialization?.role ?? role;
useEffect(() => {
alive.current = true;
void refresh(session);
const reconnect = () => void refresh(session);
window.addEventListener("online", reconnect);
return () => { alive.current = false; window.removeEventListener("online", reconnect); };
}, [refresh, session]);
const choose = async (boss: TestableBoss, start = false) => {
if (busy.current || !character) return;
busy.current = true;
setSelectedKey(boss.key); setPreparing(true); setPrepared(false); setError("");
try {
await prepareBossTesting(boss);
if (!alive.current) return;
setPrepared(true);
if (start) enterBossTesting(boss, effectiveRole);
} catch (failure) {
if (alive.current) setError(failure instanceof Error ? failure.message : "The boss could not be prepared. Please retry.");
} finally {
busy.current = false;
if (alive.current) setPreparing(false);
}
};
const testSelected = () => {
if (!selected || preparing) return;
if (!prepared) { void choose(selected, true); return; }
try { enterBossTesting(selected, effectiveRole); } catch (failure) {
setError(failure instanceof Error ? failure.message : "The test could not be started.");
}
};
const random = () => {
const boss = randomTestingBoss(bosses);
if (boss) void choose(boss, true);
};
const actions: MenuAction[] = [
{ id: "boss-testing-back", run: onClose },
{ id: "boss-testing-category", run: () => setCategory(category === "all" ? "wow" : category === "wow" ? "rom" : "all"), enabled: !preparing },
{ id: "boss-testing-status", run: () => { const values = ["all", "ready", "needs-testing", "not-ready"]; setStatus(values[(values.indexOf(status) + 1) % values.length]); }, enabled: !preparing },
{ id: "boss-testing-picker", run: () => { const next = bosses[(bosses.findIndex((boss) => boss.key === selectedKey) + 1) % bosses.length]; if (next) void choose(next); }, enabled: !preparing && bosses.length > 0 },
...roles.map((value) => ({ id: `boss-testing-role-${value}`, run: () => setRole(value), enabled: !preparing })),
{ id: "boss-testing-start", run: testSelected, enabled: Boolean(selected) && !preparing },
{ id: "boss-testing-random", run: random, enabled: bosses.length > 0 && !preparing },
];
const controller = useMenuController(actions, { initialId: "boss-testing-picker", onBack: onClose });
const selectProps = (controlId: string) => ({ className: controller.selectedId === controlId ? "is-controller-selected" : "",
onPointerEnter: () => controller.select(controlId), disabled: preparing });
const button = (controlId: string, label: string, run: () => void, disabled = false) =>
<ControllerButton controlId={controlId} selectedId={controller.selectedId} select={controller.select}
className="front-button" type="button" onClick={run} disabled={disabled}>{label}</ControllerButton>;
return <DualDisplayFrame contextLabel="Boss Testing" top={
<FrontSurface className="boss-testing-screen" ariaLabel="Boss Testing selection">
<header className="boss-testing-header"><BrandMark compact /><div><span>Main menu / Mechanics laboratory</span><h1>Boss Testing</h1></div>
{button("boss-testing-back", "Back to main menu", onClose)}</header>
<main className="boss-testing-main">
<p>Choose a boss to test with your party in an empty room. Missing content downloads automatically when you select a boss.</p>
<div className="boss-testing-filters">
<label>Search bosses<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Boss or dungeon name" disabled={preparing} /></label>
<label>Game<select {...selectProps("boss-testing-category")} value={category} onChange={(event) => setCategory(event.target.value)}>
<option value="all">All games</option><option value="wow">WoW</option><option value="rom">RoM</option></select></label>
<label>Readiness<select {...selectProps("boss-testing-status")} value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="all">All statuses</option>{Object.entries(BOSS_TESTING_STATUS_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
</div>
<label className="boss-testing-picker">Choose a boss <small>{bosses.length} bosses</small>
<select {...selectProps("boss-testing-picker")} value={bosses.some((boss) => boss.key === selectedKey) ? selectedKey : ""}
onChange={(event) => { const boss = bosses.find((entry) => entry.key === event.target.value); if (boss) void choose(boss); }}>
<option value="" disabled>Select a boss…</option>
{bosses.map((boss) => <option key={boss.key} value={boss.key}>{boss.name} · {boss.dungeonTitle} · {BOSS_TESTING_STATUS_LABELS[reviews[boss.key]?.status ?? "needs-testing"]}</option>)}
</select>
</label>
{selected && <article className="boss-testing-selected"><p className="front-kicker">Selected encounter</p><h2>{selected.name}</h2><p>{selected.dungeonTitle}</p>
<strong className={`boss-testing-status boss-testing-status--${reviews[selected.key]?.status ?? "needs-testing"}`}>{BOSS_TESTING_STATUS_LABELS[reviews[selected.key]?.status ?? "needs-testing"]}</strong>
{reviews[selected.key]?.pending && <small>Readiness saved on this device; waiting to sync.</small>}
</article>}
{roles.length > 1 && <div className="boss-testing-actions" aria-label="Your party role">{roles.map((value) =>
<ControllerButton key={value} controlId={`boss-testing-role-${value}`} selectedId={controller.selectedId} select={controller.select}
className="front-button" type="button" aria-pressed={effectiveRole === value} onClick={() => setRole(value)} disabled={preparing}>{PARTY_ROLE_LABELS[value]}</ControllerButton>
)}</div>}
{preparing && <div role="status" className="boss-testing-progress"><p>Preparing {selected?.name}… {content.installingPackId ? "Downloading content" : "Checking assets"}</p>
{content.installingPackId && <><progress max={Math.max(1, content.totalBytes)} value={content.downloadedBytes} /><small>{Math.round(content.downloadedBytes / 1024 / 1024)} / {Math.round(content.totalBytes / 1024 / 1024)} MB</small></>}
</div>}
{error && <p role="alert" className="dungeon-report-error">{error}</p>}
{!bosses.length && <p role="status">No bosses match these filters.</p>}
<div className="boss-testing-actions">{button("boss-testing-start", error ? "Retry and test selected boss" : "Test selected boss", testSelected, !selected || preparing)}
{button("boss-testing-random", "Fight a random boss", random, !bosses.length || preparing)}</div>
<p className="boss-testing-help">Mark readiness and submit a boss report from pause or after victory. Test fights do not award loot, XP, or boss cards.</p>
</main><ControllerLegend back />
</FrontSurface>
} bottom={<LazyLocalCompanionPanel />} />;
}
+19 -3
View File
@@ -34,6 +34,8 @@ import { EditableHudElement } from "./EditableHudElement";
import { DpsMeterWidget } from "./DpsMeterWidget"; import { DpsMeterWidget } from "./DpsMeterWidget";
import { useUiLayoutStore } from "../game/uiLayoutStore"; import { useUiLayoutStore } from "../game/uiLayoutStore";
import { useDpsMeterStore } from "../game/dpsMeterStore"; import { useDpsMeterStore } from "../game/dpsMeterStore";
import { currentDisplayLayout } from "../platform/displayLayout";
import { DungeonCompanionHud } from "./DungeonCompanionHud";
function safePercent(current: number, maximum: number): number { function safePercent(current: number, maximum: number): number {
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0; if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
@@ -166,12 +168,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
<p className="companion-kicker">Ready on both screens</p> <p className="companion-kicker">Ready on both screens</p>
<h2>One roster. Two ways to play.</h2> <h2>One roster. Two ways to play.</h2>
<div className="companion-feature-grid"> <div className="companion-feature-grid">
<article><b>01</b><span><strong>Local account</strong><small>Salted password verifier on this device</small></span></article> <article><b>01</b><span><strong>Your roster</strong><small>Choose your adventurer after signing in</small></span></article>
<article><b>02</b><span><strong>Offline play</strong><small>No sign-in or network required</small></span></article> <article><b>02</b><span><strong>Offline play</strong><small>No sign-in or network required</small></span></article>
<article><b>03</b><span><strong>PC fallback</strong><small>This panel opens as an overlay</small></span></article> <article><b>03</b><span><strong>PC fallback</strong><small>This panel opens as an overlay</small></span></article>
</div> </div>
</main> </main>
<footer><span>Top screen owns account entry</span><b>THOR / 620 x 540</b></footer> <footer><span>Sign in on the top screen</span><b>THOR / 3.92 INCHES</b></footer>
</FrontSurface> </FrontSurface>
); );
} }
@@ -297,6 +299,19 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
return <CompanionDungeonSelection selected={selected} send={send} />; return <CompanionDungeonSelection selected={selected} send={send} />;
} }
if (snapshot.phase === "class-hall") {
const definition = selected ? classById(selected.classId) : null;
return <FrontSurface bottom className="companion-panel companion-create" ariaLabel="Class Hall companion screen">
<header><BrandMark compact /><b>CLASS HALL</b></header>
<main><section><p className="companion-kicker">{selected?.name ?? "Your adventurer"}</p>
<h2>{definition?.name ?? "Choose your path"}</h2>
<p>Choose your specialization on the top screen.</p>
<p>Your specialization determines your role and starting equipment.</p>
</section></main>
<footer className="companion-actions"><button type="button" onClick={() => send({ name: "returnToMainMenu" })}>Back to main menu</button></footer>
</FrontSurface>;
}
if (snapshot.phase === "manastorms") { if (snapshot.phase === "manastorms") {
return <ShelvedCompanionPanel selected={selected} send={send} />; return <ShelvedCompanionPanel selected={selected} send={send} />;
} }
@@ -510,11 +525,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
} }
export function LocalCompanionPanel() { export function LocalCompanionPanel() {
useShellStore(); const shell = useShellStore();
useGameStore(); useGameStore();
useCombatStore(); useCombatStore();
usePartyStore(); usePartyStore();
useUiLayoutStore(); useUiLayoutStore();
useDpsMeterStore(); useDpsMeterStore();
if (shell.phase === "game" && currentDisplayLayout() !== "single") return <DungeonCompanionHud send={executeCompanionCommand} />;
return <CompanionPanel snapshot={currentCompanionSnapshot()} send={executeCompanionCommand} />; return <CompanionPanel snapshot={currentCompanionSnapshot()} send={executeCompanionCommand} />;
} }
+4 -2
View File
@@ -5,6 +5,7 @@ import { actionKeyLabel, type HudElementId } from "../game/uiLayout";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore"; import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { EditableHudElement } from "./EditableHudElement"; import { EditableHudElement } from "./EditableHudElement";
import { ActionIcon, type ActionBarItem } from "./ActionBar"; import { ActionIcon, type ActionBarItem } from "./ActionBar";
import { AbilityTooltipTarget } from "./AbilityTooltip";
export function DesktopActionBars({ items }: { items: Readonly<Record<string, ActionBarItem>> }) { export function DesktopActionBars({ items }: { items: Readonly<Record<string, ActionBarItem>> }) {
const ui = useUiLayoutStore(); const ui = useUiLayoutStore();
@@ -19,7 +20,8 @@ export function DesktopActionBars({ items }: { items: Readonly<Record<string, Ac
{bar.slots.map((button, s) => { {bar.slots.map((button, s) => {
const item = button.abilityId ? items[button.abilityId] : null; const item = button.abilityId ? items[button.abilityId] : null;
const selected = editing && ui.selectedButton?.bar === b && ui.selectedButton.slot === s; const selected = editing && ui.selectedButton?.bar === b && ui.selectedButton.slot === s;
return <button type="button" key={s} className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`} return <AbilityTooltipTarget key={s} ability={item} bindingLabel={actionKeyLabel(button.key)}>
<button type="button" className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`}
aria-label={`Bar ${b + 1} button ${s + 1}: ${item?.name ?? "Empty"}${button.key ? `, ${actionKeyLabel(button.key)}` : ""}`} aria-label={`Bar ${b + 1} button ${s + 1}: ${item?.name ?? "Empty"}${button.key ? `, ${actionKeyLabel(button.key)}` : ""}`}
aria-disabled={!editing && (!item || item.disabled || blocked)} aria-pressed={editing ? selected : undefined} aria-disabled={!editing && (!item || item.disabled || blocked)} aria-pressed={editing ? selected : undefined}
title={item ? `${item.name}\n${item.description ?? ""}\n${actionKeyLabel(button.key)}${item.lockedUntilLevel ? ` · Level ${item.lockedUntilLevel}` : ""}` : `Empty · ${actionKeyLabel(button.key)}`} title={item ? `${item.name}\n${item.description ?? ""}\n${actionKeyLabel(button.key)}${item.lockedUntilLevel ? ` · Level ${item.lockedUntilLevel}` : ""}` : `Empty · ${actionKeyLabel(button.key)}`}
@@ -30,7 +32,7 @@ export function DesktopActionBars({ items }: { items: Readonly<Record<string, Ac
<ActionIcon item={item ?? null} /><kbd>{actionKeyLabel(button.key)}</kbd> <ActionIcon item={item ?? null} /><kbd>{actionKeyLabel(button.key)}</kbd>
{item && item.cooldownRemaining! > .05 && <span className="desktop-action-button__cooldown">{item.cooldownRemaining! < 10 ? item.cooldownRemaining!.toFixed(1) : Math.ceil(item.cooldownRemaining!)}</span>} {item && item.cooldownRemaining! > .05 && <span className="desktop-action-button__cooldown">{item.cooldownRemaining! < 10 ? item.cooldownRemaining!.toFixed(1) : Math.ceil(item.cooldownRemaining!)}</span>}
{editing && !item && <small>{s + 1}</small>} {editing && !item && <small>{s + 1}</small>}
</button>; </button></AbilityTooltipTarget>;
})} })}
</section> </section>
</EditableHudElement>; })}</>; </EditableHudElement>; })}</>;
+3 -1
View File
@@ -4,11 +4,13 @@ import { resolveDpsMeterDisplay } from "../game/uiLayout";
import { currentDpsMeterSnapshot, useDpsMeterStore } from "../game/dpsMeterStore"; import { currentDpsMeterSnapshot, useDpsMeterStore } from "../game/dpsMeterStore";
import { EditableHudElement } from "./EditableHudElement"; import { EditableHudElement } from "./EditableHudElement";
import { DpsMeterWidget } from "./DpsMeterWidget"; import { DpsMeterWidget } from "./DpsMeterWidget";
import { useHudSurface } from "./HudSurface";
export function DpsMeter() { export function DpsMeter() {
const { surface } = useHudSurface();
useDpsMeterStore(); useDpsMeterStore();
const ui = useUiLayoutStore(); const ui = useUiLayoutStore();
const layout = ui.draft ?? ui.preferences.current[currentUiDevice()]; const layout = ui.draft ?? ui.preferences.current[currentUiDevice()];
if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== "main") return null; if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== (surface === "top" ? "main" : "companion")) return null;
return <EditableHudElement id="dps"><DpsMeterWidget snapshot={currentDpsMeterSnapshot()} width={layout.elements.dps.width} /></EditableHudElement>; return <EditableHudElement id="dps"><DpsMeterWidget snapshot={currentDpsMeterSnapshot()} width={layout.elements.dps.width} /></EditableHudElement>;
} }
+27
View File
@@ -0,0 +1,27 @@
import { Hud } from "./Hud";
import { useGameStore } from "../game/store";
import type { CompanionCommand } from "../platform/dualScreenSync";
export function DungeonCompanionHud({ send, remote = false, completion, interaction }: {
send: (command: CompanionCommand) => void;
remote?: boolean;
completion?: boolean;
interaction?: { prompt: string; name: string; label: string } | null;
}) {
const bossTest = useGameStore((state) => state.bossTest);
return <main className="game-shell companion-hud-shell" aria-label="Dungeon bottom screen">
<Hud surface="bottom" completion={completion} interaction={interaction}
onFinish={remote ? () => send({ name: "finishDungeon" }) : undefined}
onInteract={remote ? () => send({ name: "interactDungeonObject" }) : undefined}
onResurrect={remote ? (checkpointId) => send({ name: "resurrectParty", checkpointId }) : undefined}
onLayoutChange={remote ? (id, update) => send({ name: "editHudElement", id, update }) : undefined} />
<nav className="companion-hud-menus" aria-label="Dungeon menus">
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "map" })}>Map</button>
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "spellbook" })}>Spells</button>
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "inventory" })}>Gear</button>
<button type="button" onClick={() => send({ name: "openOverlay", overlay: "ui-layout" })}>Edit UI</button>
<button type="button" onClick={() => send({ name: "togglePause" })}>Pause</button>
{bossTest && <button type="button" onClick={() => send({ name: "openOverlay", overlay: "boss-test-review" })}>Boss review</button>}
</nav>
</main>;
}
+10 -6
View File
@@ -10,7 +10,10 @@ import {
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { useInputBindingLabels } from "../input/useInputBindingLabels"; import { useInputBindingLabels } from "../input/useInputBindingLabels";
export function DungeonInteractionPrompt() { export function DungeonInteractionPrompt({ interaction, onInteract }: {
interaction?: { prompt: string; name: string; label: string } | null;
onInteract?: () => void;
} = {}) {
const label = useInputBindingLabels(); const label = useInputBindingLabels();
const playerPosition = useGameStore((state) => state.playerPosition); const playerPosition = useGameStore((state) => state.playerPosition);
const inputMode = useGameStore((state) => state.inputMode); const inputMode = useGameStore((state) => state.inputMode);
@@ -21,19 +24,20 @@ export function DungeonInteractionPrompt() {
activeDungeonEncounterRevision, activeDungeonEncounterRevision,
); );
void playerPosition; void playerPosition;
const candidate = overlay ? null : activeDungeonInteractionCandidate(); const local = interaction === undefined && !overlay ? activeDungeonInteractionCandidate() : null;
const candidate = interaction ?? (local ? { prompt: local.object.interaction!.prompt, name: local.object.name, label: local.state.label } : null);
if (!candidate) return null; if (!candidate) return null;
return ( return (
<button <button
type="button" type="button"
className="dungeon-interaction-prompt" className="dungeon-interaction-prompt"
onClick={() => interactWithActiveDungeonObject()} onClick={onInteract ?? (() => interactWithActiveDungeonObject())}
aria-label={`${candidate.object.interaction!.prompt}: ${candidate.object.name}`} aria-label={`${candidate.prompt}: ${candidate.name}`}
> >
<span className="dungeon-interaction-prompt__key">{label(inputMode === "gamepad" ? "controller" : "pc", "loot")}</span> <span className="dungeon-interaction-prompt__key">{label(inputMode === "gamepad" ? "controller" : "pc", "loot")}</span>
<span> <span>
<strong>{candidate.object.interaction!.prompt}</strong> <strong>{candidate.prompt}</strong>
<small>{candidate.object.name} · {candidate.state.label}</small> <small>{candidate.name} · {candidate.label}</small>
</span> </span>
</button> </button>
); );
+9 -7
View File
@@ -8,6 +8,7 @@ import { ControllerButton } from "./ControllerButton";
export function DungeonIssueReportPanel() { export function DungeonIssueReportPanel() {
const dungeonId = useGameStore((state) => state.activeDungeonId); const dungeonId = useGameStore((state) => state.activeDungeonId);
const bossTest = useGameStore((state) => state.bossTest);
const openOverlay = useGameStore((state) => state.openOverlay); const openOverlay = useGameStore((state) => state.openOverlay);
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
@@ -16,12 +17,12 @@ export function DungeonIssueReportPanel() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const busy = useRef(false); const busy = useRef(false);
const form = useRef<HTMLFormElement>(null); const form = useRef<HTMLFormElement>(null);
const back = () => { if (!busy.current) openOverlay("pause"); }; const back = () => { if (!busy.current) openOverlay(bossTest ? "boss-test-review" : "pause"); };
const canSubmit = Boolean(comment.trim()) && comment.length <= DUNGEON_REPORT_COMMENT_MAX_LENGTH && !submitting; const canSubmit = Boolean(comment.trim()) && comment.length <= DUNGEON_REPORT_COMMENT_MAX_LENGTH && !submitting;
const actions = useMemo<MenuAction[]>(() => [ const actions = useMemo<MenuAction[]>(() => [
{ id: "report-back", run: back, enabled: !submitting }, { id: "report-back", run: back, enabled: !submitting },
{ id: "report-submit", run: () => form.current?.requestSubmit(), enabled: canSubmit && !submitted }, { id: "report-submit", run: () => form.current?.requestSubmit(), enabled: canSubmit && !submitted },
], [canSubmit, openOverlay, submitted, submitting]); ], [bossTest, canSubmit, openOverlay, submitted, submitting]);
const controller = useMenuController(actions, { initialId: "report-back", onBack: back }); const controller = useMenuController(actions, { initialId: "report-back", onBack: back });
const submit = async (event: FormEvent) => { const submit = async (event: FormEvent) => {
@@ -31,7 +32,8 @@ export function DungeonIssueReportPanel() {
setSubmitting(true); setSubmitting(true);
setError(""); setError("");
try { try {
await submitDungeonReport(session, dungeonId, comment); await submitDungeonReport(session, bossTest?.sourceDungeonId ?? dungeonId, comment,
bossTest ? { bossKey: bossTest.key, bossName: bossTest.name } : undefined);
setSubmitted(true); setSubmitted(true);
} catch (failure) { } catch (failure) {
setError(failure instanceof Error ? failure.message : "Your report could not be submitted. Please try again."); setError(failure instanceof Error ? failure.message : "Your report could not be submitted. Please try again.");
@@ -44,9 +46,9 @@ export function DungeonIssueReportPanel() {
return ( return (
<div className="modal-backdrop" role="presentation"> <div className="modal-backdrop" role="presentation">
<section className="pause-menu dungeon-issue-report" role="dialog" aria-modal="true" aria-labelledby="issue-report-title"> <section className="pause-menu dungeon-issue-report" role="dialog" aria-modal="true" aria-labelledby="issue-report-title">
<p className="eyebrow">Dungeon feedback</p> <p className="eyebrow">{bossTest ? "Boss feedback" : "Dungeon feedback"}</p>
<h2 id="issue-report-title">Report an issue</h2> <h2 id="issue-report-title">{bossTest ? "Submit a boss report" : "Report an issue"}</h2>
<p>{dungeonById(dungeonId)?.title ?? dungeonId}</p> <p>{bossTest ? `${bossTest.name} · ${dungeonById(bossTest.sourceDungeonId)?.title ?? bossTest.sourceDungeonId}` : dungeonById(dungeonId)?.title ?? dungeonId}</p>
{submitted ? <p role="status">Report submitted. You can follow its status in View Reports on the main menu.</p> : ( {submitted ? <p role="status">Report submitted. You can follow its status in View Reports on the main menu.</p> : (
<form ref={form} onSubmit={(event) => void submit(event)}> <form ref={form} onSubmit={(event) => void submit(event)}>
<label htmlFor="dungeon-issue-comment">Describe the issue</label> <label htmlFor="dungeon-issue-comment">Describe the issue</label>
@@ -62,7 +64,7 @@ export function DungeonIssueReportPanel() {
</form> </form>
)} )}
<ControllerButton controlId="report-back" selectedId={controller.selectedId} select={controller.select} <ControllerButton controlId="report-back" selectedId={controller.selectedId} select={controller.select}
className="button" type="button" onClick={back} disabled={submitting}>Back to pause menu</ControllerButton> className="button" type="button" onClick={back} disabled={submitting}>{bossTest ? "Back to boss review" : "Back to pause menu"}</ControllerButton>
</section> </section>
</div> </div>
); );
+1
View File
@@ -23,6 +23,7 @@ function ReportCard({ report, canManage, saving, onUpdate, selectedId, select }:
return ( return (
<article className={`dungeon-report-card ${report.status === "Closed" ? "is-closed" : ""}`}> <article className={`dungeon-report-card ${report.status === "Closed" ? "is-closed" : ""}`}>
<header><h2>{dungeonById(report.dungeonId)?.title ?? report.dungeonId}</h2><span className="dungeon-report-status">{report.status}</span></header> <header><h2>{dungeonById(report.dungeonId)?.title ?? report.dungeonId}</h2><span className="dungeon-report-status">{report.status}</span></header>
{report.bossName && <strong>Boss: {report.bossName}</strong>}
<p className="dungeon-report-comment">{report.comment}</p> <p className="dungeon-report-comment">{report.comment}</p>
<small>Submitted by {report.submittedBy} · <time dateTime={new Date(report.createdAt).toISOString()}>{new Date(report.createdAt).toLocaleString()}</time></small> <small>Submitted by {report.submittedBy} · <time dateTime={new Date(report.createdAt).toISOString()}>{new Date(report.createdAt).toLocaleString()}</time></small>
{report.updatedBy && <small>Updated by {report.updatedBy} · {new Date(report.updatedAt).toLocaleString()}</small>} {report.updatedBy && <small>Updated by {report.updatedBy} · {new Date(report.updatedAt).toLocaleString()}</small>}
+12 -5
View File
@@ -1,6 +1,8 @@
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { HUD_ELEMENTS, type HudElementId, type HudElementLayout } from "../game/uiLayout"; import { HUD_ELEMENTS, resolveHudScreen, type HudElementId, type HudElementLayout } from "../game/uiLayout";
import { currentDisplayLayout } from "../platform/displayLayout";
import { useHudSurface } from "./HudSurface";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore"; import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import "./uiLayout.css"; import "./uiLayout.css";
@@ -22,23 +24,26 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
onChange?: (update: Partial<HudElementLayout>) => void; onChange?: (update: Partial<HudElementLayout>) => void;
}) { }) {
const overlay = useGameStore((state) => state.overlay); const overlay = useGameStore((state) => state.overlay);
const surface = useHudSurface();
const dualScreen = currentDisplayLayout() !== "single";
const editing = editingOverride ?? overlay === "ui-layout"; const editing = editingOverride ?? overlay === "ui-layout";
const state = useUiLayoutStore(); const state = useUiLayoutStore();
const layout = state.draft ?? state.preferences.current[currentUiDevice()]; const layout = state.draft ?? state.preferences.current[currentUiDevice()];
const element = elementLayout ?? layout.elements[id]; const element = elementLayout ?? layout.elements[id];
const screen = resolveHudScreen(id, layout, dualScreen);
const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null); const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [host, setHost] = useState<Element | null>(null); const [host, setHost] = useState<Element | null>(null);
const drag = useRef<{ pointerId: number; dx: number; dy: number } | null>(null); const drag = useRef<{ pointerId: number; dx: number; dy: number } | null>(null);
const [bounds, setBounds] = useState({ width: 0, height: 0, panelWidth: 0, panelHeight: 0 }); const [bounds, setBounds] = useState({ width: 0, height: 0, panelWidth: 0, panelHeight: 0 });
const positioned = editing || element.position !== null; const positioned = editing || element.position !== null || (dualScreen && surface.surface === "bottom" && !["player", "actions", "cast", "experience", "dps"].includes(id));
const fallback = FALLBACKS[id] ?? [.5, .35, 250]; const fallback = FALLBACKS[id] ?? [.5, .35, 250];
const position = element.position ?? (bottomAnchor !== undefined const position = element.position ?? (bottomAnchor !== undefined
? { x: .5, y: 1 - bottomAnchor / Math.max(1, bounds.height) } ? { x: .5, y: 1 - bottomAnchor / Math.max(1, bounds.height) }
: origin ?? { x: fallback[0], y: fallback[1] }); : origin ?? { x: fallback[0], y: fallback[1] });
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2]; const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2];
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector]); useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector, screen]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (positioned || !ref.current) return; if (positioned || !ref.current) return;
@@ -66,18 +71,19 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
const observer = new ResizeObserver(measure); const observer = new ResizeObserver(measure);
observer.observe(node); observer.observe(host); measure(); observer.observe(node); observer.observe(host); measure();
return () => observer.disconnect(); return () => observer.disconnect();
}, [positioned, host, hostSelector]); }, [positioned, host, hostSelector, screen]);
const updatePosition = (x: number, y: number) => { const updatePosition = (x: number, y: number) => {
const update = { position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width }; const update = { position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width };
if (onChange) onChange(update); if (onChange) onChange(update);
else if (surface.onChange) surface.onChange(id, update);
else state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, [id]: { ...draft.elements[id], ...update } } })); else state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, [id]: { ...draft.elements[id], ...update } } }));
}; };
const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2)); const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2));
const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2)); const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2));
const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined; const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined;
const content = <div ref={ref} data-hud-element={id} style={style} const content = <div ref={ref} data-hud-element={id} style={style}
className={`editable-hud ${positioned ? "editable-hud--positioned" : "editable-hud--default"} ${editing ? "editable-hud--editing" : ""} ${!element.visible ? editing ? "editable-hud--ghost" : "editable-hud--hidden" : ""}`} className={`editable-hud ${positioned ? "editable-hud--positioned" : "editable-hud--default"} ${editing ? "editable-hud--editing" : ""} ${state.selectedElement === id ? "editable-hud--selected" : ""} ${!element.visible ? editing ? "editable-hud--ghost" : "editable-hud--hidden" : ""}`}
onClickCapture={(event) => { if (editing && !id.startsWith("bar-")) { event.preventDefault(); event.stopPropagation(); useUiLayoutStore.setState({ selectedElement: id }); } }}> onClickCapture={(event) => { if (editing && !id.startsWith("bar-")) { event.preventDefault(); event.stopPropagation(); useUiLayoutStore.setState({ selectedElement: id }); } }}>
{children} {children}
{editing && <button type="button" className="editable-hud__handle" aria-label={`Move ${HUD_ELEMENTS[id]}`} title="Drag to move; arrow keys nudge" {editing && <button type="button" className="editable-hud__handle" aria-label={`Move ${HUD_ELEMENTS[id]}`} title="Drag to move; arrow keys nudge"
@@ -102,5 +108,6 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
updatePosition(position.x + delta[0] * .01, position.y + delta[1] * .01); updatePosition(position.x + delta[0] * .01, position.y + delta[1] * .01);
}}>{HUD_ELEMENTS[id]}{!element.visible ? " · Hidden" : ""}</button>} }}>{HUD_ELEMENTS[id]}{!element.visible ? " · Hidden" : ""}</button>}
</div>; </div>;
if (dualScreen && screen !== surface.surface) return null;
return positioned && host ? createPortal(content, host) : content; return positioned && host ? createPortal(content, host) : content;
} }
+27 -9
View File
@@ -1,4 +1,7 @@
import { useInputBindingLabels } from "../input/useInputBindingLabels"; import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { HudSurfaceContext } from "./HudSurface";
import type { HudElementId, HudElementLayout } from "../game/uiLayout";
import { DungeonInteractionPrompt } from "./DungeonInteractionPrompt";
import { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -168,7 +171,15 @@ export function DungeonCompletePrompt({
); );
} }
export function Hud() { export function Hud({ surface = "top", onLayoutChange, completion, onFinish, onResurrect, interaction, onInteract }: {
surface?: "top" | "bottom";
onLayoutChange?: (id: HudElementId, update: Partial<HudElementLayout>) => void;
completion?: boolean;
onFinish?: () => void;
onResurrect?: (checkpointId: string) => void;
interaction?: { prompt: string; name: string; label: string } | null;
onInteract?: () => void;
} = {}) {
const bindingLabel = useInputBindingLabels(); const bindingLabel = useInputBindingLabels();
const ui = useUiLayoutStore(); const ui = useUiLayoutStore();
const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars; const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars;
@@ -230,7 +241,7 @@ export function Hud() {
); );
const runProgress = useDungeonRunStore((state) => state.progress); const runProgress = useDungeonRunStore((state) => state.progress);
const finalBossDefeated = runProgress?.dungeonId === activeDungeonId && runProgress.finishedAt !== null; const finalBossDefeated = runProgress?.dungeonId === activeDungeonId && runProgress.finishedAt !== null;
const dungeonComplete = gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId)); const dungeonComplete = completion ?? (gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId)));
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => setClock(Date.now()), 100); const timer = window.setInterval(() => setClock(Date.now()), 100);
@@ -282,6 +293,10 @@ export function Hud() {
disabled: talentLocked || level < ability.unlockLevel || health <= 0 || remaining > 0 || Boolean(activeCast) || cannotPay, disabled: talentLocked || level < ability.unlockLevel || health <= 0 || remaining > 0 || Boolean(activeCast) || cannotPay,
lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined, lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined,
target: ability.target, target: ability.target,
cooldownSeconds: ability.cooldownMs / 1_000,
castSeconds: ability.castTimeMs / 1_000,
range: ability.range.max,
talentLocked,
}; };
return [catalogAbility.id, item]; return [catalogAbility.id, item];
})), [assignedAbilities, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]); })), [assignedAbilities, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
@@ -391,12 +406,14 @@ export function Hud() {
}; };
return ( return (
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}> <HudSurfaceContext.Provider value={{ surface, onChange: onLayoutChange, onResurrect }}>
<div className={`hud hud-surface-${surface}`} style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
<ShelvedHud /> <ShelvedHud />
<DpsMeter /> <DpsMeter />
<BossLootWindow /> {surface === "top" && <BossLootWindow />}
<EditableHudElement id="interaction"><DungeonInteractionPrompt interaction={interaction} onInteract={onInteract} /></EditableHudElement>
<EditableHudElement id="completion">{dungeonComplete ? ( <EditableHudElement id="completion">{dungeonComplete ? (
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} <DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={onFinish ?? finishDungeon}
finalBossOnly={!activeDungeonRunIsComplete(activeDungeonId)} durationMs={runProgress?.timed ? runProgress.durationMs : null} finalBossOnly={!activeDungeonRunIsComplete(activeDungeonId)} durationMs={runProgress?.timed ? runProgress.durationMs : null}
onResults={finalBossDefeated ? () => useGameStore.getState().openOverlay("dungeon-results") : undefined} /> onResults={finalBossDefeated ? () => useGameStore.getState().openOverlay("dungeon-results") : undefined} />
) : null}</EditableHudElement> ) : null}</EditableHudElement>
@@ -409,7 +426,7 @@ export function Hud() {
<EditableHudElement id="party"><PartyFrames /></EditableHudElement> <EditableHudElement id="party"><PartyFrames /></EditableHudElement>
<EditableHudElement id="summons"><SummonFrames now={clock} /></EditableHudElement> <EditableHudElement id="summons"><SummonFrames now={clock} /></EditableHudElement>
{pendingGroundTarget ? ( {surface === "top" && pendingGroundTarget ? (
<section className="ground-target-reticle" role="status" aria-live="polite"> <section className="ground-target-reticle" role="status" aria-live="polite">
<div aria-hidden="true"><i /><b /></div> <div aria-hidden="true"><i /><b /></div>
<strong>Place ground effect</strong> <strong>Place ground effect</strong>
@@ -507,7 +524,7 @@ export function Hud() {
items={actionItems} items={actionItems}
activeLayer={actionLayer} activeLayer={actionLayer}
onActivate={castBinding} onActivate={castBinding}
inputMode={inputMode} inputMode={surface === "bottom" ? "gamepad" : inputMode}
feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null} feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null}
/></EditableHudElement>} /></EditableHudElement>}
</div> </div>
@@ -527,8 +544,8 @@ export function Hud() {
].map((asset) => asset.fileName).join(" and ")}.`}</span> ].map((asset) => asset.fileName).join(" and ")}.`}</span>
</div> </div>
)}</EditableHudElement> )}</EditableHudElement>
<LoadingOverlay /> {surface === "top" && <LoadingOverlay />}
{health <= 0 ? ( {surface === "top" && health <= 0 ? (
<DefeatedPrompt <DefeatedPrompt
gameMode={gameMode} gameMode={gameMode}
resurrectionCharges={resurrectionCharges} resurrectionCharges={resurrectionCharges}
@@ -539,5 +556,6 @@ export function Hud() {
/> />
) : null} ) : null}
</div> </div>
</HudSurfaceContext.Provider>
); );
} }
+9
View File
@@ -0,0 +1,9 @@
import { createContext, useContext } from "react";
import type { HudElementId, HudElementLayout } from "../game/uiLayout";
export const HudSurfaceContext = createContext<{
surface: "top" | "bottom";
onChange?: (id: HudElementId, update: Partial<HudElementLayout>) => void;
onResurrect?: (checkpointId: string) => void;
}>({ surface: "top" });
export const useHudSurface = () => useContext(HudSurfaceContext);
+5 -5
View File
@@ -26,7 +26,7 @@ const PC_KEYS = [
"Insert", "Home", "End", "PageUp", "PageDown", ...Array.from({ length: 10 }, (_, i) => `Numpad${i}`), "Insert", "Home", "End", "PageUp", "PageDown", ...Array.from({ length: 10 }, (_, i) => `Numpad${i}`),
"NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide", "NumpadDecimal", "NumpadEnter", "NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide", "NumpadDecimal", "NumpadEnter",
]; ];
function KeybindsDialog() { function KeybindsDialog({ onClose }: { onClose?: () => void }) {
const [device, setDevice] = useState<InputDevice>(() => defaultInputDevice(Capacitor.isNativePlatform(), currentUiDevice() === "mobile")); const [device, setDevice] = useState<InputDevice>(() => defaultInputDevice(Capacitor.isNativePlatform(), currentUiDevice() === "mobile"));
const [draft, setDraft] = useState(() => useUiLayoutStore.getState().preferences); const [draft, setDraft] = useState(() => useUiLayoutStore.getState().preferences);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -57,7 +57,7 @@ function KeybindsDialog() {
const shown = INPUT_ACTIONS.filter((entry) => (group === "All actions" || entry.group === group) const shown = INPUT_ACTIONS.filter((entry) => (group === "All actions" || entry.group === group)
&& (!conflictsOnly || conflicts.some((conflict) => conflict.id === entry.id)) && (!conflictsOnly || conflicts.some((conflict) => conflict.id === entry.id))
&& `${entry.label} ${detail(entry.id)} ${inputBindingLabel(device, bindings[entry.id])}`.toLowerCase().includes(search.toLowerCase())); && `${entry.label} ${detail(entry.id)} ${inputBindingLabel(device, bindings[entry.id])}`.toLowerCase().includes(search.toLowerCase()));
const close = () => useGameStore.getState().openOverlay("options"); const close = onClose ?? (() => useGameStore.getState().openOverlay("options"));
const endCapture = () => { setListening(false); setCapturingInput(false); }; const endCapture = () => { setListening(false); setCapturingInput(false); };
const cancelEdit = () => { endCapture(); setEditing(null); setPending(null); setResetPending(false); }; const cancelEdit = () => { endCapture(); setEditing(null); setPending(null); setResetPending(false); };
const apply = (id: string, key: string | null) => { const apply = (id: string, key: string | null) => {
@@ -211,7 +211,7 @@ function KeybindsDialog() {
</section> </section>
</div>; </div>;
} }
export function KeybindsPanel() { export function KeybindsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
const open = useGameStore((state) => state.overlay === "keybinds"); const storeOpen = useGameStore((state) => state.overlay === "keybinds");
return open ? <KeybindsDialog /> : null; return (open ?? storeOpen) ? <KeybindsDialog onClose={onClose} /> : null;
} }
+53
View File
@@ -0,0 +1,53 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { classById } from "../app/characterCatalog";
import { beginMainMenuCharacterEditing } from "../app/mainMenuCharacterEditing";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { AbilityBindingsPanel } from "./AbilityBindingsPanel";
import { BrandMark, FrontSurface } from "./FrontSurface";
import { KeybindsPanel } from "./KeybindsPanel";
import { RomSkillsPanel } from "./RomSkillsPanel";
import { loadTalentGameplayPanel } from "./talentGameplayResources";
import "./mainMenuCharacterEditor.css";
const TalentGameplayPanel = lazy(loadTalentGameplayPanel);
export type MainMenuEditor = "talents" | "keybinds" | "bindings";
export function MainMenuCharacterEditor({ editor, onClose }: {
editor: MainMenuEditor;
onClose: () => void;
}) {
const character = useShellStore(selectedCharacter);
const ownerId = useShellStore((state) => state.session?.ownerId);
const notice = useShellStore((state) => state.notice);
const characterKey = `${ownerId ?? ""}:${character?.id ?? ""}`;
const [readyKey, setReadyKey] = useState<string | null>(null);
useEffect(() => {
const stop = beginMainMenuCharacterEditing();
setReadyKey(stop ? characterKey : null);
return () => stop?.();
}, [characterKey]);
const ready = readyKey === characterKey;
return <DualDisplayFrame companionDisabled contextLabel="Character setup" bottom={null} top={
<FrontSurface className="main-menu-character-editor" ariaLabel="Main menu character setup">
<header className="main-menu-character-editor__header">
<BrandMark compact />
<div><strong>{character?.name ?? "Character setup"}</strong>
<span>{character ? `Level ${character.level} ${classById(character.classId).name}` : "Select a character to edit."}</span></div>
<button className="front-button" type="button" onClick={onClose}>Back to main menu</button>
</header>
{notice && <p className="front-notice" role="status">{notice}</p>}
<div className="main-menu-character-editor__panel">
{!ready ? <p role="status">Preparing character…</p> : <Suspense fallback={<p role="status">Opening talents…</p>}>
{editor === "talents" && (character?.categoryId === "rom"
? <RomSkillsPanel open onClose={onClose} />
: <TalentGameplayPanel open onClose={onClose} />)}
{editor === "keybinds" && <KeybindsPanel open onClose={onClose} />}
{editor === "bindings" && <AbilityBindingsPanel open onClose={onClose} />}
</Suspense>}
</div>
</FrontSurface>
} />;
}
+37 -5
View File
@@ -11,7 +11,9 @@ import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel"; import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures"; import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { DungeonReportsScreen } from "./DungeonReportsScreen"; import { DungeonReportsScreen } from "./DungeonReportsScreen";
import { MainMenuCharacterEditor, type MainMenuEditor } from "./MainMenuCharacterEditor";
const BossCardCollectionScreen = lazy(() => import("./BossCardCollection")); const BossCardCollectionScreen = lazy(() => import("./BossCardCollection"));
const BossTestingScreen = lazy(() => import("./BossTestingScreen").then((module) => ({ default: module.BossTestingScreen })));
const GAME_MODES = [ const GAME_MODES = [
{ {
@@ -68,16 +70,22 @@ export function MainMenuScreen() {
const [contentManagerOpen, setContentManagerOpen] = useState(false); const [contentManagerOpen, setContentManagerOpen] = useState(false);
const [reportsOpen, setReportsOpen] = useState(false); const [reportsOpen, setReportsOpen] = useState(false);
const [cardsOpen, setCardsOpen] = useState(false); const [cardsOpen, setCardsOpen] = useState(false);
const [bossTestingOpen, setBossTestingOpen] = useState(false);
const [editor, setEditor] = useState<MainMenuEditor | null>(null);
if (editor) return <MainMenuCharacterEditor editor={editor} onClose={() => setEditor(null)} />;
if (bossTestingOpen) return <Suspense fallback={<p role="status">Opening Boss Testing…</p>}><BossTestingScreen onClose={() => setBossTestingOpen(false)} /></Suspense>;
if (cardsOpen) return <Suspense fallback={<p role="status">Opening collection log…</p>}><BossCardCollectionScreen onClose={() => setCardsOpen(false)} /></Suspense>; if (cardsOpen) return <Suspense fallback={<p role="status">Opening collection log…</p>}><BossCardCollectionScreen onClose={() => setCardsOpen(false)} /></Suspense>;
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />; if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />; if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} />; return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenEditor={setEditor} />;
} }
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: { function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenEditor }: {
readonly onOpenDownloads: () => void; readonly onOpenDownloads: () => void;
readonly onOpenReports: () => void; readonly onOpenReports: () => void;
readonly onOpenCards: () => void; readonly onOpenCards: () => void;
readonly onOpenBossTesting: () => void;
readonly onOpenEditor: (editor: MainMenuEditor) => void;
}) { }) {
const activeCharacter = useShellStore((state) => state.activeCharacter); const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter); const rosterCharacter = useShellStore(selectedCharacter);
@@ -118,11 +126,17 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
run: returnToCharacters, run: returnToCharacters,
neighbors: { up: "mode-dungeons", left: "mode-dungeons" }, neighbors: { up: "mode-dungeons", left: "mode-dungeons" },
}, },
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []), ...(character ? [
{ id: "mode-class-hall", run: openClassHall },
{ id: "mode-talents", run: () => onOpenEditor("talents") },
{ id: "mode-keybinds", run: () => onOpenEditor("keybinds") },
{ id: "mode-bindings", run: () => onOpenEditor("bindings") },
] : []),
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []), ...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
{ id: "mode-reports", run: onOpenReports }, { id: "mode-reports", run: onOpenReports },
{ id: "mode-cards", run: onOpenCards }, { id: "mode-cards", run: onOpenCards },
], [character, isGm, onOpenCards, onOpenDownloads, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]); { id: "mode-boss-testing", run: onOpenBossTesting },
], [character, isGm, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
const controller = useMenuController(actions, { const controller = useMenuController(actions, {
initialId: "mode-dungeons", initialId: "mode-dungeons",
onBack: returnToCharacters, onBack: returnToCharacters,
@@ -177,7 +191,25 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
<p>Select a destination. More adventures will unlock here as they are built.</p> <p>Select a destination. More adventures will unlock here as they are built.</p>
</header> </header>
<OnlineGroupPanel /> <OnlineGroupPanel />
<nav className="main-menu-mode-grid" aria-label="Game modes"> <nav className="main-menu-mode-grid" aria-label="Main menu destinations">
<ControllerButton controlId="mode-boss-testing" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenBossTesting}>
<b aria-hidden="true">BT</b><span><strong>Boss Testing</strong><small>Choose a boss or fight a random encounter in an empty testing room.</small></span><em>Open</em>
</ControllerButton>
{character && <>
<ControllerButton controlId="mode-talents" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("talents")}>
<b aria-hidden="true">T</b><span><strong>{isRom ? "Skills" : "Talents"}</strong><small>{isRom ? "Review your primary, secondary, and elite skills." : "View your talent trees, spend points, or reset your build."}</small></span><em>Open</em>
</ControllerButton>
<ControllerButton controlId="mode-keybinds" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("keybinds")}>
<b aria-hidden="true">K</b><span><strong>Keybinds</strong><small>View and change keyboard or controller bindings.</small></span><em>Open</em>
</ControllerButton>
<ControllerButton controlId="mode-bindings" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("bindings")}>
<b aria-hidden="true">A</b><span><strong>Button assignments</strong><small>Choose the abilities on your action bars and controller buttons.</small></span><em>Open</em>
</ControllerButton>
</>}
<ControllerButton controlId="mode-cards" selectedId={controller.selectedId} select={controller.select} <ControllerButton controlId="mode-cards" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenCards}> className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenCards}>
<b aria-hidden="true">◆</b><span><strong>Boss Cards</strong><small>Track your collection, rarities, and copies from every dungeon.</small></span><em>Open</em> <b aria-hidden="true">◆</b><span><strong>Boss Cards</strong><small>Track your collection, rarities, and copies from every dungeon.</small></span><em>Open</em>
+5 -2
View File
@@ -37,6 +37,7 @@ import {
import { useMenuController, type MenuAction } from "../input/useMenuController"; import { useMenuController, type MenuAction } from "../input/useMenuController";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel"; import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton"; import { ControllerButton } from "./ControllerButton";
import { AbilityTooltipTarget } from "./AbilityTooltip";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface"; import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel"; import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore"; import { useOnlineGroupStore } from "../app/onlineGroupStore";
@@ -413,6 +414,7 @@ export function ManastormSelectScreen() {
const capped = !selected const capped = !selected
&& selectedSpellIds.length >= spellLoadoutModel.maxSelections; && selectedSpellIds.length >= spellLoadoutModel.maxSelections;
return ( return (
<AbilityTooltipTarget key={spell.id} ability={spell}>
<ControllerButton <ControllerButton
key={spell.id} key={spell.id}
controlId={`manastorm-spell-${spell.id}`} controlId={`manastorm-spell-${spell.id}`}
@@ -420,8 +422,8 @@ export function ManastormSelectScreen() {
select={controller.select} select={controller.select}
className={`manastorm-spell-option ${selected ? "is-active" : ""}`} className={`manastorm-spell-option ${selected ? "is-active" : ""}`}
type="button" type="button"
disabled={capped} aria-disabled={capped}
onClick={() => toggleSpell(spell.id)} onClick={() => { if (!capped) toggleSpell(spell.id); }}
title={`${spell.description} · Client Spell.dbc ID ${spell.dbcSpellId}`} title={`${spell.description} · Client Spell.dbc ID ${spell.dbcSpellId}`}
> >
<img src={spell.icon} alt="" aria-hidden="true" /> <img src={spell.icon} alt="" aria-hidden="true" />
@@ -430,6 +432,7 @@ export function ManastormSelectScreen() {
<small>Spell {spell.dbcSpellId}</small> <small>Spell {spell.dbcSpellId}</small>
</span> </span>
</ControllerButton> </ControllerButton>
</AbilityTooltipTarget>
); );
})} })}
{!spellLoadoutModel?.options.length ? ( {!spellLoadoutModel?.options.length ? (
+3 -1
View File
@@ -20,6 +20,7 @@ import { auraDefinitionIsDispellableDebuff } from "./auraDisplay";
import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../game/partyResurrection"; import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../game/partyResurrection";
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination"; import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { useHudSurface } from "./HudSurface";
const PARTY_COMMANDS: readonly { const PARTY_COMMANDS: readonly {
command: PartyCommand; command: PartyCommand;
@@ -212,6 +213,7 @@ function OnlineMemberFrame({
/** Compact roster for the player plus the selected number of targetable AI allies. */ /** Compact roster for the player plus the selected number of targetable AI allies. */
export function PartyFrames() { export function PartyFrames() {
const { onResurrect } = useHudSurface();
const character = useShellStore((state) => state.activeCharacter); const character = useShellStore((state) => state.activeCharacter);
const playerRole = useShellStore((state) => state.activeDungeonRole); const playerRole = useShellStore((state) => state.activeDungeonRole);
const playerHealth = useCombatStore((state) => state.health); const playerHealth = useCombatStore((state) => state.health);
@@ -342,7 +344,7 @@ export function PartyFrames() {
title={bossCombat ? "Unavailable during boss combat." : !onlineSessionOwnsWorldSimulation() title={bossCombat ? "Unavailable during boss combat." : !onlineSessionOwnsWorldSimulation()
? "Only the party's simulation host can resurrect NPC teammates." ? "Only the party's simulation host can resurrect NPC teammates."
: "Resurrect fallen NPC teammates at the selected checkpoint. They will return to you."} : "Resurrect fallen NPC teammates at the selected checkpoint. They will return to you."}
onClick={() => resurrectDeadPartyMembers(checkpointId)} onClick={() => (onResurrect ?? resurrectDeadPartyMembers)(checkpointId)}
> >
<strong>Rez dead members</strong> <strong>Rez dead members</strong>
<small>{bossCombat ? "Unavailable during boss combat" : !onlineSessionOwnsWorldSimulation() <small>{bossCombat ? "Unavailable during boss combat" : !onlineSessionOwnsWorldSimulation()
+8 -3
View File
@@ -7,6 +7,7 @@ import { sessionCanUseShelvedGmTools } from "../app/releaseFeatures";
import { useInputBindingLabels } from "../input/useInputBindingLabels"; import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { INPUT_ACTION_BY_ID } from "../input/keybinds"; import { INPUT_ACTION_BY_ID } from "../input/keybinds";
import { DungeonIssueReportPanel } from "./DungeonIssueReportPanel"; import { DungeonIssueReportPanel } from "./DungeonIssueReportPanel";
import { BossTestingReviewPanel } from "./BossTestingReviewPanel";
function PauseDialog() { function PauseDialog() {
const bindingLabel = useInputBindingLabels(); const bindingLabel = useInputBindingLabels();
@@ -14,6 +15,7 @@ function PauseDialog() {
const openOverlay = useGameStore((state) => state.openOverlay); const openOverlay = useGameStore((state) => state.openOverlay);
const reset = useGameStore((state) => state.resetAtEntrance); const reset = useGameStore((state) => state.resetAtEntrance);
const gameMode = useGameStore((state) => state.gameMode); const gameMode = useGameStore((state) => state.gameMode);
const bossTest = useGameStore((state) => state.bossTest);
const activeCharacter = useShellStore((state) => state.activeCharacter); const activeCharacter = useShellStore((state) => state.activeCharacter);
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu); const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
@@ -50,12 +52,13 @@ function PauseDialog() {
{ id: "pause-options", run: () => openOverlay("options") }, { id: "pause-options", run: () => openOverlay("options") },
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") }, { id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
...(gameMode === "dungeon" ? [{ id: "pause-report-issue", run: () => openOverlay("report-issue") }] : []), ...(gameMode === "dungeon" ? [{ id: "pause-report-issue", run: () => openOverlay("report-issue") }] : []),
...(bossTest ? [{ id: "pause-boss-testing-review", run: () => openOverlay("boss-test-review") }] : []),
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []), ...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
...(gameMode === "manastorm" ...(gameMode === "manastorm"
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }] ? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
: [{ id: "pause-entrance", run: returnToEntrance }]), : [{ id: "pause-entrance", run: returnToEntrance }]),
{ id: "pause-main-menu", run: leaveDungeon }, { id: "pause-main-menu", run: leaveDungeon },
], [abandonManastormRun, closeOverlay, gameMode, isGm, openOverlay, reset, returnToMainMenu]); ], [abandonManastormRun, bossTest, closeOverlay, gameMode, isGm, openOverlay, reset, returnToMainMenu]);
const controller = useMenuController(actions, { initialId: "pause-resume", onBack: resume }); const controller = useMenuController(actions, { initialId: "pause-resume", onBack: resume });
const menuButton = (id: string, label: string, run: () => void, primary = false) => ( const menuButton = (id: string, label: string, run: () => void, primary = false) => (
@@ -73,10 +76,11 @@ function PauseDialog() {
<div className="modal-backdrop" role="presentation"> <div className="modal-backdrop" role="presentation">
<section className="pause-menu" role="dialog" aria-modal="true" aria-labelledby="pause-title"> <section className="pause-menu" role="dialog" aria-modal="true" aria-labelledby="pause-title">
<p className="eyebrow">Healer Man paused</p> <p className="eyebrow">Healer Man paused</p>
<h2 id="pause-title">The cavern waits.</h2> <h2 id="pause-title">{bossTest ? `${bossTest.name} — Boss Testing` : "The cavern waits."}</h2>
<p>Resume play or open any character and system menu from this hub.</p> <p>Resume play or open any character and system menu from this hub.</p>
<div className="pause-menu__actions"> <div className="pause-menu__actions">
{menuButton("pause-resume", "Resume expedition", resume, true)} {menuButton("pause-resume", "Resume expedition", resume, true)}
{bossTest && menuButton("pause-boss-testing-review", "Boss readiness & report", () => openOverlay("boss-test-review"))}
{menuButton("pause-party", "Party management", () => openOverlay("party"))} {menuButton("pause-party", "Party management", () => openOverlay("party"))}
{menuButton("pause-inventory", "Inventory & equipment", () => openOverlay("inventory"))} {menuButton("pause-inventory", "Inventory & equipment", () => openOverlay("inventory"))}
{menuButton("pause-boss-cards", "Boss card collection", () => openOverlay("boss-cards"))} {menuButton("pause-boss-cards", "Boss card collection", () => openOverlay("boss-cards"))}
@@ -92,7 +96,7 @@ function PauseDialog() {
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null} {isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
{gameMode === "manastorm" {gameMode === "manastorm"
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm) ? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
: menuButton("pause-entrance", "Return to entrance", returnToEntrance)} : menuButton("pause-entrance", bossTest ? "Restart boss test" : "Return to entrance", returnToEntrance)}
{menuButton("pause-main-menu", "Return to main menu", leaveDungeon)} {menuButton("pause-main-menu", "Return to main menu", leaveDungeon)}
</div> </div>
<dl className="controls-list"> <dl className="controls-list">
@@ -110,5 +114,6 @@ function PauseDialog() {
export function PauseMenu() { export function PauseMenu() {
const overlay = useGameStore((state) => state.overlay); const overlay = useGameStore((state) => state.overlay);
if (overlay === "report-issue") return <DungeonIssueReportPanel />; if (overlay === "report-issue") return <DungeonIssueReportPanel />;
if (overlay === "boss-test-review") return <BossTestingReviewPanel />;
return overlay === "pause" ? <PauseDialog /> : null; return overlay === "pause" ? <PauseDialog /> : null;
} }
+5 -4
View File
@@ -20,9 +20,10 @@ function visibleGroup(ability: AbilityDefinition, primaryClassId: string): (type
return "primary"; return "primary";
} }
export function RomSkillsPanel() { export function RomSkillsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
const open = useGameStore((state) => state.overlay === "talents"); const storeOpen = useGameStore((state) => state.overlay === "talents");
const close = useGameStore((state) => state.closeOverlay); const closeOverlay = useGameStore((state) => state.closeOverlay);
const close = onClose ?? closeOverlay;
const classId = useCombatStore((state) => state.classId); const classId = useCombatStore((state) => state.classId);
const secondaryClassId = useCombatStore((state) => state.secondaryClassId); const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
const level = useCombatStore((state) => state.level); const level = useCombatStore((state) => state.level);
@@ -32,7 +33,7 @@ export function RomSkillsPanel() {
{ id: "rom-skills-close", run: close }, { id: "rom-skills-close", run: close },
], [abilities, close]); ], [abilities, close]);
const controller = useMenuController(actions, { initialId: abilities[0] ? `rom-skill-${abilities[0].id}` : "rom-skills-close", onBack: close }); const controller = useMenuController(actions, { initialId: abilities[0] ? `rom-skill-${abilities[0].id}` : "rom-skills-close", onBack: close });
if (!open) return null; if (!(open ?? storeOpen)) return null;
const primary = classById(classId); const primary = classById(classId);
const secondary = secondaryClassId ? classById(secondaryClassId) : null; const secondary = secondaryClassId ? classById(secondaryClassId) : null;
+19 -2
View File
@@ -1,7 +1,11 @@
import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { useGameStore } from "../game/store"; 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 { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton"; import { ControllerButton } from "./ControllerButton";
import { AbilityTooltipTarget } from "./AbilityTooltip";
export interface SpellbookAbilityView { export interface SpellbookAbilityView {
id: string; id: string;
@@ -79,6 +83,11 @@ function SpellbookDialog({
unlockedOnly, unlockedOnly,
onUnlockedOnlyChange, onUnlockedOnlyChange,
}: SpellbookDialogProps) { }: 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( const orderedAbilities = useMemo(
() => spellbookAbilitiesForDisplay(abilities, playerLevel, false), () => spellbookAbilitiesForDisplay(abilities, playerLevel, false),
[abilities, playerLevel], [abilities, playerLevel],
@@ -130,6 +139,7 @@ function SpellbookDialog({
</header> </header>
<div className="spellbook-panel__toolbar"> <div className="spellbook-panel__toolbar">
<span className="action-bar-missing-legend">Light blue: missing from action bars</span>
<button <button
type="button" type="button"
className={`spellbook-panel__filter ${unlockedOnly ? "is-active" : ""}`} 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"}> <div className="spellbook-panel__list" aria-label={unlockedOnly ? "Unlocked abilities" : "Known and locked abilities"}>
{visibleAbilities.map((ability) => { {visibleAbilities.map((ability) => {
const locked = !ability.known || ability.level > playerLevel; const locked = !ability.known || ability.level > playerLevel;
const missing = !locked && !ability.passive && !assignedIds.has(ability.id);
const lockLabel = ability.talentLocked && !ability.known const lockLabel = ability.talentLocked && !ability.known
? "Learn in talents" ? "Learn in talents"
: `Unlocks at level ${ability.level}`; : `Unlocks at level ${ability.level}`;
return ( return (
<AbilityTooltipTarget key={ability.id} ability={{ ...ability,
lockedUntilLevel: ability.level > playerLevel ? ability.level : undefined,
talentLocked: ability.talentLocked && !ability.known,
}}>
<ControllerButton <ControllerButton
key={ability.id} key={ability.id}
controlId={`spell-${ability.id}`} controlId={`spell-${ability.id}`}
@@ -158,9 +173,9 @@ function SpellbookDialog({
setInspectedId(ability.id); setInspectedId(ability.id);
}} }}
type="button" 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-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)} onFocus={() => setInspectedId(ability.id)}
onClick={() => { onClick={() => {
setInspectedId(ability.id); setInspectedId(ability.id);
@@ -184,6 +199,7 @@ function SpellbookDialog({
? <em>{ability.talentLocked && !ability.known ? "Talent" : `Level ${ability.level}`}</em> ? <em>{ability.talentLocked && !ability.known ? "Talent" : `Level ${ability.level}`}</em>
: <em className="spellbook-ability__known">{ability.passive ? "Passive" : "Known"}</em>} : <em className="spellbook-ability__known">{ability.passive ? "Passive" : "Known"}</em>}
</ControllerButton> </ControllerButton>
</AbilityTooltipTarget>
); );
})} })}
{!visibleAbilities.length && ( {!visibleAbilities.length && (
@@ -208,6 +224,7 @@ function SpellbookDialog({
? "Learn in talents" ? "Learn in talents"
: `Unlocks at level ${inspected.level}` : `Unlocks at level ${inspected.level}`
: inspected.passive ? "Passive" : "Unlocked"}</dd></div> : 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.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.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>} {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(); return name.split(/\s+/).map((word) => word[0]).join("").slice(0, 2).toUpperCase();
} }
export default function TalentGameplayPanel({ readOnly = false }: { readOnly?: boolean } = {}) { export default function TalentGameplayPanel({ readOnly = false, open, onClose }: {
readOnly?: boolean;
open?: boolean;
onClose?: () => void;
} = {}) {
const classId = useCombatStore((state) => state.classId); const classId = useCombatStore((state) => state.classId);
const level = useCombatStore((state) => state.level); const level = useCombatStore((state) => state.level);
const talentRanks = useCombatStore((state) => state.talentRanks); const talentRanks = useCombatStore((state) => state.talentRanks);
@@ -45,5 +49,7 @@ export default function TalentGameplayPanel({ readOnly = false }: { readOnly?: b
onInvest={(nodeId) => { if (!readOnly) useCombatStore.getState().allocateTalent(nodeId); }} onInvest={(nodeId) => { if (!readOnly) useCombatStore.getState().allocateTalent(nodeId); }}
onReset={() => { if (!readOnly) useCombatStore.getState().resetTalents(); }} onReset={() => { if (!readOnly) useCombatStore.getState().resetTalents(); }}
readOnly={readOnly} readOnly={readOnly}
open={open}
onClose={onClose}
/>; />;
} }
+2 -2
View File
@@ -242,7 +242,7 @@ function TalentsDialog({
<><strong>{availablePoints}</strong><span>points available</span></> <><strong>{availablePoints}</strong><span>points available</span></>
)} )}
</div> </div>
<button type="button" className="icon-button" onClick={onClose} aria-label="Return to pause menu">×</button> <button type="button" className="icon-button" onClick={onClose} aria-label="Close talents">×</button>
</header> </header>
<p className="talents-panel__intro"> <p className="talents-panel__intro">
@@ -436,7 +436,7 @@ function TalentsDialog({
>Reset talents</ControllerButton> >Reset talents</ControllerButton>
</div> </div>
<footer className="game-panel__footer talents-panel__footer"> <footer className="game-panel__footer talents-panel__footer">
<span><kbd>D-pad</kbd> Navigate</span><span><kbd>A</kbd> Learn / inspect</span><span><kbd>B</kbd> Pause menu</span> <span><kbd>D-pad</kbd> Navigate</span><span><kbd>A</kbd> Learn / inspect</span><span><kbd>B</kbd> Back</span>
</footer> </footer>
</section> </section>
</div> </div>
+40 -14
View File
@@ -2,15 +2,18 @@ import { useEffect, useRef, useState } from "react";
import { useShellStore } from "../app/shellStore"; import { useShellStore } from "../app/shellStore";
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient"; import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
import { useCombatStore } from "../game/combatStore"; import { useCombatStore } from "../game/combatStore";
import { defaultActionBarForCharacter } from "../game/abilityCatalog"; import { abilityAtLevel, defaultActionBarForCharacter } from "../game/abilityCatalog";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import { import {
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout, BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout, resetDesktopAbilities,
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice, exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
resolveHudScreen, type HudScreen,
} from "../game/uiLayout"; } from "../game/uiLayout";
import { INPUT_ACTION_BY_ID, conflictingInputActions } from "../input/keybinds"; import { INPUT_ACTION_BY_ID, conflictingInputActions } from "../input/keybinds";
import { profileInputBindings } from "../game/keybindPreferences"; import { profileInputBindings } from "../game/keybindPreferences";
import { useUiLayoutStore } from "../game/uiLayoutStore"; import { useUiLayoutStore } from "../game/uiLayoutStore";
import { AbilityTooltipTarget } from "./AbilityTooltip";
import { currentDisplayLayout } from "../platform/displayLayout";
export function UiLayoutEditor() { export function UiLayoutEditor() {
const open = useGameStore((state) => state.overlay === "ui-layout"); const open = useGameStore((state) => state.overlay === "ui-layout");
@@ -18,6 +21,8 @@ export function UiLayoutEditor() {
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const cloudSave = useCloudSaveStatus(); const cloudSave = useCloudSaveStatus();
const abilities = useCombatStore((state) => state.abilities); const abilities = useCombatStore((state) => state.abilities);
const level = useCombatStore((state) => state.level);
const talentRanks = useCombatStore((state) => state.talentRanks);
const [tab, setTab] = useState<UiDevice>(ui.device); const [tab, setTab] = useState<UiDevice>(ui.device);
const [name, setName] = useState("My UI"); const [name, setName] = useState("My UI");
const [slot, setSlot] = useState(0); const [slot, setSlot] = useState(0);
@@ -30,13 +35,13 @@ export function UiLayoutEditor() {
const file = useRef<HTMLInputElement>(null); const file = useRef<HTMLInputElement>(null);
const wasOpen = useRef(false); const wasOpen = useRef(false);
useEffect(() => { useEffect(() => {
if (open && !wasOpen.current) { if (open && (!wasOpen.current || !useUiLayoutStore.getState().draft)) {
useUiLayoutStore.getState().begin(); useUiLayoutStore.getState().begin();
setTab(useUiLayoutStore.getState().device); setMessage(""); setTab(useUiLayoutStore.getState().device); setMessage("");
} }
if (!open && wasOpen.current) useUiLayoutStore.getState().finish(false); if (!open && wasOpen.current) useUiLayoutStore.getState().finish(false);
wasOpen.current = open; wasOpen.current = open;
}, [open]); }, [open, ui.draft !== null]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
@@ -69,6 +74,7 @@ export function UiLayoutEditor() {
}, [open]); }, [open]);
if (!open || !ui.draft) return null; if (!open || !ui.draft) return null;
const draft = ui.draft; const draft = ui.draft;
const dualScreen = currentDisplayLayout() !== "single";
const attempt = (run: () => void) => { try { run(); } catch (error) { setMessage(error instanceof Error ? error.message : "Could not save this UI."); } }; const attempt = (run: () => void) => { try { run(); } catch (error) { setMessage(error instanceof Error ? error.message : "Could not save this UI."); } };
const saveAndClose = () => attempt(() => { ui.finish(true); useGameStore.getState().closeOverlay(); }); const saveAndClose = () => attempt(() => { ui.finish(true); useGameStore.getState().closeOverlay(); });
const validName = name.trim().length > 0; const validName = name.trim().length > 0;
@@ -87,7 +93,7 @@ export function UiLayoutEditor() {
<button type="button" onClick={() => setDockRight(!dockRight)} aria-label={dockRight ? "Dock editor left" : "Dock editor right"}>{dockRight ? "←" : "→"}</button> <button type="button" onClick={() => setDockRight(!dockRight)} aria-label={dockRight ? "Dock editor left" : "Dock editor right"}>{dockRight ? "←" : "→"}</button>
<button type="button" onClick={() => setCollapsed(!collapsed)} aria-label={collapsed ? "Expand editor" : "Collapse editor"}>{collapsed ? "+" : "−"}</button></header> <button type="button" onClick={() => setCollapsed(!collapsed)} aria-label={collapsed ? "Expand editor" : "Collapse editor"}>{collapsed ? "+" : "−"}</button></header>
{!collapsed && <div className="ui-layout-editor__body"> {!collapsed && <div className="ui-layout-editor__body">
<p>Drag a widget’s label to move it. Hidden and inactive widgets appear as placeholders. Use the list to show them again.</p> <p>Choose a screen for each element below. Collapse this panel and drag a widget’s label to move it. Use the list to show hidden elements again.</p>
<details open><summary>UI templates · 6 slots</summary> <details open><summary>UI templates · 6 slots</summary>
<div className="ui-layout-editor__tabs">{(["mobile", "pc"] as const).map((device) => <button key={device} type="button" aria-pressed={tab === device} onClick={() => chooseSlot(device, 0)}>{device === "pc" ? "PC" : "Mobile"} · 3 slots</button>)}</div> <div className="ui-layout-editor__tabs">{(["mobile", "pc"] as const).map((device) => <button key={device} type="button" aria-pressed={tab === device} onClick={() => chooseSlot(device, 0)}>{device === "pc" ? "PC" : "Mobile"} · 3 slots</button>)}</div>
<div className="ui-layout-editor__slots">{ui.preferences.slots[tab].map((template, index) => <button type="button" key={index} aria-pressed={slot === index} onClick={() => chooseSlot(tab, index)}><small>Slot {index + 1}</small><strong>{template?.name ?? "Empty"}</strong></button>)}</div> <div className="ui-layout-editor__slots">{ui.preferences.slots[tab].map((template, index) => <button type="button" key={index} aria-pressed={slot === index} onClick={() => chooseSlot(tab, index)}><small>Slot {index + 1}</small><strong>{template?.name ?? "Empty"}</strong></button>)}</div>
@@ -131,20 +137,30 @@ export function UiLayoutEditor() {
})}>Replace with imported UI</button><button type="button" onClick={() => setPendingImport(null)}>Keep existing UI</button></div>} })}>Replace with imported UI</button><button type="button" onClick={() => setPendingImport(null)}>Keep existing UI</button></div>}
<small>{session?.accessToken ? "Templates save to your account and upload with cloud saves. Sign in on another device to load them." : "Offline profile: saved on this device. Use export/import to transfer your UI."}</small> <small>{session?.accessToken ? "Templates save to your account and upload with cloud saves. Sign in on another device to load them." : "Offline profile: saved on this device. Use export/import to transfer your UI."}</small>
</details> </details>
<details><summary>Elements · move / hide</summary> <details open={dualScreen}><summary>Elements · screen / move / hide</summary>
{(Object.entries(HUD_ELEMENTS) as [HudElementId, string][]).filter(([id]) => !id.startsWith("bar-") || ui.device === "pc").map(([id, label]) => <div key={id} className={`ui-layout-editor__element ${ui.selectedElement === id ? "is-selected" : ""}`}> {(Object.entries(HUD_ELEMENTS) as [HudElementId, string][]).filter(([id]) => !id.startsWith("bar-") || ui.device === "pc").map(([id, label]) => <div key={id} className={`ui-layout-editor__element ${ui.selectedElement === id ? "is-selected" : ""}`}
<label><input type="checkbox" checked={draft.elements[id].visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], visible: event.target.checked } } }))} />{label}</label> onClick={() => useUiLayoutStore.setState({ selectedElement: id })} onFocus={() => useUiLayoutStore.setState({ selectedElement: id })}>
<label>{label}</label>
<select aria-label={`${label} screen`} value={dualScreen ? resolveHudScreen(id, draft, true) : draft.elements[id].visible ? draft.elements[id].screen ?? "auto" : "hidden"}
onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
[id]: { ...layout.elements[id], screen: event.target.value as HudScreen, visible: event.target.value !== "hidden", position: null, width: null },
} }))}>
{!dualScreen && <option value="auto">Automatic</option>}
<option value="top">Top</option><option value="bottom">Bottom</option><option value="hidden">Hidden</option>
</select>
<button type="button" aria-label={`Reset ${label} position`} onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], position: null, width: null } } }))}>Reset</button> <button type="button" aria-label={`Reset ${label} position`} onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], position: null, width: null } } }))}>Reset</button>
</div>)} </div>)}
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button> <button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements, dpsMeterDisplay: "auto" }))}>Reset all elements</button>
{dualScreen && <p>Top: party health and the dungeon. Bottom: action bars, buffs and debuffs. Collapse this panel to drag labels on either display.</p>}
</details> </details>
<details open className="ui-layout-editor__dps"><summary>DPS meter</summary> <details className="ui-layout-editor__dps"><summary>DPS meter</summary>
<label><input type="checkbox" checked={draft.elements.dps.visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, <label><input type="checkbox" checked={draft.elements.dps.visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: event.target.checked } }, elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: event.target.checked,
screen: event.target.checked && layout.elements.dps.screen === "hidden" ? "auto" : layout.elements.dps.screen } },
}))} />Show DPS meter</label> }))} />Show DPS meter</label>
<label>Screen<select aria-label="DPS meter screen" value={draft.dpsMeterDisplay} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, <label>Screen<select aria-label="DPS meter screen" value={draft.dpsMeterDisplay} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
dpsMeterDisplay: event.target.value as typeof layout.dpsMeterDisplay, dpsMeterDisplay: event.target.value as typeof layout.dpsMeterDisplay,
elements: { ...layout.elements, dps: { ...layout.elements.dps, position: null } }, elements: { ...layout.elements, dps: { ...layout.elements.dps, screen: "auto", position: null } },
}))}> }))}>
<option value="auto">Automatic · PC lower left / Thor bottom</option> <option value="auto">Automatic · PC lower left / Thor bottom</option>
<option value="main">Main screen</option><option value="companion">Companion screen</option> <option value="main">Main screen</option><option value="companion">Companion screen</option>
@@ -160,7 +176,7 @@ export function UiLayoutEditor() {
const combat = useCombatStore.getState(); const combat = useCombatStore.getState();
const defaults = defaultActionBarForCharacter(combat.classId, combat.secondaryClassId, combat.level); const defaults = defaultActionBarForCharacter(combat.classId, combat.secondaryClassId, combat.level);
const empty = layout.bars.every((bar) => bar.slots.every((button) => !button.abilityId)); 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> })} />Use WoW action bars</label>
{draft.wowBars && <> {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> <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> {selected && <div className="ui-layout-editor__assignment"><strong>Bar {selected.bar + 1} · Button {selected.slot + 1}{ui.mode === "bindings" ? " · Press a key…" : ""}</strong>
<button type="button" onClick={() => setAbility(null)}>Clear ability</button> <button type="button" onClick={() => setAbility(null)}>Clear ability</button>
<label>Find ability<input value={search} onChange={(event) => setSearch(event.target.value)} /></label> <label>Find ability<input value={search} onChange={(event) => setSearch(event.target.value)} /></label>
<div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((ability) => <button key={ability.id} type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button>)}</div> <div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((definition) => {
const talentRank = definition.talentEntryId === undefined ? undefined : talentRanks[`coa-entry-${definition.talentEntryId}`] ?? 0;
const ability = abilityAtLevel(definition, level, talentRank);
return <AbilityTooltipTarget key={ability.id} ability={{
name: ability.name, description: ability.description, target: ability.target,
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000, range: ability.range.max,
lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
talentLocked: ability.talentEntryId !== undefined && !talentRank,
}}><button type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button></AbilityTooltipTarget>;
})}</div>
</div>} </div>}
</>} </>}
</details>} </details>}
+47
View File
@@ -0,0 +1,47 @@
.boss-testing-screen {
--boss-test-border: rgba(177, 199, 218, 0.22);
--boss-test-panel: rgba(14, 23, 33, 0.76);
display: flex;
flex-direction: column;
min-height: 100%;
overflow: auto;
padding: clamp(16px, 3vw, 40px);
gap: 20px;
}
.boss-testing-header { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; }
.boss-testing-header > div:nth-child(2) { flex: 1; }
.boss-testing-header h1, .boss-testing-selected h2 { margin: 5px 0; }
.boss-testing-header span, .boss-testing-help { color: var(--front-muted, #b1bbc5); }
.boss-testing-main { width: min(100%, 1060px); margin: 0 auto; display: grid; gap: 18px; }
.boss-testing-main p { margin: 0; line-height: 1.6; }
.boss-testing-filters { display: grid; grid-template-columns: 2fr 1fr 1fr; gap: 14px; }
.boss-testing-main label { display: grid; gap: 8px; font-weight: 600; min-width: 0; }
.boss-testing-main input, .boss-testing-main select {
color: #e8edf2;
background: #17232f;
border: 1px solid var(--boss-test-border);
padding: 12px;
border-radius: 8px;
width: 100%;
min-width: 0;
font: inherit;
}
.boss-testing-main select option { background: #17232f; }
.boss-testing-selected { background: var(--boss-test-panel); border: 1px solid var(--boss-test-border); border-radius: 12px; padding: 20px; display: grid; gap: 8px; }
.boss-testing-status { width: fit-content; padding: 5px 10px; border-radius: 5px; background: #354454; }
.boss-testing-status--ready { color: #b6f0cd; }
.boss-testing-status--needs-testing { color: #f4db9e; }
.boss-testing-status--not-ready { color: #f7b7b7; }
.boss-testing-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.boss-testing-actions [aria-pressed="true"] { border-color: #d6b66b; box-shadow: inset 0 0 0 1px #d6b66b; }
.boss-testing-progress { display: grid; gap: 8px; }
.boss-testing-progress progress { width: 100%; accent-color: #d6b66b; }
.boss-testing-help { font-size: 0.9rem; }
.boss-testing-review .boss-testing-actions { margin: 18px 0; }
@media (max-width: 700px) {
.boss-testing-filters { grid-template-columns: 1fr 1fr; }
.boss-testing-filters label:first-child { grid-column: 1 / -1; }
.boss-testing-header .front-brand { display: none; }
.boss-testing-header { gap: 12px; }
.boss-testing-actions > button { flex: 1 1 180px; }
}
+37
View File
@@ -0,0 +1,37 @@
.main-menu-character-editor {
display: flex;
flex-direction: column;
gap: 12px;
}
.main-menu-character-editor__header {
display: flex;
align-items: center;
gap: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--edge);
}
.main-menu-character-editor__header > div:nth-child(2) {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
}
.main-menu-character-editor__header strong { color: var(--gold); }
.main-menu-character-editor__header span { color: var(--muted); font-size: 12px; }
.main-menu-character-editor__panel { position: relative; flex: 1; min-height: 0; }
.main-menu-character-editor__panel .modal-backdrop {
padding: 0;
background: transparent;
backdrop-filter: none;
}
.main-menu-character-editor__panel .game-panel {
max-width: 100%;
max-height: 100%;
}
.main-menu-character-editor__panel .keybinds-panel { width: min(1100px, 100%); height: 100%; }
@media (max-width: 640px), (max-height: 500px) {
.main-menu-character-editor { gap: 6px; }
.main-menu-character-editor__header { gap: 10px; padding-bottom: 6px; }
.main-menu-character-editor__header .front-brand { display: none; }
.main-menu-character-editor__header .front-button { padding: 8px 12px; font-size: 12px; }
}
+29
View File
@@ -0,0 +1,29 @@
import { createElement, type ComponentType } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { AbilityBindingsPanel } from "./AbilityBindingsPanel";
import { KeybindsPanel } from "./KeybindsPanel";
import { RomSkillsPanel } from "./RomSkillsPanel";
const editors: {
Panel: ComponentType<{ open?: boolean; onClose?: () => void }>;
title: string;
close: string;
}[] = [
{ Panel: AbilityBindingsPanel, title: "Ability controls", close: "Close button assignments" },
{ Panel: KeybindsPanel, title: "Keybinds", close: "Close keybinds" },
{ Panel: RomSkillsPanel, title: "Skills", close: ">Close</button>" },
];
describe.each(editors)("standalone main-menu editor: $title", ({ Panel, title, close }) => {
it("can open without changing the gameplay overlay", () => {
const markup = renderToStaticMarkup(createElement(Panel, { open: true, onClose: () => undefined }));
expect(markup).toContain('role="dialog"');
expect(markup).toContain(title);
expect(markup).toContain(close);
});
it("respects an explicit closed state", () => {
expect(renderToStaticMarkup(createElement(Panel, { open: false }))).toBe("");
});
});
+39 -1
View File
@@ -1,12 +1,27 @@
import { createElement } from "react"; import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
SpellbookPanel, SpellbookPanel,
spellbookAbilitiesForDisplay, spellbookAbilitiesForDisplay,
type SpellbookAbilityView, type SpellbookAbilityView,
} from "./SpellbookPanel"; } 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[] = [ const abilities: readonly SpellbookAbilityView[] = [
{ {
id: "known-one", id: "known-one",
@@ -47,6 +62,29 @@ const abilities: readonly SpellbookAbilityView[] = [
]; ];
describe("spellbook ability progression", () => { 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", () => { it("renders future abilities as inspectable locked buttons with level requirements", () => {
const markup = renderToStaticMarkup(createElement(SpellbookPanel, { const markup = renderToStaticMarkup(createElement(SpellbookPanel, {
abilities, 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; }
}