Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
756ee4f64a | ||
|
|
ccc01396cd | ||
|
|
c7e8163790 |
No files matched your search
@@ -4,6 +4,48 @@ This file is the durable record of runtime behavior, regressions, and the
|
||||
evidence required before calling a visual bug fixed. Update it whenever scene,
|
||||
camera, input, physics, mob-rendering, or asset-loading code changes.
|
||||
|
||||
## 2026-09-14 New-character PC bars and missing-spell borders
|
||||
|
||||
- A temporary browser fixture used the real desktop bars, ability controls,
|
||||
spellbook, combat store, and character UI activation with memory-only saves.
|
||||
Fresh Priest and Mage profiles populated their PC bars automatically. The
|
||||
Mage started with Frostbolt, Fire Blast, Frost Nova, and Arcane Intellect.
|
||||
- Clearing Lesser Heal gave its spellbook entry and ability-picker entry a
|
||||
visible light blue border, including the icon border. Both computed border
|
||||
colors were `rgb(146, 213, 255)`. Assigned and locked spells kept their usual
|
||||
borders. Reassigning Lesser Heal removed the missing-spell border.
|
||||
- Switching to the fresh Mage and back retained the Priest's cleared slot.
|
||||
Screenshots of both missing-spell menus were inspected during the playtest.
|
||||
The temporary fixture was removed afterward.
|
||||
- Validation: 62 focused tests, TypeScript, production build including bundle
|
||||
verification, and the release-candidate check passed. Full-game verification
|
||||
was limited by a persistent opening-expedition loading screen in both local
|
||||
development and production; menu visuals were verified in the fixture.
|
||||
|
||||
## 2026-09-14 Ability hover tooltips
|
||||
|
||||
- Entered Stormwind Stockade with the existing offline level-17 Priest through
|
||||
the normal roster, dungeon, and role screens. Hovering Smite on the controller
|
||||
bar displayed its description, Mana cost, cast time, cooldown, range, and
|
||||
target. Leaving the button removed the tooltip.
|
||||
- Opened Ability controls and its ability picker. Hovering Shadow Word: Pain
|
||||
displayed its own description without assigning it. Flash Heal was readable
|
||||
by hover and keyboard focus despite its level-20 lock. Switching from keyboard
|
||||
inspection to another hovered ability left exactly one tooltip visible.
|
||||
- Checked the picker at the default 650 x 490 viewport and at 1280 x 800.
|
||||
Tooltip DOM bounds remained inside the viewport. The in-app screenshot
|
||||
scales unusually with a viewport override; reset the override afterward.
|
||||
- Enabled desktop bars in an unsaved UI-layout draft. Hovering the leftmost
|
||||
Smite button showed the tooltip above the bar, clamped to the left edge and
|
||||
outside the HUD's transformed layout container. Discarded the layout draft.
|
||||
- A temporary Flash Heal assignment during input testing was restored to the
|
||||
original Smite assignment. No character or template was created or deleted.
|
||||
- Screenshot evidence: `playtest-artifacts/ability-tooltips/desktop-bar.png`
|
||||
and `playtest-artifacts/ability-tooltips/locked-picker.png`.
|
||||
- Automated verification: all 64 UI tests passed, including nine tooltip
|
||||
content, unavailable-control, and viewport-placement regression tests.
|
||||
TypeScript, the production build, and the release candidate check passed.
|
||||
|
||||
## 2026-09-10 Personal UI templates and desktop action bars
|
||||
|
||||
- Opened Wailing Caverns through the normal offline roster, dungeon, and role
|
||||
@@ -3116,3 +3158,225 @@ Vite's existing large-chunk advisory remains informational.
|
||||
`artifacts/boss-difficulty/wow-coa-balance.json`, and
|
||||
`artifacts/boss-difficulty/coa-healer-review.md`. Survival at the 60-second
|
||||
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);
|
||||
});
|
||||
presentation.show();
|
||||
bridge.getWebView().getSettings().setUseWideViewPort(true);
|
||||
bridge.getWebView().getSettings().setLoadWithOverviewMode(true);
|
||||
bridge.getWebView().loadUrl(currentUrl);
|
||||
}
|
||||
|
||||
@@ -279,6 +281,8 @@ public class ThorDualScreenPlugin extends Plugin implements DisplayManager.Displ
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setUseWideViewPort(true);
|
||||
settings.setLoadWithOverviewMode(true);
|
||||
settings.setDatabaseEnabled(true);
|
||||
settings.setMediaPlaybackRequiresUserGesture(false);
|
||||
settings.setAllowFileAccess(true);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"version": "0.1.41",
|
||||
"version": "0.1.44",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "healer-man",
|
||||
"version": "0.1.41",
|
||||
"version": "0.1.44",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "8.4.1",
|
||||
"@capacitor/core": "8.4.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "healer-man",
|
||||
"private": true,
|
||||
"version": "0.1.41",
|
||||
"version": "0.1.44",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.41",
|
||||
"versionCode": 1041
|
||||
"versionName": "0.1.44",
|
||||
"versionCode": 1044
|
||||
}
|
||||
@@ -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; });
|
||||
@@ -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; });
|
||||
@@ -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; });
|
||||
@@ -19,11 +19,22 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
|
||||
revision INTEGER NOT NULL DEFAULT 0
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS dungeon_reports_dungeon_status_idx ON dungeon_reports(dungeon_id, status);
|
||||
CREATE TABLE IF NOT EXISTS boss_testing_reviews (
|
||||
boss_key TEXT PRIMARY KEY,
|
||||
dungeon_id TEXT NOT NULL,
|
||||
boss_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('ready', 'needs-testing', 'not-ready')),
|
||||
reviewed_by TEXT NOT NULL,
|
||||
reviewed_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
`);
|
||||
const columns = new Set(database.prepare("PRAGMA table_info(dungeon_reports)").all().map((column) => column.name));
|
||||
if (!columns.has("boss_key")) database.exec("ALTER TABLE dungeon_reports ADD COLUMN boss_key TEXT");
|
||||
if (!columns.has("boss_name")) database.exec("ALTER TABLE dungeon_reports ADD COLUMN boss_name TEXT");
|
||||
const insert = database.prepare(`
|
||||
INSERT INTO dungeon_reports (
|
||||
id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'Submitted', ?, ?, ?, ?)
|
||||
id, dungeon_id, comment, status, submitted_by_account_id, submitted_by_username, created_at, updated_at, boss_key, boss_name
|
||||
) VALUES (?, ?, ?, 'Submitted', ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const byId = database.prepare("SELECT * FROM dungeon_reports WHERE id = ?");
|
||||
const list = database.prepare(`
|
||||
@@ -45,6 +56,14 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
|
||||
function validateStatus(status) {
|
||||
if (!policy.statuses.includes(status)) throw new ErrorType("Choose a valid report status.", 400, "invalid_report_status");
|
||||
}
|
||||
function validateBoss(input) {
|
||||
validateDungeon(input?.dungeonId);
|
||||
if (typeof input.bossKey !== "string" || !input.bossKey.startsWith(`${input.dungeonId}/`)
|
||||
|| input.bossKey.length <= input.dungeonId.length + 1 || input.bossKey.length > 256
|
||||
|| typeof input.bossName !== "string" || !input.bossName.trim() || input.bossName.length > 128) {
|
||||
throw new ErrorType("Choose a valid boss.", 400, "invalid_boss");
|
||||
}
|
||||
}
|
||||
function publicReport(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -56,10 +75,30 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
|
||||
updatedAt: Number(row.updated_at),
|
||||
updatedBy: row.updated_by_username,
|
||||
revision: Number(row.revision),
|
||||
...(row.boss_key ? { bossKey: row.boss_key, bossName: row.boss_name } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
listBossTestingReviews() {
|
||||
return { reviews: database.prepare("SELECT * FROM boss_testing_reviews ORDER BY boss_key").all().map((row) => ({
|
||||
bossKey: row.boss_key, dungeonId: row.dungeon_id, bossName: row.boss_name,
|
||||
status: row.status, reviewedBy: row.reviewed_by, reviewedAt: Number(row.reviewed_at),
|
||||
})) };
|
||||
},
|
||||
saveBossTestingReview(account, input) {
|
||||
validateBoss(input);
|
||||
if (!["ready", "needs-testing", "not-ready"].includes(input.status)) {
|
||||
throw new ErrorType("Choose a valid boss readiness status.", 400, "invalid_boss_status");
|
||||
}
|
||||
const review = { bossKey: input.bossKey, dungeonId: input.dungeonId, bossName: input.bossName.trim(),
|
||||
status: input.status, reviewedBy: account?.username ?? "Guest player", reviewedAt: Date.now() };
|
||||
database.prepare(`INSERT INTO boss_testing_reviews VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(boss_key) DO UPDATE SET boss_name = excluded.boss_name, status = excluded.status,
|
||||
reviewed_by = excluded.reviewed_by, reviewed_at = excluded.reviewed_at`).run(
|
||||
review.bossKey, review.dungeonId, review.bossName, review.status, review.reviewedBy, review.reviewedAt);
|
||||
return review;
|
||||
},
|
||||
listDungeonReports({ status = null, dungeonId = null } = {}) {
|
||||
if (status !== null) validateStatus(status);
|
||||
if (dungeonId !== null) validateDungeon(dungeonId);
|
||||
@@ -70,13 +109,14 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
|
||||
},
|
||||
submitDungeonReport(account, input) {
|
||||
validateDungeon(input?.dungeonId);
|
||||
if (input?.bossKey !== undefined || input?.bossName !== undefined) validateBoss(input);
|
||||
if (typeof input?.comment !== "string" || !input.comment.trim() || input.comment.length > policy.commentMaxLength) {
|
||||
throw new ErrorType("Describe the issue in 1-500 characters.", 400, "invalid_report_comment");
|
||||
}
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
insert.run(id, input.dungeonId, input.comment.replace(/\r\n?/g, "\n").trim(), account?.id ?? null,
|
||||
account?.username ?? "Guest player", now, now);
|
||||
account?.username ?? "Guest player", now, now, input.bossKey ?? null, input.bossName?.trim() ?? null);
|
||||
return publicReport(byId.get(id));
|
||||
},
|
||||
updateDungeonReport(account, id, input) {
|
||||
|
||||
@@ -69,6 +69,30 @@ test("persists reports and status changes across database restarts", async () =>
|
||||
} finally { database?.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("persists boss readiness and structured boss reports without changing ordinary reports", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-boss-feedback-"));
|
||||
let database;
|
||||
try {
|
||||
database = openGameDatabase({ dataDir: directory });
|
||||
const boss = { dungeonId: "wailing-caverns", bossKey: "wailing-caverns/lady-anacondra", bossName: "Lady Anacondra" };
|
||||
for (const status of ["ready", "needs-testing", "not-ready"]) {
|
||||
assert.equal(database.saveBossTestingReview(null, { ...boss, status }).status, status);
|
||||
}
|
||||
assert.throws(() => database.saveBossTestingReview(null, { ...boss, status: "Closed" }), { code: "invalid_boss_status" });
|
||||
assert.throws(() => database.saveBossTestingReview(null, { ...boss, bossKey: "other/boss", status: "ready" }), { code: "invalid_boss" });
|
||||
const report = database.submitDungeonReport(null, { ...boss, comment: "x".repeat(500) });
|
||||
assert.equal(report.bossKey, boss.bossKey);
|
||||
assert.equal(report.bossName, boss.bossName);
|
||||
const reviews = database.listBossTestingReviews();
|
||||
database.close();
|
||||
database = openGameDatabase({ dataDir: directory });
|
||||
assert.deepEqual(database.listBossTestingReviews(), reviews);
|
||||
assert.deepEqual(database.listDungeonReports().reports, [report]);
|
||||
const ordinary = database.submitDungeonReport(null, { dungeonId: boss.dungeonId, comment: "Door issue" });
|
||||
assert.equal(ordinary.bossKey, undefined);
|
||||
} finally { database?.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("serves public reports and GM-only status API with shelved modes disabled", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-report-api-"));
|
||||
const runtime = createGameServer({ dataDir: directory, enableShelvedModes: false });
|
||||
@@ -80,6 +104,11 @@ test("serves public reports and GM-only status API with shelved modes disabled",
|
||||
});
|
||||
try {
|
||||
const player = await runtime.database.register("ApiPlayer", "report-player");
|
||||
const boss = { dungeonId: "wailing-caverns", bossKey: "wailing-caverns/lady-anacondra", bossName: "Lady Anacondra", status: "ready" };
|
||||
const saved = await fetch(`${origin}/api/boss-testing/reviews`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(boss) });
|
||||
assert.equal(saved.status, 200);
|
||||
const labels = await (await fetch(`${origin}/api/boss-testing/reviews`)).json();
|
||||
assert.equal(labels.reviews[0].status, "ready");
|
||||
const gm = await runtime.database.register("Phenom", "report-admin");
|
||||
const created = await request("", "POST", { dungeonId: "stormwind-stockade", comment: "Issue via API" }, player.token);
|
||||
assert.equal(created.status, 201);
|
||||
|
||||
@@ -261,6 +261,19 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, gameDatabase.getDungeonRun(dungeonRunMatch[1]));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/boss-testing/reviews" && request.method === "GET") {
|
||||
json(response, 200, gameDatabase.listBossTestingReviews());
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/boss-testing/reviews" && request.method === "POST") {
|
||||
const token = bearerToken(request);
|
||||
const account = token ? gameDatabase.authenticate(token) : null;
|
||||
if (!allowReportRequest(account?.id ?? request.socket.remoteAddress)) {
|
||||
throw new GameDatabaseError("Too many updates. Please wait a minute and try again.", 429, "rate_limited");
|
||||
}
|
||||
json(response, 200, { review: gameDatabase.saveBossTestingReview(account, await readJson(request, 8 * 1024)) });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/dungeon-reports" && request.method === "GET") {
|
||||
json(response, 200, gameDatabase.listDungeonReports({
|
||||
status: url.searchParams.get("status"),
|
||||
|
||||
+11
-5
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { useShellStore } from "./app/shellStore";
|
||||
import "./ui/uiLayout.css";
|
||||
import { DualDisplayFrame } from "./components/DualDisplayFrame";
|
||||
import { installInput, releasePointerLock } from "./game/inputManager";
|
||||
@@ -7,7 +8,8 @@ import { connectDpsMeter } from "./game/dpsMeterStore";
|
||||
import { useCombatStore } from "./game/combatStore";
|
||||
import { releaseDungeonGltfCache } from "./game/dungeonAssetCache";
|
||||
import { clearDungeonRuntimeCaches } from "./game/dungeonRuntimeReset";
|
||||
import { requireDungeonDefinition } from "./game/dungeonRegistry";
|
||||
import { bossTestingSourceDungeonId, clearBossTestingDungeons, requireDungeonDefinition } from "./game/dungeonRegistry";
|
||||
import { BossTestingBridge } from "./game/BossTestingBridge";
|
||||
import { PartyBridge } from "./game/PartyBridge";
|
||||
import { usePartyStore } from "./game/partyStore";
|
||||
import { useGameStore } from "./game/store";
|
||||
@@ -24,9 +26,7 @@ import { DungeonRunBridge } from "./game/DungeonRunBridge";
|
||||
import { DungeonRunResults } from "./ui/DungeonRunResults";
|
||||
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
|
||||
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
|
||||
import { DungeonInteractionPrompt } from "./ui/DungeonInteractionPrompt";
|
||||
import { UiLayoutEditor } from "./ui/UiLayoutEditor";
|
||||
import { EditableHudElement } from "./ui/EditableHudElement";
|
||||
import { activeUiLayout, currentUiDevice } from "./game/uiLayoutStore";
|
||||
import { useUiLayoutStore } from "./game/uiLayoutStore";
|
||||
import { profileInputBindings } from "./game/keybindPreferences";
|
||||
@@ -39,7 +39,7 @@ function InputBridge() {
|
||||
|
||||
useEffect(() => installInput({
|
||||
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
|
||||
inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results"].includes(useGameStore.getState().overlay ?? ""),
|
||||
inputSuspended: () => ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
|
||||
getBindings: (device) => {
|
||||
const preferences = useUiLayoutStore.getState().preferences;
|
||||
const bindings = profileInputBindings(preferences, device);
|
||||
@@ -103,6 +103,12 @@ export default function GameRuntime() {
|
||||
clearDungeonRuntimeCaches();
|
||||
releaseDungeonGltfCache(activeDungeon);
|
||||
releaseShelvedGameRuntime();
|
||||
const sourceDungeonId = bossTestingSourceDungeonId(activeDungeon.id);
|
||||
if (sourceDungeonId) {
|
||||
useGameStore.setState({ activeDungeonId: sourceDungeonId, bossTest: null, gmTestMode: false });
|
||||
useShellStore.setState({ activeDungeonId: sourceDungeonId });
|
||||
}
|
||||
clearBossTestingDungeons();
|
||||
}, []);
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
@@ -118,10 +124,10 @@ export default function GameRuntime() {
|
||||
<CombatBridge />
|
||||
<PartyBridge />
|
||||
<DungeonRunBridge />
|
||||
<BossTestingBridge />
|
||||
<ShelvedGameBridges />
|
||||
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
|
||||
<Hud />
|
||||
<EditableHudElement id="interaction"><DungeonInteractionPrompt /></EditableHudElement>
|
||||
<MapPanel />
|
||||
<GameplayPanels />
|
||||
<PauseMenu />
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
createLocalAccount,
|
||||
createOfflineSession,
|
||||
listCharacters,
|
||||
loadAccountBossCards,
|
||||
saveAccountBossCards,
|
||||
replaceCharactersForOwner,
|
||||
deleteCharacter,
|
||||
loadCombatPresentationSettings,
|
||||
loginLocalAccount,
|
||||
restoreSession,
|
||||
@@ -12,7 +16,6 @@ import {
|
||||
updateCharacterEquipment,
|
||||
updateCharacterDungeonRunCheckpoint,
|
||||
updateCharacterInventory,
|
||||
updateCharacterBossCards,
|
||||
updateCharacterManastormProgress,
|
||||
updateCharacterProgression,
|
||||
updateCharacterSettings,
|
||||
@@ -213,7 +216,7 @@ describe("local profile repository", () => {
|
||||
}));
|
||||
expect(loadCombatPresentationSettings("offline-roster", storage))
|
||||
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
|
||||
}
|
||||
|
||||
const legacyV3Storage = new MemoryStorage();
|
||||
@@ -382,18 +385,58 @@ describe("local profile repository", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("persists card copies and claim receipts per character across roster reloads", () => {
|
||||
it("persists account card copies independently of character creation and deletion", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const offline = createOfflineSession();
|
||||
const draft = { ownerId: offline.ownerId, name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } };
|
||||
const character = createCharacter(draft, storage).character!;
|
||||
const other = createCharacter({ ...draft, name: "Other" }, storage).character!;
|
||||
const bossCards = { counts: { "wow:lady anacondra": { normal: 7, epic: 1 } }, lastClaims: { spawn: "kill:7" } };
|
||||
expect(updateCharacterBossCards(offline.ownerId, character.id, bossCards, storage)?.bossCards).toEqual(bossCards);
|
||||
expect(saveAccountBossCards(offline.ownerId, bossCards, storage)).toEqual(bossCards);
|
||||
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
|
||||
expect(loadAccountBossCards("different-account", storage)).toEqual({ counts: {}, lastClaims: {} });
|
||||
const roster = listCharacters(offline.ownerId, storage);
|
||||
expect(roster.find((entry) => entry.id === character.id)?.bossCards).toEqual(bossCards);
|
||||
expect(roster.find((entry) => entry.id === other.id)?.bossCards).toEqual({ counts: {}, lastClaims: {} });
|
||||
expect(roster.find((entry) => entry.id === character.id)?.bossCards).toBeUndefined();
|
||||
expect(roster.find((entry) => entry.id === other.id)?.bossCards).toBeUndefined();
|
||||
expect(roster.find((entry) => entry.id === character.id)?.inventory).toEqual([]);
|
||||
deleteCharacter(offline.ownerId, character.id, storage);
|
||||
deleteCharacter(offline.ownerId, other.id, storage);
|
||||
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
|
||||
createCharacter({ ...draft, name: "Newcomer" }, storage);
|
||||
expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards);
|
||||
});
|
||||
|
||||
it("migrates and sums all legacy character logs once while retaining the newest receipts", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const draft = { ownerId: "one", name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } };
|
||||
const first = createCharacter(draft, storage).character!;
|
||||
const second = createCharacter({ ...draft, name: "Other" }, storage).character!;
|
||||
const separate = createCharacter({ ...draft, ownerId: "two" }, storage).character!;
|
||||
const legacyCharacters = [
|
||||
{ ...first, bossCards: { counts: { boss: { normal: 7, epic: 1 } }, lastClaims: { spawn: "100:1:spawn" } } },
|
||||
{ ...second, bossCards: { counts: { boss: { normal: 3, rare: 2 } }, lastClaims: { spawn: "200:2:spawn" } } },
|
||||
];
|
||||
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||
version: 6, accounts: [], rosters: { one: legacyCharacters, two: [{ ...separate, bossCards: { counts: { boss: { normal: 4 } }, lastClaims: {} } }] },
|
||||
}));
|
||||
const expected = { counts: { boss: { normal: 10, rare: 2, epic: 1 } }, lastClaims: { spawn: "200:2:spawn" } };
|
||||
expect(loadAccountBossCards("one", storage)).toEqual(expected);
|
||||
expect(loadAccountBossCards("one", storage)).toEqual(expected);
|
||||
expect(loadAccountBossCards("two", storage).counts.boss?.normal).toBe(4);
|
||||
const database = JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!);
|
||||
expect(database.version).toBe(7);
|
||||
expect(database.rosters.one.every((entry: { bossCards?: unknown }) => entry.bossCards === undefined)).toBe(true);
|
||||
// An old cloud roster can be downloaded again without repeating the migration.
|
||||
replaceCharactersForOwner("one", legacyCharacters, storage);
|
||||
replaceCharactersForOwner("one", legacyCharacters, storage);
|
||||
expect(loadAccountBossCards("one", storage)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("restores account logs from cloud without overwriting higher cached counts", () => {
|
||||
const storage = new MemoryStorage();
|
||||
saveAccountBossCards("one", { counts: { boss: { normal: 8 } }, lastClaims: { spawn: "200:2:spawn" } }, storage);
|
||||
replaceCharactersForOwner("one", [], storage, { counts: { boss: { normal: 7, rare: 1 } }, lastClaims: { spawn: "100:1:spawn" } });
|
||||
expect(loadAccountBossCards("one", storage)).toEqual({ counts: { boss: { normal: 8, rare: 1 } }, lastClaims: { spawn: "200:2:spawn" } });
|
||||
});
|
||||
|
||||
it("persists threat-meter visibility and position per character", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
import { normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
|
||||
import { mergeBossCardCollections, normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
|
||||
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
|
||||
import {
|
||||
contentCategoryForClass,
|
||||
@@ -62,9 +62,10 @@ interface LocalAccount {
|
||||
}
|
||||
|
||||
interface ProfileDatabase {
|
||||
version: 6;
|
||||
version: 7;
|
||||
accounts: LocalAccount[];
|
||||
rosters: Record<string, CharacterProfile[]>;
|
||||
bossCardsByOwner: Record<string, BossCardCollection>;
|
||||
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
|
||||
uiPreferencesByOwner?: Record<string, UiPreferences>;
|
||||
}
|
||||
@@ -105,7 +106,7 @@ function browserSessionStorage(): StorageLike | null {
|
||||
}
|
||||
|
||||
function emptyDatabase(): ProfileDatabase {
|
||||
return { version: 6, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
|
||||
return { version: 7, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} };
|
||||
}
|
||||
|
||||
function isCharacterProfile(value: unknown): value is CharacterProfile {
|
||||
@@ -121,6 +122,9 @@ function isCharacterProfile(value: unknown): value is CharacterProfile {
|
||||
}
|
||||
|
||||
function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
const profile = { ...value };
|
||||
// Cards now belong to the owner; legacy fields are consumed before normalization.
|
||||
delete profile.bossCards;
|
||||
const legacy = value as CharacterProfile & {
|
||||
actionBindings?: unknown;
|
||||
experience?: unknown;
|
||||
@@ -180,7 +184,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
: undefined;
|
||||
const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint);
|
||||
return {
|
||||
...value,
|
||||
...profile,
|
||||
level,
|
||||
experience,
|
||||
talentRanks,
|
||||
@@ -191,7 +195,6 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
actionLoadouts,
|
||||
inventory,
|
||||
equipment,
|
||||
bossCards: normalizeBossCardCollection(value.bossCards),
|
||||
manastormProgress,
|
||||
dungeonRunCheckpoint,
|
||||
settings,
|
||||
@@ -205,10 +208,11 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
version?: number;
|
||||
accounts?: unknown;
|
||||
rosters?: unknown;
|
||||
bossCardsByOwner?: unknown;
|
||||
presentationSettingsByOwner?: unknown;
|
||||
uiPreferencesByOwner?: unknown;
|
||||
}) | null;
|
||||
if (!parsed || ![1, 2, 3, 4, 5, 6].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
if (!parsed || ![1, 2, 3, 4, 5, 6, 7].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
return emptyDatabase();
|
||||
}
|
||||
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
|
||||
@@ -220,12 +224,19 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
&& typeof account.verifier.salt === "string"
|
||||
&& typeof account.verifier.digest === "string",
|
||||
));
|
||||
const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record<string, unknown>).map(([ownerId, roster]) => [
|
||||
ownerId,
|
||||
Array.isArray(roster)
|
||||
? roster.filter(isCharacterProfile).map(normalizeCharacterProfile).slice(0, MAX_CHARACTERS)
|
||||
: [],
|
||||
]));
|
||||
const rawCards = parsed.bossCardsByOwner && typeof parsed.bossCardsByOwner === "object" && !Array.isArray(parsed.bossCardsByOwner)
|
||||
? parsed.bossCardsByOwner : {};
|
||||
const bossCardsByOwner = Object.fromEntries(Object.entries(rawCards).map(([owner, cards]) => [owner, normalizeBossCardCollection(cards)]));
|
||||
let cardsMigrated = false;
|
||||
const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record<string, unknown>).map(([ownerId, roster]) => {
|
||||
const characters = Array.isArray(roster) ? roster.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : [];
|
||||
if (characters.some((character) => character.bossCards !== undefined)) {
|
||||
const legacyCards = mergeBossCardCollections(characters.map((character) => character.bossCards), "sum");
|
||||
bossCardsByOwner[ownerId] = mergeBossCardCollections([bossCardsByOwner[ownerId], legacyCards]);
|
||||
cardsMigrated = true;
|
||||
}
|
||||
return [ownerId, characters.map(normalizeCharacterProfile)];
|
||||
}));
|
||||
const rawPresentationSettings = parsed.presentationSettingsByOwner
|
||||
&& typeof parsed.presentationSettingsByOwner === "object"
|
||||
&& !Array.isArray(parsed.presentationSettingsByOwner)
|
||||
@@ -259,8 +270,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
);
|
||||
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
|
||||
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
|
||||
const database: ProfileDatabase = { version: 6, accounts, rosters, presentationSettingsByOwner, uiPreferencesByOwner };
|
||||
if (parsed.version !== 6 || presentationSettingsMigrated) writeDatabase(database, storage);
|
||||
const database: ProfileDatabase = { version: 7, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
|
||||
if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated) writeDatabase(database, storage);
|
||||
return database;
|
||||
} catch {
|
||||
return emptyDatabase();
|
||||
@@ -270,7 +281,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void {
|
||||
storage?.setItem(DATABASE_KEY, JSON.stringify(database));
|
||||
if (!storage || !rosterChangeListener) return;
|
||||
for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
|
||||
for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.bossCardsByOwner), ...Object.keys(database.uiPreferencesByOwner ?? {})])) {
|
||||
rosterChangeListener(ownerId, database.rosters[ownerId] ?? []);
|
||||
}
|
||||
}
|
||||
@@ -452,15 +463,19 @@ export function replaceCharactersForOwner(
|
||||
ownerId: string,
|
||||
characters: unknown,
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
accountBossCards?: unknown,
|
||||
): CharacterProfile[] {
|
||||
if (!storage) return [];
|
||||
const database = readDatabase(storage);
|
||||
database.rosters[ownerId] = Array.isArray(characters)
|
||||
? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS).map((character) => normalizeCharacterProfile({
|
||||
...character,
|
||||
ownerId,
|
||||
}))
|
||||
: [];
|
||||
const roster = Array.isArray(characters) ? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : [];
|
||||
const importedCards = accountBossCards === undefined
|
||||
? mergeBossCardCollections(roster.map((character) => character.bossCards), "sum")
|
||||
: normalizeBossCardCollection(accountBossCards);
|
||||
database.bossCardsByOwner[ownerId] = mergeBossCardCollections([database.bossCardsByOwner[ownerId], importedCards]);
|
||||
database.rosters[ownerId] = roster.map((character) => normalizeCharacterProfile({
|
||||
...character,
|
||||
ownerId,
|
||||
}));
|
||||
writeDatabase(database, storage);
|
||||
return listCharacters(ownerId, storage);
|
||||
}
|
||||
@@ -730,22 +745,24 @@ export function updateCharacterEquipment(
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function updateCharacterBossCards(
|
||||
export function loadAccountBossCards(
|
||||
ownerId: string,
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): BossCardCollection {
|
||||
return normalizeBossCardCollection(readDatabase(storage).bossCardsByOwner[ownerId]);
|
||||
}
|
||||
|
||||
export function saveAccountBossCards(
|
||||
ownerId: string,
|
||||
characterId: string,
|
||||
bossCards: BossCardCollection,
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CharacterProfile | null {
|
||||
if (!storage) return null;
|
||||
): BossCardCollection {
|
||||
const normalized = normalizeBossCardCollection(bossCards);
|
||||
if (!storage) return normalized;
|
||||
const database = readDatabase(storage);
|
||||
let updated: CharacterProfile | null = null;
|
||||
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
||||
if (character.id !== characterId) return character;
|
||||
updated = normalizeCharacterProfile({ ...character, bossCards });
|
||||
return updated;
|
||||
});
|
||||
if (updated) writeDatabase(database, storage);
|
||||
return updated;
|
||||
database.bossCardsByOwner[ownerId] = normalized;
|
||||
writeDatabase(database, storage);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function updateCharacterSettings(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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; }
|
||||
},
|
||||
}));
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -16,6 +16,8 @@ export interface DungeonReport {
|
||||
readonly updatedAt: number;
|
||||
readonly updatedBy: string | null;
|
||||
readonly revision: number;
|
||||
readonly bossKey?: string;
|
||||
readonly bossName?: string;
|
||||
}
|
||||
|
||||
export interface DungeonReportSnapshot {
|
||||
@@ -40,10 +42,11 @@ export function fetchDungeonReports(): Promise<DungeonReportSnapshot> {
|
||||
return requestOnlineJson<DungeonReportSnapshot>("/api/dungeon-reports");
|
||||
}
|
||||
|
||||
export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string): Promise<DungeonReport> {
|
||||
export async function submitDungeonReport(session: PlayerSession | null, dungeonId: string, comment: string,
|
||||
boss?: { readonly bossKey: string; readonly bossName: string }): Promise<DungeonReport> {
|
||||
const response = await requestOnlineJson<{ report: DungeonReport }>("/api/dungeon-reports", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ dungeonId, comment }),
|
||||
body: JSON.stringify({ dungeonId, comment, ...boss }),
|
||||
}, session?.kind === "account" ? session.accessToken : undefined);
|
||||
return response.report;
|
||||
}
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
replaceCharactersForOwner,
|
||||
setRosterChangeListener,
|
||||
loadUiPreferences,
|
||||
loadAccountBossCards,
|
||||
saveUiPreferences,
|
||||
type AccountResult,
|
||||
} from "./accountRepository";
|
||||
@@ -23,7 +24,7 @@ interface AuthResponse {
|
||||
interface CloudSaveResponse {
|
||||
revision: number;
|
||||
updatedAt: number | null;
|
||||
data: { version?: number; characters?: unknown; uiPreferences?: unknown };
|
||||
data: { version?: number; characters?: unknown; uiPreferences?: unknown; bossCards?: unknown };
|
||||
}
|
||||
|
||||
interface MeResponse {
|
||||
@@ -106,11 +107,12 @@ function flushCloudSave(): void {
|
||||
pendingCharacters = null;
|
||||
if (!session?.accessToken || !characters) return;
|
||||
const uiPreferences = loadUiPreferences(session.ownerId);
|
||||
const bossCards = loadAccountBossCards(session.ownerId);
|
||||
const sequence = saveSequence;
|
||||
saveQueue = saveQueue.then(async () => {
|
||||
await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ data: { version: 1, characters, uiPreferences } }),
|
||||
body: JSON.stringify({ data: { version: 2, characters, uiPreferences, bossCards } }),
|
||||
}, session.accessToken);
|
||||
if (activeSession?.ownerId === session.ownerId && sequence === saveSequence) useCloudSaveStatus.setState({ state: "synced", message: "Saved to your account." });
|
||||
}).catch((error) => {
|
||||
@@ -149,12 +151,18 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise<Char
|
||||
activateOnlineSession(session);
|
||||
const cloud = await requestOnlineJson<CloudSaveResponse>("/api/cloud-save", {}, session.accessToken);
|
||||
suppressUpload = true;
|
||||
let characters: CharacterProfile[];
|
||||
try {
|
||||
saveUiPreferences(session.ownerId, cloud.data?.uiPreferences);
|
||||
return replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? []);
|
||||
characters = replaceCharactersForOwner(session.ownerId, cloud.data?.characters ?? [], undefined, cloud.data?.bossCards);
|
||||
} finally {
|
||||
suppressUpload = false;
|
||||
}
|
||||
// Publish migrated logs even if the player has not earned another card yet.
|
||||
if (JSON.stringify(cloud.data?.bossCards) !== JSON.stringify(loadAccountBossCards(session.ownerId))) {
|
||||
queueCloudSave(session.ownerId, characters);
|
||||
}
|
||||
return characters;
|
||||
}
|
||||
|
||||
export async function refreshOnlineSession(session: PlayerSession): Promise<PlayerSession> {
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
|
||||
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(6);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
|
||||
});
|
||||
|
||||
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
|
||||
|
||||
+19
-15
@@ -5,13 +5,14 @@ import {
|
||||
createCharacter as persistCharacter,
|
||||
deleteCharacter as removeCharacter,
|
||||
listCharacters,
|
||||
loadAccountBossCards,
|
||||
loadCombatPresentationSettings,
|
||||
restoreSession as restoreStoredSession,
|
||||
saveSession,
|
||||
updateCharacterActionBindings,
|
||||
updateCharacterEquipment,
|
||||
updateCharacterInventory,
|
||||
updateCharacterBossCards,
|
||||
saveAccountBossCards as persistAccountBossCards,
|
||||
updateCharacterManastormProgress,
|
||||
updateCharacterDungeonRunCheckpoint,
|
||||
updateCharacterProgression,
|
||||
@@ -39,7 +40,7 @@ import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from
|
||||
import type { DungeonId } from "../game/dungeonRegistry";
|
||||
import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults";
|
||||
import type { InventoryItem } from "../game/lootTypes";
|
||||
import type { BossCardCollection } from "../game/bossCards";
|
||||
import { emptyBossCardCollection, type BossCardCollection } from "../game/bossCards";
|
||||
import type { EquipmentAssignments } from "../game/equipment";
|
||||
import type { GameplaySettings } from "../game/combatStore";
|
||||
import {
|
||||
@@ -70,6 +71,7 @@ export interface ShellState {
|
||||
phase: ShellPhase;
|
||||
session: PlayerSession | null;
|
||||
characters: CharacterProfile[];
|
||||
bossCards: BossCardCollection;
|
||||
selectedCharacterId: string | null;
|
||||
activeCharacter: CharacterProfile | null;
|
||||
draft: CharacterDraft;
|
||||
@@ -111,7 +113,7 @@ export interface ShellState {
|
||||
saveActiveProgression: (level: number, experience: number, talentRanks: Record<string, number>) => void;
|
||||
saveActiveActionBindings: (actionBindings: Readonly<Record<string, string | null>>) => void;
|
||||
saveActiveInventory: (inventory: readonly InventoryItem[]) => void;
|
||||
saveActiveBossCards: (bossCards: BossCardCollection) => void;
|
||||
saveAccountBossCards: (bossCards: BossCardCollection) => void;
|
||||
saveActiveEquipment: (equipment: EquipmentAssignments) => void;
|
||||
saveActiveSettings: (settings: GameplaySettings) => void;
|
||||
updateCombatPresentationSettings: (patch: Partial<CombatPresentationSettings>) => void;
|
||||
@@ -138,12 +140,13 @@ function defaultDraft(categoryId: ContentCategoryId = "wow"): CharacterDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "selectedCharacterId" | "combatPresentationSettings"> {
|
||||
function restoredState(): Pick<ShellState, "phase" | "session" | "characters" | "bossCards" | "selectedCharacterId" | "combatPresentationSettings"> {
|
||||
const session = restoreStoredSession();
|
||||
if (!session) return {
|
||||
phase: "login",
|
||||
session: null,
|
||||
characters: [],
|
||||
bossCards: emptyBossCardCollection(),
|
||||
selectedCharacterId: null,
|
||||
combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
};
|
||||
@@ -152,6 +155,7 @@ function restoredState(): Pick<ShellState, "phase" | "session" | "characters" |
|
||||
phase: "characters",
|
||||
session,
|
||||
characters,
|
||||
bossCards: loadAccountBossCards(session.ownerId),
|
||||
selectedCharacterId: characters[0]?.id ?? null,
|
||||
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
|
||||
};
|
||||
@@ -174,6 +178,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
phase: "characters",
|
||||
session,
|
||||
characters,
|
||||
bossCards: loadAccountBossCards(session.ownerId),
|
||||
selectedCharacterId: characters[0]?.id ?? null,
|
||||
activeCharacter: null,
|
||||
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
|
||||
@@ -189,6 +194,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
phase: "login",
|
||||
session: null,
|
||||
characters: [],
|
||||
bossCards: emptyBossCardCollection(),
|
||||
selectedCharacterId: null,
|
||||
activeCharacter: null,
|
||||
draft: defaultDraft(),
|
||||
@@ -276,6 +282,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({
|
||||
phase: "characters",
|
||||
characters,
|
||||
bossCards: loadAccountBossCards(session.ownerId),
|
||||
selectedCharacterId: result.character.id,
|
||||
draft: defaultDraft(),
|
||||
notice: `${result.character.name} is ready for the expedition.`,
|
||||
@@ -439,7 +446,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
returnToMainMenu: () => {
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
|
||||
if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame();
|
||||
const { session, characters, activeCharacter, selectedCharacterId } = get();
|
||||
if (!session) {
|
||||
@@ -477,7 +484,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
},
|
||||
enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID),
|
||||
returnToCharacters: () => {
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
|
||||
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
|
||||
const session = get().session;
|
||||
if (!session) {
|
||||
set({ phase: "login", activeCharacter: null });
|
||||
@@ -488,6 +495,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
set({
|
||||
phase: "characters",
|
||||
characters,
|
||||
bossCards: loadAccountBossCards(session.ownerId),
|
||||
selectedCharacterId: characters.some((character) => character.id === currentId)
|
||||
? currentId
|
||||
: characters[0]?.id ?? null,
|
||||
@@ -538,15 +546,10 @@ export const useShellStore = create<ShellState>((set, get) => ({
|
||||
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
|
||||
}));
|
||||
},
|
||||
saveActiveBossCards: (bossCards) => {
|
||||
const { session, activeCharacter } = get();
|
||||
if (!session || !activeCharacter) return;
|
||||
const updated = updateCharacterBossCards(session.ownerId, activeCharacter.id, bossCards);
|
||||
if (!updated) return;
|
||||
set((state) => ({
|
||||
activeCharacter: updated,
|
||||
characters: state.characters.map((character) => character.id === updated.id ? updated : character),
|
||||
}));
|
||||
saveAccountBossCards: (bossCards) => {
|
||||
const { session } = get();
|
||||
if (!session) return;
|
||||
set({ bossCards: persistAccountBossCards(session.ownerId, bossCards) });
|
||||
},
|
||||
saveActiveEquipment: (equipment) => {
|
||||
const { session, activeCharacter } = get();
|
||||
@@ -651,6 +654,7 @@ if (restored.session?.accessToken) {
|
||||
useShellStore.setState({
|
||||
session: refreshedSession,
|
||||
characters,
|
||||
bossCards: loadAccountBossCards(refreshedSession.ownerId),
|
||||
selectedCharacterId: characters.some((character) => character.id === useShellStore.getState().selectedCharacterId)
|
||||
? useShellStore.getState().selectedCharacterId
|
||||
: characters[0]?.id ?? null,
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface CharacterProfile {
|
||||
actionLoadouts?: Record<string, Record<string, string | null>>;
|
||||
inventory: InventoryItem[];
|
||||
equipment: EquipmentAssignments;
|
||||
/** Legacy character log, read only during migration to the account collection. */
|
||||
bossCards?: BossCardCollection;
|
||||
/** Present on normalized and newly created profiles; optional for legacy callers. */
|
||||
manastormProgress?: ManastormProgress;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
|
||||
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { resolveDisplayLayout } from "../platform/displayLayout";
|
||||
import { THOR_DISPLAYS } from "../platform/thorDisplayProfile";
|
||||
import { requestDisplaySurface, subscribeDisplaySurface, type DisplaySurface } from "../platform/displayRouting";
|
||||
|
||||
interface DualDisplayFrameProps {
|
||||
@@ -136,12 +137,12 @@ export function DualDisplayFrame({ top, bottom, contextLabel = "Companion", onAc
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="thor-preview-frame">
|
||||
<div className="thor-preview-label"><b>Main display</b><span>960 x 540 CSS / 1920 x 1080</span></div>
|
||||
<div className="thor-preview-top">{top}</div>
|
||||
<div className="thor-preview-frame" style={{ width: THOR_DISPLAYS.top.cssWidth + 80 }}>
|
||||
<div className="thor-preview-label"><b>Top · 6 inches</b><span>1920 × 1080 · {THOR_DISPLAYS.top.widthMm.toFixed(1)} × {THOR_DISPLAYS.top.heightMm.toFixed(1)} mm</span></div>
|
||||
<div className="thor-preview-top" style={{ width: THOR_DISPLAYS.top.cssWidth, height: THOR_DISPLAYS.top.cssHeight }}>{top}</div>
|
||||
<div className="thor-preview-hinge"><i /><b>AYN THOR</b><i /></div>
|
||||
<div className="thor-preview-label"><b>Companion display</b><span>620 x 540 CSS / 1240 x 1080</span></div>
|
||||
<div className="thor-preview-bottom">{bottom}</div>
|
||||
<div className="thor-preview-label"><b>Bottom · 3.92 inches</b><span>1240 × 1080 · {THOR_DISPLAYS.bottom.widthMm.toFixed(1)} × {THOR_DISPLAYS.bottom.heightMm.toFixed(1)} mm</span></div>
|
||||
<div className="thor-preview-bottom" style={{ width: THOR_DISPLAYS.bottom.cssWidth, height: THOR_DISPLAYS.bottom.cssHeight }}>{bottom}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { activateCharacterUi } from "./uiLayoutStore";
|
||||
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
||||
import { useGameStore } from "./store";
|
||||
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
|
||||
@@ -40,7 +41,8 @@ export function CombatBridge() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
useCombatStore.getState().initializeCharacter({ ...character, role: activeRole });
|
||||
useCombatStore.getState().initializeCharacter({ ...character, bossCards: useShellStore.getState().bossCards, role: activeRole });
|
||||
activateCharacterUi(character);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
if (gameMode === "manastorm") {
|
||||
activateManastormActiveSpellLoadout(
|
||||
@@ -66,12 +68,14 @@ export function CombatBridge() {
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
const characterId = character.id;
|
||||
const ownerId = useShellStore.getState().session?.ownerId;
|
||||
const unsubscribe = useCombatStore.subscribe((state, previousState) => {
|
||||
// Save APIs intentionally target the active character. Avoid persisting a
|
||||
// final combat notification during the render-to-effect-cleanup window
|
||||
// after Character Select has already replaced the active character.
|
||||
const shell = useShellStore.getState();
|
||||
if (shell.activeCharacter?.id !== characterId) return;
|
||||
if (shell.activeCharacter?.id !== characterId || shell.session?.ownerId !== ownerId) return;
|
||||
if (useGameStore.getState().bossTest) return;
|
||||
|
||||
// Persisted collections are replaced immutably by combat actions. A
|
||||
// reference/primitive comparison therefore detects the same changes as
|
||||
@@ -101,7 +105,7 @@ export function CombatBridge() {
|
||||
shell.saveActiveInventory(state.inventory);
|
||||
}
|
||||
if (state.bossCards !== previousState.bossCards) {
|
||||
shell.saveActiveBossCards(state.bossCards);
|
||||
shell.saveAccountBossCards(state.bossCards);
|
||||
}
|
||||
if (state.equipment !== previousState.equipment) {
|
||||
shell.saveActiveEquipment(state.equipment);
|
||||
|
||||
@@ -246,6 +246,7 @@ function applyRelayEvent(
|
||||
}
|
||||
|
||||
export function OnlineSessionBridge() {
|
||||
const bossTest = useGameStore((state) => state.bossTest);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const activeActivity = useOnlineGroupStore((state) => state.activeActivity);
|
||||
const group = useOnlineGroupStore((state) => state.snapshot.group);
|
||||
@@ -256,7 +257,7 @@ export function OnlineSessionBridge() {
|
||||
const applyingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.accessToken || !activeActivity || !group) {
|
||||
if (bossTest || !session?.accessToken || !activeActivity || !group) {
|
||||
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||
reset();
|
||||
return undefined;
|
||||
@@ -362,7 +363,7 @@ export function OnlineSessionBridge() {
|
||||
configureOnlineSessionRuntime({ active: false, authority: true, localAccountId: null });
|
||||
reset();
|
||||
};
|
||||
}, [accept, activeActivity?.id, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]);
|
||||
}, [accept, activeActivity?.id, bossTest, fail, group?.id, group?.leaderAccountId, reset, session?.accessToken, session?.ownerId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export async function runDungeonBossBenchmark(
|
||||
const dungeon = requireDungeonDefinition("ragefire-chasm");
|
||||
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)!;
|
||||
const entity = dungeon.entities[spawn.entityId];
|
||||
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, build.level, "normal", 1);
|
||||
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, build.level, "normal", 1, build.source);
|
||||
const specs = specializationsForClass(build.classId).filter((spec) => spec.role === build.role);
|
||||
const specialization = specs.find((spec) => spec.displayName === build.orientation) ?? specs[0];
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); resetCombatSpatialProvider();
|
||||
|
||||
@@ -49,6 +49,35 @@ export function normalizeBossCardCollection(value: unknown): BossCardCollection
|
||||
return { counts, lastClaims };
|
||||
}
|
||||
|
||||
/** Sum independent legacy character logs; max reconciles copies of the same account log. */
|
||||
export function mergeBossCardCollections(collections: readonly unknown[], mode: "sum" | "max" = "max"): BossCardCollection {
|
||||
const counts: Record<string, BossCardCounts> = Object.create(null);
|
||||
const lastClaims: Record<string, string> = Object.create(null);
|
||||
for (const value of collections) {
|
||||
const collection = normalizeBossCardCollection(value);
|
||||
for (const [bossKey, entry] of Object.entries(collection.counts)) {
|
||||
const merged = counts[bossKey] ??= {};
|
||||
for (const rarity of BOSS_CARD_RARITIES) {
|
||||
const count = entry[rarity];
|
||||
if (count === undefined) continue;
|
||||
merged[rarity] = mode === "sum"
|
||||
? Math.min(Number.MAX_SAFE_INTEGER, (merged[rarity] ?? 0) + count)
|
||||
: Math.max(merged[rarity] ?? 0, count);
|
||||
}
|
||||
}
|
||||
for (const [spawnId, claim] of Object.entries(collection.lastClaims)) {
|
||||
const previous = lastClaims[spawnId];
|
||||
if (!previous) { lastClaims[spawnId] = claim; continue; }
|
||||
if (/^\d+:/.test(previous) && /^\d+:/.test(claim)) {
|
||||
const [previousTime, previousRevision] = previous.split(":").map(Number);
|
||||
const [time, revision] = claim.split(":").map(Number);
|
||||
if (time > previousTime || (time === previousTime && revision > previousRevision)) lastClaims[spawnId] = claim;
|
||||
} else if (claim > previous) lastClaims[spawnId] = claim;
|
||||
}
|
||||
}
|
||||
return { counts, lastClaims };
|
||||
}
|
||||
|
||||
/** Independent bonus rolls; Normal is always awarded, including with all three bonuses. */
|
||||
export function rollBossCards(random: () => number = Math.random): readonly BossCardRarity[] {
|
||||
return BOSS_CARD_RARITIES.filter((rarity) => {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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: [] } },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2631,15 +2631,16 @@ function damageMobInWork(
|
||||
const health = mob.health - applied;
|
||||
const killed = health <= 0;
|
||||
const canPlayWoundAnimation = !killed && now >= mob.nextWoundAnimationAt;
|
||||
const canAward = killed && !mob.experienceGranted;
|
||||
const testing = Boolean(useGameStore.getState().bossTest);
|
||||
const canAward = killed && !mob.experienceGranted && !testing;
|
||||
const beforeLevel = work.progression.level;
|
||||
const pendingLoot = killed && mob.lootSourceId
|
||||
const pendingLoot = testing ? [] : killed && mob.lootSourceId
|
||||
? rollBossLoot(mob.lootSourceId, beforeLevel, {
|
||||
now,
|
||||
bonusItemChance: mob.bonusLootChance,
|
||||
})
|
||||
: mob.pendingLoot;
|
||||
const card = killed && mob.boss
|
||||
const card = killed && mob.boss && !testing
|
||||
? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name)
|
||||
: null;
|
||||
const bossCardDrop = card ? {
|
||||
@@ -2648,7 +2649,7 @@ function damageMobInWork(
|
||||
claimId: `${now}:${mob.deathRevision + 1}:${mob.id}`,
|
||||
rarities: rollBossCards(),
|
||||
} satisfies BossCardDrop : mob.bossCardDrop;
|
||||
const hasLoot = killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot;
|
||||
const hasLoot = testing ? false : killed ? mob.hasLoot || pendingLoot.length > 0 || Boolean(bossCardDrop) : mob.hasLoot;
|
||||
const xpAwarded = canAward ? grantMobExperience(classId, work, mob.xpReward) : 0;
|
||||
engageMobGroupInWork(work, id, now, source.actorId);
|
||||
const engagedMob = work.mobs[id] ?? mob;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { BASE_CLASS_IDS, COA_CLASS_IDS } from "../app/characterCatalog";
|
||||
import { BASE_CLASS_IDS, COA_CLASS_IDS, ROM_CLASS_IDS } from "../app/characterCatalog";
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
import { runDungeonBossBenchmark } from "./benchmark/dungeonBossBenchmark";
|
||||
import { BENCHMARK_POLICY_ID } from "./benchmark/benchmarkController";
|
||||
@@ -14,16 +14,16 @@ const samples: unknown[] = [];
|
||||
afterAll(() => {
|
||||
if (process.env.BOSS_BALANCE_REPORT !== "1") return;
|
||||
mkdirSync("artifacts/boss-difficulty", { recursive: true });
|
||||
writeFileSync("artifacts/boss-difficulty/wow-coa-balance.json", `${JSON.stringify({
|
||||
writeFileSync("artifacts/boss-difficulty/wow-rom-coa-balance.json", `${JSON.stringify({
|
||||
difficulty: 1, dungeon: dungeon.id, durationLimitSeconds: 60,
|
||||
targets: { ordinary: 25, final: 45 }, policy: BENCHMARK_POLICY_ID, gear: "resolved starter heirlooms", samples,
|
||||
}, null, 2)}\n`);
|
||||
});
|
||||
|
||||
describe("default boss difficulty with WoW and CoA classes", () => {
|
||||
describe("default boss difficulty across WoW, RoM, and CoA classes", () => {
|
||||
for (const level of [8, 20, 40, 80]) {
|
||||
it(`benchmarks every class-role build at level ${level}`, async () => {
|
||||
const builds = await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...COA_CLASS_IDS]);
|
||||
const builds = await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...ROM_CLASS_IDS, ...COA_CLASS_IDS]);
|
||||
const tooFast: string[] = [];
|
||||
for (const build of builds) {
|
||||
// Let the test worker deliver progress between CPU-heavy simulations.
|
||||
@@ -43,7 +43,7 @@ describe("default boss difficulty with WoW and CoA classes", () => {
|
||||
|
||||
for (const level of [8, 80]) {
|
||||
it(`records healer outcomes with the final boss attacking at level ${level}`, async () => {
|
||||
const builds = (await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...COA_CLASS_IDS]))
|
||||
const builds = (await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...ROM_CLASS_IDS, ...COA_CLASS_IDS]))
|
||||
.filter((build) => build.role === "healer");
|
||||
for (const build of builds) {
|
||||
const last = await runDungeonBossBenchmark(build, final.id, "class-balance-2", true);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock
|
||||
import { subscribeCombatEvents } from "./combatEvents";
|
||||
import { bossDifficultyMultipliers } from "./dungeonDifficulty";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
|
||||
const dungeon = requireDungeonDefinition("ragefire-chasm");
|
||||
const final = finalDungeonBoss(dungeon)!;
|
||||
@@ -21,15 +22,16 @@ afterAll(() => {
|
||||
writeFileSync("artifacts/boss-difficulty/ragefire-balance.json", `${JSON.stringify({ samples }, null, 2)}\n`);
|
||||
});
|
||||
|
||||
function fight(bossId: string, level: number, difficulty: number, seed: string, mechanics = false, playerDamage = false) {
|
||||
function fight(bossId: string, level: number, difficulty: number, seed: string, mechanics = false, playerDamage = false,
|
||||
characterSource: ContentCategoryId = "rom", encounterDungeon = dungeon) {
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||
const entity = dungeon.entities[dungeon.staticSpawns.find((spawn) => spawn.id === bossId)!.entityId];
|
||||
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, level, "normal", difficulty);
|
||||
const entity = encounterDungeon.entities[encounterDungeon.staticSpawns.find((spawn) => spawn.id === bossId)!.entityId];
|
||||
const scaling = dungeonEnemyScaling(encounterDungeon, entity, bossId, level, "normal", difficulty, characterSource);
|
||||
const combat = useCombatStore.getState();
|
||||
const classId = playerDamage ? "rom-mage" : "rom-priest";
|
||||
combat.initializeCharacter({ classId, raceId: "rom-human", level });
|
||||
const classId = characterSource === "wow" ? (playerDamage ? "mage" : "priest") : (playerDamage ? "rom-mage" : "rom-priest");
|
||||
combat.initializeCharacter({ classId, raceId: characterSource === "wow" ? "human" : "rom-human", level });
|
||||
combat.setPlayerPosition([-18, 0, 0]);
|
||||
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health, playerDamage ? "damage" : "healer", 5, "rom");
|
||||
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health, playerDamage ? "damage" : "healer", 5, characterSource);
|
||||
combat.synchronizeActors();
|
||||
combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry,
|
||||
...scaling, attacks: entity.combat?.attacks, xpReward: 0 });
|
||||
@@ -40,7 +42,8 @@ function fight(bossId: string, level: number, difficulty: number, seed: string,
|
||||
}
|
||||
let firstDamage: number | undefined;
|
||||
let incomingDamage = 0; let healing = 0;
|
||||
const heal = abilitiesForClass("rom-priest").find((ability) => ability.name === "Urgent Heal")!;
|
||||
const heal = abilitiesForClass(classId).find((ability) => ability.target === "friendly"
|
||||
&& ability.effects.some((effect) => effect.kind === "heal") && (ability.unlockLevel ?? 1) <= level)!;
|
||||
const damage = abilitiesForClass(classId).find((ability) => ability.target === "hostile"
|
||||
&& ability.effects.some((effect) => effect.kind === "damage"))!;
|
||||
combat.selectMob(bossId);
|
||||
@@ -59,7 +62,8 @@ function fight(bossId: string, level: number, difficulty: number, seed: string,
|
||||
const injured = usePartyStore.getState().members.filter((member) => member.health > 0)
|
||||
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0];
|
||||
if (injured && injured.health < injured.maxHealth * .8 && !useCombatStore.getState().activeCast) {
|
||||
usePartyStore.getState().selectMember(injured.id); combat.castAbility(heal.id, undefined, now);
|
||||
usePartyStore.getState().selectMember(injured.id);
|
||||
combat.castAbility(heal.id, { targetActorId: injured.id, now });
|
||||
}
|
||||
}
|
||||
const mob = useCombatStore.getState().mobs[bossId];
|
||||
@@ -72,6 +76,39 @@ function fight(bossId: string, level: number, difficulty: number, seed: string,
|
||||
}
|
||||
|
||||
describe("dungeon boss durability and difficulty", () => {
|
||||
const stockades = requireDungeonDefinition("stormwind-stockade");
|
||||
const stockadesBoss = stockades.bosses[0];
|
||||
const stockadesEntity = stockades.entities[stockades.staticSpawns.find((entry) => entry.id === stockadesBoss.id)!.entityId];
|
||||
|
||||
it("gives each level-three Stockades party its source-calibrated health minimum", () => {
|
||||
const easy = dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "wow");
|
||||
expect(easy).toMatchObject({ level: 4, maxHealth: 1300 });
|
||||
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 10, "wow").maxHealth)
|
||||
.toBe(1300 * bossDifficultyMultipliers(10).health);
|
||||
// The dungeon catalog must not choose the companion power curve.
|
||||
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "rom").maxHealth).toBeCloseTo(7074.8);
|
||||
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "coa").maxHealth).toBe(10766);
|
||||
});
|
||||
|
||||
for (const level of [1, 3, 8, 20, 40, 80]) {
|
||||
it(`preserves WoW boss mechanic windows in Stockades at level ${level}`, () => {
|
||||
for (const seed of ["wow-health-1", "wow-health-2", "wow-health-3"]) {
|
||||
const ordinary = fight(stockadesBoss.id, level, 1, seed, false, false, "wow", stockades);
|
||||
const last = fight(finalDungeonBoss(stockades)!.id, level, 1, seed, false, false, "wow", stockades);
|
||||
samples.push({ level, seed, boss: ordinary, final: last });
|
||||
expect(ordinary.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(25);
|
||||
expect(last.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(45);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("allows real Stockades boss mechanics during a level-three WoW Priest party fight", () => {
|
||||
const result = fight(stockadesBoss.id, 3, 1, "wow-health-1", true, false, "wow", stockades);
|
||||
expect(result.seconds, JSON.stringify(result)).not.toBeNull();
|
||||
expect(result.seconds!).toBeLessThanOrEqual(180);
|
||||
expect(result.attacks).toBeGreaterThan(0);
|
||||
expect(result.incomingDamage).toBeGreaterThan(0);
|
||||
});
|
||||
it("increases boss health and damage at every slider step without changing trash", () => {
|
||||
const spawn = dungeon.staticSpawns.find((entry) => entry.id === final.id)!;
|
||||
const entity = dungeon.entities[spawn.entityId];
|
||||
|
||||
@@ -4,11 +4,41 @@ import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLe
|
||||
import { finalDungeonBoss } from "./dungeonRunStore";
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import type { PopulationEntityDefinition } from "./mobPopulation";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
|
||||
/** Shared by rendering and deterministic combat benchmarks; preserves authored stats above the floor. */
|
||||
const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [
|
||||
[2, 900],
|
||||
[4, 1_300],
|
||||
[9, 7_000],
|
||||
[21, 11_000],
|
||||
[41, 23_000],
|
||||
[80, 180_000],
|
||||
];
|
||||
|
||||
function interpolatedFloor(level: number, anchors: readonly (readonly [number, number])[]): number {
|
||||
const upperIndex = anchors.findIndex(([anchorLevel]) => level <= anchorLevel);
|
||||
if (upperIndex <= 0) return anchors[0][1];
|
||||
if (upperIndex < 0) return anchors[anchors.length - 1][1];
|
||||
const [lowerLevel, lowerHealth] = anchors[upperIndex - 1];
|
||||
const [upperLevel, upperHealth] = anchors[upperIndex];
|
||||
const progress = (level - lowerLevel) / (upperLevel - lowerLevel);
|
||||
return lowerHealth + (upperHealth - lowerHealth) * progress;
|
||||
}
|
||||
|
||||
function partyBossHealthFloor(level: number, characterSource: ContentCategoryId): number {
|
||||
if (characterSource === "wow") return interpolatedFloor(level, WOW_BOSS_HEALTH_FLOOR);
|
||||
const baseline = 1_000 + level * 1_200 + level * level * 22;
|
||||
// Each catalog is calibrated independently against its strongest canonical
|
||||
// class-role cohort so every fight retains the 25/45-second mechanic window.
|
||||
return baseline * (characterSource === "rom" ? 1.15 : 1.75);
|
||||
}
|
||||
|
||||
/** Campaign fights retain a durability floor; testing rooms use level-scaled authored stats. */
|
||||
export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: PopulationEntityDefinition,
|
||||
instanceId: string, characterLevel: number, difficultyId: string, bossDifficulty: number) {
|
||||
instanceId: string, characterLevel: number, difficultyId: string, bossDifficulty: number,
|
||||
characterSource: ContentCategoryId = "wow") {
|
||||
const boss = entity.kind === "boss";
|
||||
const testing = dungeon.id.startsWith("boss-testing:");
|
||||
const level = scaledDungeonEnemyLevel(characterLevel, boss);
|
||||
const authoredHealth = entity.combat?.healthMultiplier ?? 1;
|
||||
const authoredDamage = entity.combat?.damageMultiplier ?? 1;
|
||||
@@ -16,10 +46,12 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
|
||||
const native = entity.combat?.serverEntry ? creatureRuntimeBaseStats(entity.combat.serverEntry, level) : null;
|
||||
const originalHealth = (baseline ?? native?.health ?? (boss ? 260 + level * 65 : 72 + level * 22)) * authoredHealth;
|
||||
const multipliers = boss ? bossDifficultyMultipliers(bossDifficulty) : { health: 1, damage: 1 };
|
||||
const finalBoss = boss && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
|
||||
// Calibrated against active RoM, WoW and CoA class-role rotations with heirlooms.
|
||||
// Every class faces the same floor; the margin covers stronger CoA burst builds.
|
||||
const bossFloor = (1_000 + level * 1_200 + level * level * 22) * 1.75 * (finalBoss ? 1.8 : 1);
|
||||
return { level, maxHealth: (boss ? Math.max(originalHealth, bossFloor) : originalHealth) * multipliers.health,
|
||||
const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
|
||||
// Match durability to the character's game, including when crossing dungeon catalogs.
|
||||
const partyHealthFloor = partyBossHealthFloor(level, characterSource);
|
||||
const bossFloor = partyHealthFloor * (finalBoss ? 1.8 : 1);
|
||||
// A test room's sole objective must not promote every boss to a campaign finale
|
||||
// or replace low-level native health with the campaign's full-party minimum.
|
||||
return { level, maxHealth: (boss && !testing ? Math.max(originalHealth, bossFloor) : originalHealth) * multipliers.health,
|
||||
healthMultiplier: authoredHealth * multipliers.health, damageMultiplier: authoredDamage * multipliers.damage };
|
||||
}
|
||||
@@ -199,8 +199,27 @@ export type DungeonId = string;
|
||||
|
||||
export const DUNGEON_DEFINITIONS = Object.freeze(Object.values(DUNGEON_REGISTRY));
|
||||
|
||||
// Test rooms are session-only definitions; the shipped dungeon catalog stays immutable.
|
||||
const bossTestingDefinitions = new Map<string, { definition: DungeonDefinition; sourceDungeonId: string }>();
|
||||
|
||||
export function registerBossTestingDungeon(definition: DungeonDefinition, sourceDungeonId: string): void {
|
||||
if (!definition.id.startsWith("boss-testing:") || !DUNGEON_REGISTRY[sourceDungeonId]) {
|
||||
throw new Error("Invalid boss testing dungeon.");
|
||||
}
|
||||
bossTestingDefinitions.clear();
|
||||
bossTestingDefinitions.set(definition.id, { definition, sourceDungeonId });
|
||||
}
|
||||
|
||||
export function bossTestingSourceDungeonId(id: string): string | null {
|
||||
return bossTestingDefinitions.get(id)?.sourceDungeonId ?? null;
|
||||
}
|
||||
|
||||
export function clearBossTestingDungeons(): void {
|
||||
bossTestingDefinitions.clear();
|
||||
}
|
||||
|
||||
export function dungeonDefinitionById(id: string): DungeonDefinition | null {
|
||||
return DUNGEON_REGISTRY[id] ?? null;
|
||||
return bossTestingDefinitions.get(id)?.definition ?? DUNGEON_REGISTRY[id] ?? null;
|
||||
}
|
||||
|
||||
export function requireDungeonDefinition(id: string): DungeonDefinition {
|
||||
|
||||
@@ -5,8 +5,8 @@ import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
|
||||
import { resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore, type GameMode } from "./store";
|
||||
import { dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
|
||||
import { useGameStore, type BossTestSession, type GameMode } from "./store";
|
||||
import { bossTestingSourceDungeonId, dungeonCanEnter, dungeonDefinitionById, type DungeonId } from "./dungeonRegistry";
|
||||
import type { WorldSpawn } from "./dungeonTypes";
|
||||
import type { GmReviewTarget } from "./gmContentReview";
|
||||
import { shelvedDungeonSpawn } from "@shelvedDungeonSession";
|
||||
@@ -27,6 +27,7 @@ export interface DungeonSessionOptions {
|
||||
readonly gameMode?: GameMode;
|
||||
readonly spawn?: WorldSpawn;
|
||||
readonly gmTestMode?: boolean;
|
||||
readonly bossTest?: BossTestSession | null;
|
||||
readonly gmReviewTarget?: GmReviewTarget | null;
|
||||
/** Used by in-game encounter transitions so character progression and inventory survive. */
|
||||
readonly preserveCharacter?: boolean;
|
||||
@@ -47,7 +48,10 @@ export function activateDungeonSession(
|
||||
options: DungeonSessionOptions = {},
|
||||
): boolean {
|
||||
const definition = dungeonDefinitionById(dungeonId);
|
||||
if (!definition || !dungeonCanEnter(dungeonId) || !dungeonContentIsUsable(dungeonId)) {
|
||||
const testingSource = bossTestingSourceDungeonId(dungeonId);
|
||||
const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource);
|
||||
if (!definition || (!testing && !dungeonCanEnter(dungeonId))
|
||||
|| !dungeonContentIsUsable(testing ? testingSource! : dungeonId)) {
|
||||
return false;
|
||||
}
|
||||
if ((difficultyId !== undefined && !definition.difficultyVariants.some((variant) => variant.id === difficultyId))
|
||||
@@ -107,6 +111,7 @@ export function activateDungeonSession(
|
||||
gameMode: options.gameMode,
|
||||
spawn: shelvedDungeonSpawn(dungeonId, options.spawn),
|
||||
gmTestMode: options.gmTestMode,
|
||||
bossTest: options.bossTest,
|
||||
gmReviewTarget: options.gmReviewTarget,
|
||||
bossDifficulty,
|
||||
});
|
||||
@@ -128,6 +133,7 @@ export function restartActiveDungeonSession(): boolean {
|
||||
gameMode: game.gameMode,
|
||||
spawn: game.activeSpawn,
|
||||
gmTestMode: game.gmTestMode,
|
||||
bossTest: game.bossTest,
|
||||
gmReviewTarget: game.activeGmReviewTarget,
|
||||
preserveCharacter: true,
|
||||
bossDifficulty: game.activeBossDifficulty,
|
||||
|
||||
+14
-2
@@ -9,7 +9,15 @@ import { validBossDifficulty } from "./dungeonDifficulty";
|
||||
export type AssetStatus = "checking" | "loading" | "ready" | "proxy" | "error";
|
||||
export type InputMode = "keyboard" | "gamepad";
|
||||
export type GameMode = "dungeon" | "manastorm";
|
||||
export type GameOverlay = "pause" | "report-issue" | "party" | "inventory" | "boss-cards" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | "dungeon-results" | null;
|
||||
export type GameOverlay = "pause" | "boss-test-review" | "report-issue" | "party" | "inventory" | "boss-cards" | "map" | "boss-guide" | "spellbook" | "talents" | "bindings" | "options" | "ui-layout" | "keybinds" | "gm" | "dungeon-results" | null;
|
||||
|
||||
export interface BossTestSession {
|
||||
readonly key: string;
|
||||
readonly name: string;
|
||||
readonly sourceDungeonId: string;
|
||||
readonly entityId: string;
|
||||
readonly runtimeIds: readonly string[];
|
||||
}
|
||||
|
||||
interface PlayerSnapshot {
|
||||
position: Vector3Tuple;
|
||||
@@ -24,6 +32,7 @@ export interface GameState {
|
||||
activeBossDifficulty: number;
|
||||
gameMode: GameMode;
|
||||
gmTestMode: boolean;
|
||||
bossTest: BossTestSession | null;
|
||||
activeGmReviewTarget: GmReviewTarget | null;
|
||||
activeSpawn: WorldSpawn;
|
||||
sessionRevision: number;
|
||||
@@ -55,6 +64,7 @@ export interface GameState {
|
||||
readonly gameMode?: GameMode;
|
||||
readonly spawn?: WorldSpawn;
|
||||
readonly gmTestMode?: boolean;
|
||||
readonly bossTest?: BossTestSession | null;
|
||||
readonly gmReviewTarget?: GmReviewTarget | null;
|
||||
readonly bossDifficulty?: number;
|
||||
},
|
||||
@@ -88,6 +98,7 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
activeBossDifficulty: 1,
|
||||
gameMode: "dungeon",
|
||||
gmTestMode: false,
|
||||
bossTest: null,
|
||||
activeGmReviewTarget: null,
|
||||
activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw },
|
||||
sessionRevision: 0,
|
||||
@@ -125,6 +136,7 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
activeBossDifficulty: validBossDifficulty(options?.bossDifficulty) ? options.bossDifficulty : 1,
|
||||
gameMode: options?.gameMode ?? "dungeon",
|
||||
gmTestMode: options?.gmTestMode ?? false,
|
||||
bossTest: options?.bossTest ?? null,
|
||||
activeGmReviewTarget: options?.gmReviewTarget ?? null,
|
||||
activeSpawn,
|
||||
sessionRevision: state.sessionRevision + 1,
|
||||
@@ -279,7 +291,7 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
const activeSpawn = { footPosition: activeEntrance.footPosition, yaw: activeEntrance.yaw };
|
||||
return {
|
||||
gameMode: "dungeon",
|
||||
gmTestMode: false,
|
||||
gmTestMode: Boolean(state.bossTest),
|
||||
activeGmReviewTarget: null,
|
||||
activeSpawn,
|
||||
overlay: null,
|
||||
|
||||
+106
-2
@@ -1,10 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
actionKeyForEvent, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
|
||||
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resolveDpsMeterDisplay,
|
||||
actionKeyForEvent, assignDesktopAbility, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
|
||||
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resetDesktopAbilities, resolveDpsMeterDisplay,
|
||||
HUD_ELEMENTS, resolveHudScreen,
|
||||
activateDesktopAbilityLoadout, assignedActionBarAbilityIds,
|
||||
} from "./uiLayout";
|
||||
|
||||
describe("personal UI layouts", () => {
|
||||
it("populates fresh PC bars without changing shared keys, geometry, templates, or mobile controls", () => {
|
||||
const ui = normalizeUiPreferences(null);
|
||||
ui.current.pc.wowBars = true;
|
||||
ui.current.pc.barCount = 2;
|
||||
ui.current.pc.bars[1].columns = 3;
|
||||
ui.current.pc = bindDesktopSlot(ui.current.pc, 1, 0, "Ctrl+KeyQ");
|
||||
ui.slots.pc[0] = { name: "Raid", layout: ui.current.pc };
|
||||
const defaults = Array.from({ length: 14 }, (_, i) => `spell-${i}`);
|
||||
const activated = activateDesktopAbilityLoadout(ui, "new:priest:", defaults, defaults);
|
||||
expect(activated.current.pc.bars[0].slots[0]).toEqual({ abilityId: "spell-0", key: "Digit1" });
|
||||
expect(activated.current.pc.bars[1].slots[0]).toEqual({ abilityId: "spell-12", key: "Ctrl+KeyQ" });
|
||||
expect(activated.current.pc.bars[1].columns).toBe(3);
|
||||
expect(activated.current.pc.barCount).toBe(2);
|
||||
expect(activated.current.pc.wowBars).toBe(true);
|
||||
expect(activated.current.mobile).toEqual(ui.current.mobile);
|
||||
expect(activated.slots).toEqual(ui.slots);
|
||||
expect(ui.current.pc.bars[0].slots[0].abilityId).toBeNull();
|
||||
});
|
||||
it("restores separate character and ordered-class loadouts, including intentionally empty bars", () => {
|
||||
let ui = activateDesktopAbilityLoadout(normalizeUiPreferences(null), "one:priest:", ["heal"], ["heal"]);
|
||||
ui.desktopAbilityLoadouts["one:priest:"] = Array(60).fill(null);
|
||||
ui = activateDesktopAbilityLoadout(ui, "two:priest:", ["heal"], ["heal"]);
|
||||
expect(ui.current.pc.bars[0].slots[0].abilityId).toBe("heal");
|
||||
ui = activateDesktopAbilityLoadout(ui, "one:priest:", ["heal"], ["heal"]);
|
||||
expect(ui.current.pc.bars.every((bar) => bar.slots.every((slot) => slot.abilityId === null))).toBe(true);
|
||||
ui = activateDesktopAbilityLoadout(ui, "one:rom-priest:rom-warrior", ["secondary-attack"], ["secondary-attack"]);
|
||||
expect(ui.current.pc.bars[0].slots[0].abilityId).toBe("secondary-attack");
|
||||
expect(importUiPreferences(exportUiPreferences(ui))).toEqual(ui);
|
||||
});
|
||||
it("adopts compatible legacy assignments once and repairs bars holding only another class's spells", () => {
|
||||
const ui = normalizeUiPreferences(null);
|
||||
ui.current.pc = assignDesktopAbility(ui.current.pc, 0, 2, "custom-heal");
|
||||
const migrated = activateDesktopAbilityLoadout(ui, "one:priest:", ["heal"], ["heal", "custom-heal"]);
|
||||
expect(migrated.current.pc.bars[0].slots[0].abilityId).toBeNull();
|
||||
expect(migrated.current.pc.bars[0].slots[2].abilityId).toBe("custom-heal");
|
||||
const next = activateDesktopAbilityLoadout(migrated, "two:priest:", ["heal"], ["heal", "custom-heal"]);
|
||||
expect(next.current.pc.bars[0].slots[0].abilityId).toBe("heal");
|
||||
expect(next.current.pc.bars[0].slots[2].abilityId).toBeNull();
|
||||
const repaired = activateDesktopAbilityLoadout(ui, "new:mage:", ["fireball"], ["fireball"]);
|
||||
expect(repaired.current.pc.bars[0].slots[0].abilityId).toBe("fireball");
|
||||
const oldExport = JSON.parse(exportUiPreferences(ui));
|
||||
delete oldExport.desktopAbilityLoadouts;
|
||||
expect(importUiPreferences(JSON.stringify(oldExport)).desktopAbilityLoadouts).toEqual({});
|
||||
});
|
||||
it("checks the selected layout across modifier layers and active PC bars", () => {
|
||||
const layout = defaultUiLayout();
|
||||
layout.bars[0].slots[0].abilityId = "pc-heal";
|
||||
layout.bars[1].slots[0].abilityId = "inactive-heal";
|
||||
const bindings = { "primary:face-bottom": "controller-heal", "tertiary:r2": "modifier-heal" };
|
||||
expect([...assignedActionBarAbilityIds(layout, bindings, false)]).toEqual(["controller-heal", "modifier-heal"]);
|
||||
expect([...assignedActionBarAbilityIds(layout, bindings, true)]).toEqual(["pc-heal"]);
|
||||
layout.barCount = 2;
|
||||
expect(assignedActionBarAbilityIds(layout, bindings, true).has("inactive-heal")).toBe(true);
|
||||
});
|
||||
it("assigns every element one display, saves routing, and keeps single-display widgets available", () => {
|
||||
const preferences = normalizeUiPreferences(null);
|
||||
const layout = preferences.current.mobile;
|
||||
expect(resolveHudScreen("party", layout, true)).toBe("top");
|
||||
for (const id of ["actions", "player", "cast", "experience"] as const) expect(resolveHudScreen(id, layout, true)).toBe("bottom");
|
||||
for (const id of Object.keys(HUD_ELEMENTS) as (keyof typeof HUD_ELEMENTS)[]) {
|
||||
layout.elements[id].screen = "bottom";
|
||||
expect(resolveHudScreen(id, layout, true)).toBe("bottom");
|
||||
expect(resolveHudScreen(id, layout, false)).toBe("top");
|
||||
if (id === "dps") {
|
||||
expect(resolveDpsMeterDisplay(layout, false)).toBe("main");
|
||||
expect(resolveDpsMeterDisplay(layout, true)).toBe("companion");
|
||||
}
|
||||
layout.elements[id].screen = "top";
|
||||
expect(resolveHudScreen(id, layout, true)).toBe("top");
|
||||
layout.elements[id].screen = "hidden";
|
||||
expect(resolveHudScreen(id, layout, true)).toBe("hidden");
|
||||
}
|
||||
expect(importUiPreferences(exportUiPreferences(preferences))).toEqual(preferences);
|
||||
});
|
||||
it("saves and clears abilities on inactive WoW bars without changing keys, templates, or mobile controls", () => {
|
||||
const ui = normalizeUiPreferences(null);
|
||||
ui.slots.pc[0] = { name: "Original", layout: ui.current.pc };
|
||||
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", () => {
|
||||
const ui = normalizeUiPreferences(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"; }],
|
||||
["duplicate chords", (data: any) => { data.current.pc.bars[0].slots[1].key = "Digit1"; }],
|
||||
["missing buttons", (data: any) => { data.current.pc.bars[0].slots.pop(); }],
|
||||
["invalid character bars", (data: any) => { data.desktopAbilityLoadouts = { character: ["heal"] }; }],
|
||||
])("rejects %s without mutating the original preferences", (_, mutate) => {
|
||||
const ui = normalizeUiPreferences(null);
|
||||
const original = exportUiPreferences(ui);
|
||||
|
||||
+75
-2
@@ -6,7 +6,7 @@ export const UI_DEVICES: readonly UiDevice[] = ["mobile", "pc"];
|
||||
export const BAR_COLUMNS = [1, 2, 3, 4, 6, 12] as const;
|
||||
export const HUD_ELEMENTS = {
|
||||
party: "Party frames", summons: "Pet and summon frames", minimap: "Minimap",
|
||||
player: "Player status", timeline: "Encounter timeline", target: "Enemy target",
|
||||
player: "Buffs and debuffs", timeline: "Encounter timeline", target: "Enemy target",
|
||||
friendly: "Friendly target", threat: "Threat meter", dps: "DPS meter", cast: "Cast bar",
|
||||
hints: "Control hints", actions: "Controller action bar", experience: "Experience bar",
|
||||
reticle: "Crosshair", loot: "Loot notifications", interaction: "Interaction prompt",
|
||||
@@ -16,8 +16,10 @@ export const HUD_ELEMENTS = {
|
||||
"bar-4": "Action bar 4", "bar-5": "Action bar 5",
|
||||
} as const;
|
||||
export type HudElementId = keyof typeof HUD_ELEMENTS;
|
||||
export type HudScreen = "auto" | "top" | "bottom" | "hidden";
|
||||
export interface HudElementLayout {
|
||||
visible: boolean;
|
||||
screen?: HudScreen;
|
||||
position: NormalizedHudPosition | null;
|
||||
width: number | null;
|
||||
}
|
||||
@@ -34,6 +36,8 @@ export interface UiTemplate { name: string; layout: UiLayout }
|
||||
export interface UiPreferences {
|
||||
version: 1;
|
||||
keybinds: InputPreferences;
|
||||
/** Ability assignments belong to a character and ordered class pair; geometry and keys stay shared. */
|
||||
desktopAbilityLoadouts: Record<string, (string | null)[]>;
|
||||
current: Record<UiDevice, UiLayout>;
|
||||
slots: Record<UiDevice, (UiTemplate | null)[]>;
|
||||
}
|
||||
@@ -87,6 +91,7 @@ export function normalizeUiLayout(value: unknown): UiLayout {
|
||||
const element = record(elements[id]);
|
||||
defaults.elements[id] = {
|
||||
visible: element.visible !== false,
|
||||
...(["auto", "top", "bottom", "hidden"].includes(String(element.screen)) ? { screen: element.screen as HudScreen } : {}),
|
||||
position: element.position ? normalizeHudPosition(element.position, { x: 0.5, y: 0.5 }) : null,
|
||||
width: typeof element.width === "number" ? bounded(element.width, 200, 20, 2000) : null,
|
||||
};
|
||||
@@ -114,8 +119,21 @@ export function normalizeUiLayout(value: unknown): UiLayout {
|
||||
}
|
||||
|
||||
export function resolveDpsMeterDisplay(layout: UiLayout, dualScreen: boolean): "main" | "companion" {
|
||||
const screen = layout.elements.dps.screen;
|
||||
if (screen === "top" || screen === "bottom") return !dualScreen || screen === "top" ? "main" : "companion";
|
||||
return layout.dpsMeterDisplay === "auto" ? dualScreen ? "companion" : "main" : layout.dpsMeterDisplay;
|
||||
}
|
||||
/** One owner per widget; single-display layouts keep all visible widgets available. */
|
||||
export function resolveHudScreen(id: HudElementId, layout: UiLayout, dualScreen: boolean): "top" | "bottom" | "hidden" {
|
||||
const element = layout.elements[id];
|
||||
if (!element.visible || element.screen === "hidden") return "hidden";
|
||||
if (!dualScreen) return "top";
|
||||
if (element.screen === "top" || element.screen === "bottom") return element.screen;
|
||||
if (id === "dps") return resolveDpsMeterDisplay(layout, true) === "main" ? "top" : "bottom";
|
||||
if (["player", "actions", "cast", "experience", "bar-1", "bar-2", "bar-3", "bar-4", "bar-5"].includes(id)) return "bottom";
|
||||
if (id === "hints") return "hidden";
|
||||
return "top";
|
||||
}
|
||||
export function normalizeUiPreferences(value: unknown): UiPreferences {
|
||||
const source = record(value);
|
||||
const current = record(source.current);
|
||||
@@ -123,6 +141,12 @@ export function normalizeUiPreferences(value: unknown): UiPreferences {
|
||||
return {
|
||||
version: 1,
|
||||
keybinds: normalizeInputPreferences(source.keybinds),
|
||||
desktopAbilityLoadouts: Object.fromEntries(Object.entries(record(source.desktopAbilityLoadouts))
|
||||
.filter(([key, value]) => key.length <= 300 && Array.isArray(value))
|
||||
.map(([key, value]) => [key, Array.from({ length: 60 }, (_, index) => {
|
||||
const id = (value as unknown[])[index];
|
||||
return typeof id === "string" && id.length <= 200 ? id : null;
|
||||
})])),
|
||||
current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) },
|
||||
slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => {
|
||||
const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null);
|
||||
@@ -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,
|
||||
})) })) };
|
||||
}
|
||||
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 {
|
||||
if (!layout.wowBars || !key) return null;
|
||||
// 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>;
|
||||
try { source = record(JSON.parse(text)); } catch { throw new Error("This file is not valid JSON."); }
|
||||
if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version.");
|
||||
if (source.desktopAbilityLoadouts !== undefined) {
|
||||
if (!source.desktopAbilityLoadouts || typeof source.desktopAbilityLoadouts !== "object" || Array.isArray(source.desktopAbilityLoadouts)) throw new Error("Invalid character action bars.");
|
||||
for (const [key, ids] of Object.entries(record(source.desktopAbilityLoadouts))) {
|
||||
if (key.length > 300 || !Array.isArray(ids) || ids.length !== 60
|
||||
|| ids.some((id) => id !== null && (typeof id !== "string" || id.length > 200))) throw new Error("Invalid character action bars.");
|
||||
}
|
||||
}
|
||||
const validateLayout = (value: unknown) => {
|
||||
const layout = record(value);
|
||||
if (layout.dpsMeterDisplay !== undefined && !["auto", "main", "companion"].includes(String(layout.dpsMeterDisplay))) throw new Error("Invalid DPS meter display.");
|
||||
@@ -162,7 +235,7 @@ export function importUiPreferences(text: string): UiPreferences {
|
||||
if (id === "dps" && record(layout.elements)[id] === undefined) continue;
|
||||
const element = record(record(layout.elements)[id]);
|
||||
const position = record(element.position);
|
||||
if (typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
|
||||
if ((element.screen !== undefined && !["auto", "top", "bottom", "hidden"].includes(String(element.screen))) || typeof element.visible !== "boolean" || (element.position !== null && [position.x, position.y].some((v) => typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1))
|
||||
|| (element.width !== null && (typeof element.width !== "number" || !Number.isFinite(element.width) || element.width < 20 || element.width > 2000))) throw new Error("Invalid HUD position or visibility.");
|
||||
}
|
||||
const keys = new Set<string>();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,20 @@ import { create } from "zustand";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { loadUiPreferences, saveUiPreferences } from "../app/accountRepository";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout";
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import { abilitiesForCharacter, defaultActionBarForCharacter } from "./abilityCatalog";
|
||||
import { activateDesktopAbilityLoadout, normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout";
|
||||
import { publishInputPreferences } from "../input/keybindRuntime";
|
||||
|
||||
export function currentUiDevice(): UiDevice {
|
||||
return Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent))
|
||||
return (typeof window !== "undefined" && /(?:[?&]layout=(?:thor-preview|dual)|[?&]display=(?:top|bottom))\b/.test(window.location.search))
|
||||
|| Capacitor.isNativePlatform() || (typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent))
|
||||
|| (typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches && window.innerWidth < 1100)
|
||||
? "mobile" : "pc";
|
||||
}
|
||||
interface UiLayoutState {
|
||||
ownerId: string | null;
|
||||
desktopLoadoutKey: string | null;
|
||||
preferences: UiPreferences;
|
||||
draft: UiLayout | null;
|
||||
device: UiDevice;
|
||||
@@ -24,7 +28,7 @@ interface UiLayoutState {
|
||||
finish: (save: boolean) => void;
|
||||
}
|
||||
export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
|
||||
ownerId: null, preferences: normalizeUiPreferences(null), draft: null, device: currentUiDevice(),
|
||||
ownerId: null, desktopLoadoutKey: null, preferences: normalizeUiPreferences(null), draft: null, device: currentUiDevice(),
|
||||
mode: "layout", selectedButton: null, selectedElement: null,
|
||||
begin: () => {
|
||||
const device = currentUiDevice();
|
||||
@@ -35,7 +39,11 @@ export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
|
||||
const ownerId = get().ownerId;
|
||||
if (!ownerId) throw new Error("Sign in or choose an offline profile before saving your UI.");
|
||||
// Write first: storage errors leave the editor open with its draft intact.
|
||||
const saved = saveUiPreferences(ownerId, preferences);
|
||||
const key = get().desktopLoadoutKey;
|
||||
const saved = saveUiPreferences(ownerId, key ? { ...preferences,
|
||||
desktopAbilityLoadouts: { ...preferences.desktopAbilityLoadouts,
|
||||
[key]: preferences.current.pc.bars.flatMap((bar) => bar.slots.map((slot) => slot.abilityId)) },
|
||||
} : preferences);
|
||||
set({ preferences: saved });
|
||||
},
|
||||
finish: (save) => {
|
||||
@@ -47,7 +55,7 @@ export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
|
||||
|
||||
function activateOwner() {
|
||||
const ownerId = useShellStore.getState().session?.ownerId ?? null;
|
||||
useUiLayoutStore.setState({ ownerId, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null });
|
||||
useUiLayoutStore.setState({ ownerId, desktopLoadoutKey: null, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null });
|
||||
}
|
||||
activateOwner();
|
||||
publishInputPreferences(useUiLayoutStore.getState().preferences.keybinds);
|
||||
@@ -56,6 +64,18 @@ useShellStore.subscribe((state, previous) => {
|
||||
if (state.session !== previous.session) activateOwner();
|
||||
});
|
||||
|
||||
/** Call after the character's catalogs have loaded, in both menu and gameplay paths. */
|
||||
export function activateCharacterUi(character: CharacterProfile): void {
|
||||
const ui = useUiLayoutStore.getState();
|
||||
if (ui.ownerId !== character.ownerId) return;
|
||||
const key = `${character.id}:${character.classId}:${character.secondaryClassId ?? ""}`;
|
||||
useUiLayoutStore.setState({ desktopLoadoutKey: key, draft: null,
|
||||
preferences: activateDesktopAbilityLoadout(ui.preferences, key,
|
||||
defaultActionBarForCharacter(character.classId, character.secondaryClassId, character.level),
|
||||
abilitiesForCharacter(character.classId, character.secondaryClassId).map((ability) => ability.id)),
|
||||
});
|
||||
}
|
||||
|
||||
export function activeUiLayout(): UiLayout {
|
||||
const state = useUiLayoutStore.getState();
|
||||
return state.draft ?? state.preferences.current[currentUiDevice()];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
||||
import type { CharacterProfile } from "../app/types";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { activateCharacterUi } from "./uiLayoutStore";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { useArenaStore } from "./arenaStore";
|
||||
@@ -16,6 +17,7 @@ export function useArenaCombatView(character: CharacterProfile | null, actor: Ar
|
||||
const previousParty = usePartyStore.getState();
|
||||
const previousGame = useGameStore.getState();
|
||||
useCombatStore.getState().initializeCharacter(character);
|
||||
activateCharacterUi(character);
|
||||
usePartyStore.setState({ members: [] });
|
||||
useGameStore.setState({ overlay: null, paused: false, companionOpen: false });
|
||||
const unsubscribe = useCombatStore.subscribe((state, before) => {
|
||||
|
||||
@@ -4,15 +4,21 @@ import { Capacitor } from "@capacitor/core";
|
||||
import App from "./App";
|
||||
import { startControllerInput } from "./input/controller";
|
||||
import { currentDisplayLayout } from "./platform/displayLayout";
|
||||
import { THOR_DISPLAYS } from "./platform/thorDisplayProfile";
|
||||
import { initializeContentSystem } from "./content/contentManager";
|
||||
import "./styles.css";
|
||||
import "./ui/dungeonReports.css";
|
||||
import "./ui/thorUi.css";
|
||||
|
||||
const BottomDisplayApp = lazy(() => import("./platform/BottomDisplayApp").then((module) => ({ default: module.BottomDisplayApp })));
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const displayMode = params.get("display");
|
||||
const nativeLayoutRequested = params.has("nativeLayout");
|
||||
if (Capacitor.isNativePlatform() && (displayMode === "top" || displayMode === "bottom")) {
|
||||
// Let each WebView fit the same physical UI density, independently of Android's shared display density.
|
||||
document.querySelector('meta[name="viewport"]')?.setAttribute("content", `width=${THOR_DISPLAYS[displayMode].cssWidth}, viewport-fit=cover`);
|
||||
}
|
||||
if (Capacitor.isNativePlatform() || nativeLayoutRequested) document.documentElement.classList.add("native-platform");
|
||||
if (displayMode === "top" || displayMode === "bottom") document.documentElement.dataset.displaySurface = displayMode;
|
||||
document.documentElement.dataset.displayLayout = currentDisplayLayout();
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { normalizeInputPreferences } from "../input/keybinds";
|
||||
import { publishInputPreferences } from "../input/keybindRuntime";
|
||||
import { useForcedThorDisplays } from "./useThorDualScreen";
|
||||
import { RemoteDungeonHud } from "./RemoteDungeonHud";
|
||||
|
||||
export function BottomDisplayApp() {
|
||||
useForcedThorDisplays();
|
||||
@@ -78,5 +79,7 @@ export function BottomDisplayApp() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <main className="bottom-display-root"><CompanionPanel snapshot={snapshot} send={send} preferControllerBinds /></main>;
|
||||
return <main className="bottom-display-root">{snapshot.phase === "game"
|
||||
? <RemoteDungeonHud snapshot={snapshot} send={send} />
|
||||
: <CompanionPanel snapshot={snapshot} send={send} preferControllerBinds />}</main>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { clearBossTestingDungeons, registerBossTestingDungeon } from "../game/dungeonRegistry";
|
||||
import { usePartyStore } from "../game/partyStore";
|
||||
import { useManastormStore } from "../game/manastormStore";
|
||||
import { useDungeonRunStore } from "../game/dungeonRunStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { DungeonCompanionHud } from "../ui/DungeonCompanionHud";
|
||||
import type { CompanionCommand, CompanionSnapshot } from "./dualScreenSync";
|
||||
|
||||
export function RemoteDungeonHud({ snapshot, send }: {
|
||||
snapshot: CompanionSnapshot;
|
||||
send: (command: CompanionCommand) => void;
|
||||
}) {
|
||||
const hud = snapshot.game.hud;
|
||||
useLayoutEffect(() => {
|
||||
if (hud.bossTestingDungeon) registerBossTestingDungeon(hud.bossTestingDungeon.definition, hud.bossTestingDungeon.sourceDungeonId);
|
||||
const session = useShellStore.getState().session;
|
||||
useShellStore.setState({ ...hud.shell,
|
||||
session: session?.ownerId === snapshot.session?.ownerId ? session : snapshot.session,
|
||||
});
|
||||
useUiLayoutStore.setState(hud.ui);
|
||||
useGameStore.setState({ ...hud.game,
|
||||
toggleMap: () => send({ name: "toggleMap" }),
|
||||
openOverlay: (overlay) => send({ name: "openOverlay", overlay }),
|
||||
});
|
||||
if (!hud.bossTestingDungeon) clearBossTestingDungeons();
|
||||
useCombatStore.setState({ ...hud.combat,
|
||||
castActionBinding: (layer, control) => { send({ name: "castActionBinding", layer, control }); return { ok: false, affectedMobIds: [], damage: 0, healing: 0, levelsGained: 0 }; },
|
||||
selectFriendlyActor: (id) => { send({ name: "selectFriendlyActor", id }); return true; },
|
||||
clearTarget: () => send({ name: "clearTarget" }),
|
||||
updateSettings: (patch) => { if (patch.threatMeterPosition) send({ name: "moveThreatMeter", position: patch.threatMeterPosition }); },
|
||||
});
|
||||
usePartyStore.setState({ ...hud.party,
|
||||
selectMember: (memberId) => { if (memberId) send({ name: "selectPartyMember", memberId }); else send({ name: "clearPartySelection" }); return true; },
|
||||
selectSelf: () => send({ name: "selectPlayer" }),
|
||||
clearSelection: () => send({ name: "clearPartySelection" }),
|
||||
setCommand: (command) => send({ name: "setPartyCommand", command }),
|
||||
recallParty: () => send({ name: "recallParty" }),
|
||||
});
|
||||
useManastormStore.setState(hud.manastorm);
|
||||
useDungeonRunStore.setState({ progress: hud.run });
|
||||
useOnlineSessionStore.setState({ snapshot: hud.onlineSession });
|
||||
useOnlineGroupStore.setState(hud.onlineGroup);
|
||||
useDpsMeterStore.setState(hud.dps);
|
||||
}, [hud, send, snapshot.session]);
|
||||
return <DungeonCompanionHud send={send} remote completion={hud.completion} interaction={hud.interaction} />;
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import { currentCompanionSnapshot, executeCompanionCommand, type DualScreenMessa
|
||||
import { createRateLimitedPublisher } from "./rateLimitedPublisher";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { useDungeonRunStore } from "../game/dungeonRunStore";
|
||||
import { subscribeDungeonEncounterSnapshots } from "../game/dungeonEncounterRuntime";
|
||||
|
||||
export function connectAuthoritativeDualScreen(
|
||||
channel: BroadcastChannel,
|
||||
@@ -38,6 +42,11 @@ export function connectAuthoritativeDualScreen(
|
||||
const unsubscribeDps = useDpsMeterStore.subscribe(() => {
|
||||
if (companionReady) gamePublisher.request();
|
||||
});
|
||||
const request = () => { if (companionReady) gamePublisher.request(); };
|
||||
const unsubscribeOnline = useOnlineSessionStore.subscribe(request);
|
||||
const unsubscribeGroup = useOnlineGroupStore.subscribe(request);
|
||||
const unsubscribeRun = useDungeonRunStore.subscribe(request);
|
||||
const unsubscribeEncounters = subscribeDungeonEncounterSnapshots(request);
|
||||
channel.onmessage = (event: MessageEvent<DualScreenMessage>) => {
|
||||
if (event.data.type === "companion-ready") {
|
||||
companionReady = true;
|
||||
@@ -62,6 +71,10 @@ export function connectAuthoritativeDualScreen(
|
||||
unsubscribeParty();
|
||||
unsubscribeUi();
|
||||
unsubscribeDps();
|
||||
unsubscribeOnline();
|
||||
unsubscribeGroup();
|
||||
unsubscribeRun();
|
||||
unsubscribeEncounters();
|
||||
gamePublisher.dispose();
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,10 @@ import { activeUiLayout, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { normalizeHudPosition } from "../game/hudLayout";
|
||||
import { resolveDpsMeterDisplay, type HudElementLayout } from "../game/uiLayout";
|
||||
import { currentDisplayLayout } from "./displayLayout";
|
||||
import { currentHudSnapshot, type HudSnapshot } from "./hudSnapshot";
|
||||
import { HUD_ELEMENTS, type HudElementId } from "../game/uiLayout";
|
||||
import { interactWithActiveDungeonObject } from "../game/dungeonWorldInteraction";
|
||||
import { resurrectDeadPartyMembers } from "../game/partyResurrection";
|
||||
|
||||
export { createDualScreenChannel } from "./dualScreenChannel";
|
||||
|
||||
@@ -95,6 +99,7 @@ export interface CompanionPartyObjectiveSnapshot {
|
||||
}
|
||||
|
||||
export interface CompanionGameSnapshot {
|
||||
hud: HudSnapshot;
|
||||
dpsMeter: {
|
||||
values: DpsMeterSnapshot;
|
||||
layout: HudElementLayout;
|
||||
@@ -172,7 +177,15 @@ export type CompanionCommand =
|
||||
| { name: "setPartyCommand"; command: PartyCommand }
|
||||
| { name: "recallParty" }
|
||||
| { name: "resetAtEntrance" }
|
||||
| { name: "editDpsMeter"; update: Partial<HudElementLayout> };
|
||||
| { name: "editDpsMeter"; update: Partial<HudElementLayout> }
|
||||
| { name: "editHudElement"; id: HudElementId; update: Partial<HudElementLayout> }
|
||||
| { name: "selectFriendlyActor"; id: string | null }
|
||||
| { name: "selectPlayer" }
|
||||
| { name: "clearPartySelection" }
|
||||
| { name: "resurrectParty"; checkpointId: string }
|
||||
| { name: "interactDungeonObject" }
|
||||
| { name: "finishDungeon" }
|
||||
| { name: "moveThreatMeter"; position: { x: number; y: number } };
|
||||
|
||||
export type DualScreenMessage =
|
||||
| { type: "companion-ready" }
|
||||
@@ -265,6 +278,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
|
||||
draft: shell.draft,
|
||||
notice: shell.notice,
|
||||
game: {
|
||||
hud: currentHudSnapshot(),
|
||||
dpsMeter: {
|
||||
values: currentDpsMeterSnapshot(),
|
||||
layout: uiLayout.elements.dps,
|
||||
@@ -342,6 +356,24 @@ export function executeCompanionCommand(command: CompanionCommand): void {
|
||||
const game = useGameStore.getState();
|
||||
const combat = useCombatStore.getState();
|
||||
switch (command.name) {
|
||||
case "editHudElement": {
|
||||
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft || !(command.id in HUD_ELEMENTS)) break;
|
||||
const update: Partial<HudElementLayout> = {};
|
||||
if (command.update.position) update.position = normalizeHudPosition(command.update.position, { x: .5, y: .5 });
|
||||
if (typeof command.update.width === "number" && Number.isFinite(command.update.width)) update.width = Math.max(20, Math.min(2000, command.update.width));
|
||||
useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
|
||||
[command.id]: { ...layout.elements[command.id], ...update },
|
||||
} }));
|
||||
useUiLayoutStore.setState({ selectedElement: command.id });
|
||||
break;
|
||||
}
|
||||
case "selectFriendlyActor": combat.selectFriendlyActor(command.id); break;
|
||||
case "selectPlayer": combat.clearTarget(); usePartyStore.getState().selectSelf(); break;
|
||||
case "clearPartySelection": usePartyStore.getState().clearSelection(); break;
|
||||
case "resurrectParty": resurrectDeadPartyMembers(command.checkpointId); break;
|
||||
case "interactDungeonObject": if (!game.overlay) interactWithActiveDungeonObject(); break;
|
||||
case "finishDungeon": if (currentHudSnapshot().completion) { shell.saveActiveDungeonRunCheckpoint(null); shell.returnToMainMenu(); } break;
|
||||
case "moveThreatMeter": combat.updateSettings({ threatMeterPosition: normalizeHudPosition(command.position, { x: .8, y: .3 }) }); break;
|
||||
case "editDpsMeter": {
|
||||
if (game.overlay !== "ui-layout" || !useUiLayoutStore.getState().draft) break;
|
||||
const update: Partial<HudElementLayout> = {};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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,10 +5,12 @@ import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { ProductionDungeon } from "./ProductionDungeon";
|
||||
import { PrototypeDungeon } from "./PrototypeDungeon";
|
||||
import { BossTestingRoom } from "./BossTestingRoom";
|
||||
|
||||
export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void }) {
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const bossTest = useGameStore((state) => state.bossTest);
|
||||
const sessionRevision = useGameStore((state) => state.sessionRevision);
|
||||
const definition = requireDungeonDefinition(activeDungeonId);
|
||||
const manastormPackage = gameMode === "manastorm"
|
||||
@@ -31,6 +33,7 @@ export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void
|
||||
&& assets.collision.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (bossTest) return;
|
||||
if (hasProductionAssets) return;
|
||||
const missing = [
|
||||
...definition.assets.visual.filter((_, index) => !assets.visual[index]).map((asset) => asset.fileName),
|
||||
@@ -38,8 +41,9 @@ export function DungeonEnvironment({ onReady }: { readonly onReady?: () => void
|
||||
];
|
||||
useGameStore.getState().setAssetStatus("proxy", `Waiting for ${missing.join(" and ") || "generated dungeon assets"}.`);
|
||||
onReady?.();
|
||||
}, [assets.collision, assets.visual, definition, hasProductionAssets, onReady]);
|
||||
}, [assets.collision, assets.visual, bossTest, definition, hasProductionAssets, onReady]);
|
||||
|
||||
if (bossTest) return <BossTestingRoom definition={definition} onReady={onReady} />;
|
||||
if (!hasProductionAssets) return <PrototypeDungeon definition={definition} />;
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -47,6 +47,7 @@ import { combatHasLineOfSight } from "../game/combatSpatial";
|
||||
import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset";
|
||||
import { scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling";
|
||||
import { dungeonEnemyScaling } from "../game/dungeonBossScaling";
|
||||
import { contentCategoryForClass } from "../app/characterCatalog";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { stepFearedMobAway } from "../game/mobMotion";
|
||||
import {
|
||||
@@ -891,6 +892,7 @@ function ProxyMob({
|
||||
const difficultyId = useGameStore((state) => state.activeDifficultyId);
|
||||
const bossDifficulty = useGameStore((state) => state.activeBossDifficulty);
|
||||
const characterLevel = useCombatStore((state) => state.level);
|
||||
const characterSource = useCombatStore((state) => contentCategoryForClass(state.classId));
|
||||
const manastormLevel = useManastormStore((state) => state.level);
|
||||
const manastormPartySize = useManastormStore((state) => state.partySize);
|
||||
const manastormModeId = useManastormStore((state) => state.modeId);
|
||||
@@ -933,7 +935,7 @@ function ProxyMob({
|
||||
? definition.combat?.level
|
||||
: scaledDungeonEnemyLevel(characterLevel, boss);
|
||||
const scaling = gameMode === "dungeon"
|
||||
? dungeonEnemyScaling(dungeon, definition, instanceId, characterLevel, difficultyId, bossDifficulty)
|
||||
? dungeonEnemyScaling(dungeon, definition, instanceId, characterLevel, difficultyId, bossDifficulty, characterSource)
|
||||
: null;
|
||||
const healthMultiplier = scaling?.healthMultiplier ?? baseHealthMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1);
|
||||
useCombatStore.getState().registerMob(instanceId, {
|
||||
@@ -965,6 +967,7 @@ function ProxyMob({
|
||||
}, [
|
||||
boss,
|
||||
characterLevel,
|
||||
characterSource,
|
||||
definition.hasLoot,
|
||||
definition.id,
|
||||
definition.name,
|
||||
|
||||
+43
-3
@@ -28,6 +28,30 @@ html, body, #root {
|
||||
body { color: var(--ink); }
|
||||
button { font: inherit; }
|
||||
|
||||
.ability-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
width: min(340px, calc(100vw - 24px));
|
||||
max-height: calc(100dvh - 24px);
|
||||
overflow: hidden;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--gold);
|
||||
border-radius: 8px 3px;
|
||||
background: var(--panel-solid);
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ability-tooltip header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.ability-tooltip header strong { color: var(--gold); font: 600 16px Georgia, serif; }
|
||||
.ability-tooltip kbd { color: var(--muted); font-size: 10px; }
|
||||
.ability-tooltip p { margin: 8px 0; white-space: pre-line; overflow-wrap: anywhere; }
|
||||
.ability-tooltip footer { display: flex; flex-wrap: wrap; gap: 4px 12px; padding-top: 8px; border-top: 1px solid var(--edge); color: var(--moss-bright); font-size: 11px; }
|
||||
.ability-tooltip .ability-tooltip__lock { margin-bottom: 0; color: var(--gold); }
|
||||
|
||||
.game-shell, .scene-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -1278,7 +1302,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
||||
.spellbook-panel__progress { display: grid; grid-template-columns: auto auto 1px auto auto; align-items: baseline; gap: 4px; color: var(--muted); font-size: 7px; text-transform: uppercase; }
|
||||
.spellbook-panel__progress strong { color: #e7d491; font: 700 14px Georgia, serif; }
|
||||
.spellbook-panel__progress i { width: 1px; height: 19px; margin: 0 5px; background: rgba(216,226,194,.15); }
|
||||
.spellbook-panel__toolbar { display: flex; justify-content: flex-end; margin-top: 10px; }
|
||||
.spellbook-panel__toolbar { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 10px; }
|
||||
.spellbook-panel__filter { display: flex; min-height: 28px; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid rgba(190,210,157,.17); border-radius: 999px; background: rgba(152,181,122,.05); color: var(--muted); font-size: 7px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; cursor: pointer; }
|
||||
.spellbook-panel__filter:hover { border-color: rgba(190,210,157,.32); background: rgba(152,181,122,.11); color: var(--ink); }
|
||||
.spellbook-panel__filter > span { position: relative; width: 22px; height: 12px; border: 1px solid rgba(190,210,157,.26); border-radius: 999px; background: rgba(0,0,0,.26); }
|
||||
@@ -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; }
|
||||
|
||||
.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__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.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; }
|
||||
@@ -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:hover { background: rgba(138,171,104,.1); }
|
||||
.ability-bindings__choices button.is-current { border-color: rgba(151,199,108,.34); background: rgba(101,151,72,.12); }
|
||||
.spellbook-ability.is-missing-from-bars, .ability-bindings__choices button.is-missing-from-bars { border-color: #92d5ff; box-shadow: inset 0 0 0 1px rgba(146,213,255,.22), 0 0 6px rgba(146,213,255,.13); }
|
||||
.is-missing-from-bars :is(.spellbook-ability__icon, .ability-bindings__icon) { border-color: #92d5ff; }
|
||||
.action-bar-missing-legend, .ability-bindings__picker > p.action-bar-missing-legend { color: #92d5ff; font-size: 9px; line-height: 1.4; }
|
||||
.ability-bindings__choices button.is-locked { filter: grayscale(.85); opacity: .65; }
|
||||
.ability-bindings__choices button.is-danger { border-color: rgba(202,111,87,.32); background: rgba(138,48,34,.14); }
|
||||
.ability-bindings__choices button > span:last-child { min-width: 0; }
|
||||
@@ -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 kbd, .ability-bindings__layers small { display: none; }
|
||||
.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__dpad { display: grid; max-width: 230px; margin: 0 auto 7px; }
|
||||
.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 small { color: var(--front-dim); font-size: 6px; }
|
||||
.manastorm-spell-option.is-active { border-color: rgba(142,231,239,.72); background: rgba(81,126,143,.28); color: #efffff; box-shadow: inset 0 0 12px rgba(100,210,225,.11); }
|
||||
.manastorm-spell-option:disabled { opacity: .42; }
|
||||
.manastorm-spell-option:is(:disabled, [aria-disabled="true"]) { opacity: .42; }
|
||||
.manastorm-role-label { display: block; margin-top: 11px; }
|
||||
.manastorm-role-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin-top: 5px; }
|
||||
.manastorm-role-grid .dungeon-role-option { min-height: 47px; padding: 6px; }
|
||||
@@ -3759,7 +3799,7 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); }
|
||||
display: grid; justify-items: center; gap: .1rem; padding: .2rem;
|
||||
border: 1px solid #9dad8277; background: #08130feb; color: #fff;
|
||||
}
|
||||
.arena-personal-bars button:disabled { opacity: .55; }
|
||||
.arena-personal-bars button:is(:disabled, [aria-disabled="true"]) { opacity: .55; }
|
||||
.arena-personal-bars button kbd { position: absolute; top: .1rem; right: .2rem; z-index: 1; color: #fff5aa; font-size: .7rem; text-shadow: 1px 1px #000; }
|
||||
.arena-personal-bars button .action-slot__icon { width: 1.9rem; height: 1.9rem; }
|
||||
.arena-personal-bars button > span:last-of-type { font-size: .58rem; line-height: 1.15; overflow: hidden; }
|
||||
|
||||
+204
-64
@@ -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 {
|
||||
ACTION_CONTROLS,
|
||||
@@ -10,13 +10,17 @@ import {
|
||||
type ActionBindingLayer,
|
||||
} from "../game/actionBindings";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { abilityAtLevel, defaultActionBarForCharacter, type AbilityDefinition } from "../game/abilityCatalog";
|
||||
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 { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
|
||||
import { getInputPreferences, isCapturingInput } from "../input/keybindRuntime";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { AbilityTooltipTarget, type AbilityTooltipData } from "./AbilityTooltip";
|
||||
|
||||
function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: string; color: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
@@ -29,27 +33,38 @@ function AbilityBindingIcon({ icon, sigil, color }: { icon?: string; sigil: stri
|
||||
}
|
||||
|
||||
type RestoreScope = "layer" | "all";
|
||||
type BindingLayout = "controller" | "wow";
|
||||
type BindingControl = ActionBindingControl | number;
|
||||
|
||||
interface RestoreRequest {
|
||||
scope: RestoreScope;
|
||||
layout: BindingLayout;
|
||||
layer: ActionBindingLayer;
|
||||
bar: number;
|
||||
cancelFocusId: string;
|
||||
completeFocusId: string;
|
||||
}
|
||||
|
||||
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 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": {
|
||||
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": {
|
||||
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": {
|
||||
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": {
|
||||
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 bindingChord = (layer: ActionBindingLayer, control: ActionBindingControl) => `${keyLabel("controller", `ability:${layer}:${control}`)} / ${keyLabel("pc", `ability:${layer}:${control}`)}`;
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
const classId = useCombatStore((state) => state.classId);
|
||||
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
const abilities = useCombatStore((state) => state.abilities);
|
||||
const talentRanks = useCombatStore((state) => state.talentRanks);
|
||||
const tooltipData = (definition: AbilityDefinition | null | undefined): AbilityTooltipData | null => {
|
||||
if (!definition) return null;
|
||||
const talentRank = definition.talentEntryId === undefined ? undefined : talentRanks[`coa-entry-${definition.talentEntryId}`] ?? 0;
|
||||
const ability = abilityAtLevel(definition, level, talentRank);
|
||||
return {
|
||||
name: ability.name, description: ability.description, target: ability.target,
|
||||
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
|
||||
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000,
|
||||
range: ability.range.max, lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
|
||||
talentLocked: ability.talentEntryId !== undefined && !talentRank,
|
||||
};
|
||||
};
|
||||
const bindings = useCombatStore((state) => state.actionBindings);
|
||||
const pcLayout = useUiLayoutStore((state) => state.preferences.current.pc);
|
||||
const [layout, setLayout] = useState<BindingLayout>(() => currentUiDevice() === "pc" && pcLayout.wowBars ? "wow" : "controller");
|
||||
const [bar, setBar] = useState(0);
|
||||
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 [notice, setNotice] = useState("Choose a button, then choose the ability it should use.");
|
||||
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 ordered = abilities
|
||||
.filter((ability) => !ability.passive)
|
||||
@@ -108,7 +151,32 @@ function AbilityBindingsDialog() {
|
||||
]
|
||||
: ordered;
|
||||
}, [abilities, editingAbilityId]);
|
||||
const close = () => openOverlay("pause");
|
||||
const close = onClose ?? (() => openOverlay("pause"));
|
||||
const saveWowLayout = (update: (current: UiLayout) => UiLayout) => {
|
||||
try {
|
||||
const ui = useUiLayoutStore.getState();
|
||||
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) => {
|
||||
setLayer(next);
|
||||
@@ -117,40 +185,42 @@ function AbilityBindingsDialog() {
|
||||
setNotice(`${ACTION_LAYER_LABELS[next]} layer selected.`);
|
||||
controller.select(layerControlId(next));
|
||||
};
|
||||
const beginEditing = (control: ActionBindingControl) => {
|
||||
const abilityId = bindings[actionBindingId(layer, control)];
|
||||
const beginEditing = (control: BindingControl) => {
|
||||
const abilityId = assignment(control);
|
||||
setEditingControl(control);
|
||||
setRestoreRequest(null);
|
||||
controller.select(abilityId ? choiceControlId(abilityId) : "binding-choice-empty");
|
||||
};
|
||||
const cancelEditing = () => {
|
||||
if (!editingControl) return;
|
||||
if (editingControl === null) return;
|
||||
const control = editingControl;
|
||||
setEditingControl(null);
|
||||
controller.select(slotControlId(control));
|
||||
};
|
||||
const assign = (abilityId: string | null) => {
|
||||
if (!editingControl) return;
|
||||
if (editingControl === null) return;
|
||||
const control = editingControl;
|
||||
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
|
||||
useCombatStore.getState().setActionBinding(layer, control, abilityId);
|
||||
setNotice(`${bindingChord(layer, control)}: ${ability?.name ?? "Empty"}.`);
|
||||
if (!setBinding(control, abilityId)) return;
|
||||
setNotice(`${controlLabel(control)}: ${ability?.name ?? "Empty"}. Saved.`);
|
||||
setEditingControl(null);
|
||||
controller.select(slotControlId(control));
|
||||
};
|
||||
const clearSelected = (control: ActionBindingControl | null = editingControl) => {
|
||||
if (!control) return;
|
||||
useCombatStore.getState().setActionBinding(layer, control, null);
|
||||
setNotice(`${bindingChord(layer, control)} cleared.`);
|
||||
const clearSelected = (control: BindingControl | null = editingControl) => {
|
||||
if (control === null) return;
|
||||
if (!setBinding(control, null)) return;
|
||||
setNotice(`${controlLabel(control)} cleared. Saved.`);
|
||||
setEditingControl(null);
|
||||
controller.select(slotControlId(control));
|
||||
};
|
||||
const requestRestore = (scope: RestoreScope, focusId: string) => {
|
||||
setRestoreRequest({
|
||||
scope,
|
||||
layout,
|
||||
layer,
|
||||
bar,
|
||||
cancelFocusId: focusId,
|
||||
completeFocusId: editingControl ? slotControlId(editingControl) : focusId,
|
||||
completeFocusId: editingControl !== null ? slotControlId(editingControl) : focusId,
|
||||
});
|
||||
controller.select("binding-confirm-cancel");
|
||||
};
|
||||
@@ -163,7 +233,11 @@ function AbilityBindingsDialog() {
|
||||
const confirmRestore = () => {
|
||||
if (!restoreRequest) return;
|
||||
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);
|
||||
setNotice(`${ACTION_LAYER_LABELS[request.layer]} layer restored.`);
|
||||
} else {
|
||||
@@ -190,53 +264,72 @@ function AbilityBindingsDialog() {
|
||||
},
|
||||
];
|
||||
}
|
||||
if (editingControl) {
|
||||
if (editingControl !== null) {
|
||||
return [
|
||||
...pickerAbilities.map((ability) => ({ id: choiceControlId(ability.id), run: () => assign(ability.id) })),
|
||||
{ id: "binding-choice-empty", run: () => assign(null) },
|
||||
];
|
||||
}
|
||||
return [
|
||||
...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] };
|
||||
}),
|
||||
...(["controller", "wow"] as const).map((next) => ({
|
||||
id: `binding-layout-${next}`, run: () => chooseLayout(next),
|
||||
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}`],
|
||||
})),
|
||||
...(isWow ? [
|
||||
...pcLayout.bars.map((_, index) => ({ id: barControlId(index), run: () => chooseBar(index), neighbors: {
|
||||
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",
|
||||
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",
|
||||
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, {
|
||||
columns: restoreRequest ? 2 : editingControl ? 1 : 3,
|
||||
columns: restoreRequest ? 2 : editingControl !== null ? 1 : isWow ? 4 : 3,
|
||||
initialId: restoreRequest
|
||||
? "binding-confirm-cancel"
|
||||
: editingControl
|
||||
: editingControl !== null
|
||||
? editingAbilityId ? choiceControlId(editingAbilityId) : "binding-choice-empty"
|
||||
: layerControlId(layer),
|
||||
onBack: restoreRequest ? cancelRestore : editingControl ? cancelEditing : close,
|
||||
: isWow ? barControlId(bar) : layerControlId(layer),
|
||||
onBack: restoreRequest ? cancelRestore : editingControl !== null ? cancelEditing : close,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
panel.current?.querySelector(".is-controller-selected")?.scrollIntoView({ block: "nearest" });
|
||||
}, [controller.selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
const run = (id: string | undefined) => {
|
||||
if (restoreRequest) return;
|
||||
if (id === "editor-layer-2") chooseLayer("secondary");
|
||||
else if (id === "editor-layer-3") chooseLayer("tertiary");
|
||||
if (id === "editor-layer-2") { if (isWow) chooseBar(1); else chooseLayer("secondary"); }
|
||||
else if (id === "editor-layer-3") { if (isWow) chooseBar(2); else chooseLayer("tertiary"); }
|
||||
else if (id === "editor-clear") {
|
||||
if (editingControl) clearSelected(editingControl);
|
||||
if (editingControl !== null) clearSelected(editingControl);
|
||||
else {
|
||||
const prefix = "binding-slot-";
|
||||
if (controller.selectedId.startsWith(prefix)) clearSelected(controller.selectedId.slice(prefix.length) as ActionBindingControl);
|
||||
const prefix = isWow ? "binding-wow-slot-" : "binding-slot-";
|
||||
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);
|
||||
};
|
||||
@@ -252,7 +345,7 @@ function AbilityBindingsDialog() {
|
||||
};
|
||||
window.addEventListener("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 definition = ACTION_CONTROL_DEFINITIONS[control];
|
||||
@@ -260,6 +353,7 @@ function AbilityBindingsDialog() {
|
||||
const ability = abilityId ? abilities.find((candidate) => candidate.id === abilityId) ?? null : null;
|
||||
const locked = Boolean(ability && ability.unlockLevel > level);
|
||||
return (
|
||||
<AbilityTooltipTarget key={control} ability={tooltipData(ability)} bindingLabel={bindingChord(layer, control)}>
|
||||
<ControllerButton
|
||||
key={control}
|
||||
controlId={slotControlId(control)}
|
||||
@@ -277,21 +371,38 @@ function AbilityBindingsDialog() {
|
||||
: <span className="ability-bindings__empty" aria-hidden="true">+</span>}
|
||||
<span><strong>{definition.label}</strong><small>{locked ? `${ability!.name} · Level ${ability!.unlockLevel}` : ability?.name ?? "Empty"}</small></span>
|
||||
</ControllerButton>
|
||||
</AbilityTooltipTarget>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<button type="button" className="icon-button" onClick={close} aria-label="Return to pause menu">×</button>
|
||||
<button type="button" className="icon-button" onClick={close} aria-label="Close button assignments">×</button>
|
||||
</header>
|
||||
|
||||
<nav className="ability-bindings__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) => (
|
||||
<ControllerButton
|
||||
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>
|
||||
</ControllerButton>
|
||||
))}
|
||||
</nav>
|
||||
</nav>}
|
||||
|
||||
<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">
|
||||
{bindingSlot("r1")}
|
||||
{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-all" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => requestRestore("all", "binding-restore-all")}>Restore all</ControllerButton>
|
||||
</div>
|
||||
</section>
|
||||
</section>}
|
||||
|
||||
<aside className="ability-bindings__picker" aria-live="polite">
|
||||
{restoreRequest ? (
|
||||
<>
|
||||
<p className="eyebrow">Confirm controller reset</p>
|
||||
<h3>{restoreRequest.scope === "all"
|
||||
<p className="eyebrow">Confirm {restoreRequest.layout === "wow" ? "WoW bar" : "controller"} reset</p>
|
||||
<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 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 all eight ${ACTION_LAYER_LABELS[restoreRequest.layer]} slots with their class defaults.`}</p>
|
||||
<div className="ability-bindings__choices ability-bindings__choices--confirm">
|
||||
@@ -353,16 +490,19 @@ function AbilityBindingsDialog() {
|
||||
</ControllerButton>
|
||||
</div>
|
||||
</>
|
||||
) : editingControl ? (
|
||||
) : editingControl !== null ? (
|
||||
<>
|
||||
<p className="eyebrow">Assign {bindingChord(layer, editingControl)}</p>
|
||||
<p className="eyebrow">Assign {controlLabel(editingControl)}</p>
|
||||
<h3>Choose an ability</h3>
|
||||
<p className="action-bar-missing-legend">Light blue: missing from action bars</p>
|
||||
<div className="ability-bindings__choices">
|
||||
{pickerAbilities.map((ability) => (
|
||||
<ControllerButton key={ability.id} controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""}`} onClick={() => assign(ability.id)}>
|
||||
<AbilityTooltipTarget key={ability.id} ability={tooltipData(ability)}>
|
||||
<ControllerButton controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""} ${isMissing(ability) ? "is-missing-from-bars" : ""}`} aria-label={`${ability.name}${isMissing(ability) ? ", missing from action bars" : ""}`} onClick={() => assign(ability.id)}>
|
||||
<AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} />
|
||||
<span><strong>{ability.name}</strong><small>{ability.unlockLevel > level ? `Unlocks at level ${ability.unlockLevel}` : ability.id === editingAbilityId ? "Current assignment" : ability.target === "friendly" ? "Friendly / self" : ability.target}</small></span>
|
||||
</ControllerButton>
|
||||
</AbilityTooltipTarget>
|
||||
))}
|
||||
<ControllerButton controlId="binding-choice-empty" selectedId={controller.selectedId} select={controller.select} type="button" className={editingAbilityId === null ? "is-current" : ""} onClick={() => assign(null)}>
|
||||
<span className="ability-bindings__empty" aria-hidden="true">×</span>
|
||||
@@ -372,26 +512,26 @@ function AbilityBindingsDialog() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="eyebrow">{ACTION_LAYER_LABELS[layer]} layer</p>
|
||||
<h3>Select a 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 className="ability-bindings__notice" role="status">{notice}</p>
|
||||
<p className="eyebrow">{isWow ? `Bar ${bar + 1}` : `${ACTION_LAYER_LABELS[layer]} layer`}</p>
|
||||
<h3>Select a {isWow ? "WoW bar" : "controller"} button</h3>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<p className="ability-bindings__notice" role="status">{notice}</p>
|
||||
<footer className="game-panel__footer">
|
||||
{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> 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>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AbilityBindingsPanel() {
|
||||
const open = useGameStore((state) => state.overlay === "bindings");
|
||||
return open ? <AbilityBindingsDialog /> : null;
|
||||
export function AbilityBindingsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
|
||||
const storeOpen = useGameStore((state) => state.overlay === "bindings");
|
||||
return (open ?? storeOpen) ? <AbilityBindingsDialog onClose={onClose} /> : null;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import type { AbilityTarget } from "../game/abilityCatalog";
|
||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import {
|
||||
ACTION_LAYER_LABELS,
|
||||
@@ -21,6 +22,10 @@ export interface ActionBarItem {
|
||||
disabled?: boolean;
|
||||
lockedUntilLevel?: number;
|
||||
target?: AbilityTarget;
|
||||
cooldownSeconds?: number;
|
||||
castSeconds?: number;
|
||||
range?: number;
|
||||
talentLocked?: boolean;
|
||||
}
|
||||
|
||||
export type ActionBarItems = Readonly<Record<ActionBindingControl, ActionBarItem | null>>;
|
||||
@@ -94,6 +99,7 @@ function ActionSlot({
|
||||
: "";
|
||||
|
||||
return (
|
||||
<AbilityTooltipTarget ability={item} bindingLabel={chord}>
|
||||
<button
|
||||
type="button"
|
||||
className={`action-slot action-slot--${control} ${item ? "" : "action-slot--empty"} ${unavailable ? "action-slot--disabled" : ""} ${item?.target === "friendly" || item?.target === "any" ? "action-slot--friendly" : ""} ${cooldown > 0 ? "action-slot--cooldown" : ""}`}
|
||||
@@ -124,6 +130,7 @@ function ActionSlot({
|
||||
<em>{item!.cooldownRemaining! < 10 ? item!.cooldownRemaining!.toFixed(1) : Math.ceil(item!.cooldownRemaining!)}</em>
|
||||
)}
|
||||
</button>
|
||||
</AbilityTooltipTarget>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { ActionBar, ActionIcon, type ActionBarItem, type ActionBarItems } from "./ActionBar";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { useArenaStore } from "../game/arenaStore";
|
||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||
|
||||
const PC_ACTION_CONTROLS = ["face-bottom", "face-left", "face-top", "face-right", "r1", "r2", "dpad-left", "dpad-right"] as const;
|
||||
|
||||
@@ -38,6 +39,7 @@ export function ArenaActionBar({ character, actor, layer, inputMode, blocked, on
|
||||
resourceCost: ability.cost.amount, resourceLabel: kit.resource.name,
|
||||
cooldownDuration: Math.max(ability.cooldownMs, ability.gcdMs) / 1000,
|
||||
cooldownRemaining: remaining,
|
||||
cooldownSeconds: ability.cooldownMs / 1000, castSeconds: ability.castTimeMs / 1000, range: ability.range.max,
|
||||
disabled: blocked || !actor?.alive || !learned || remaining > 0 || actor.effects.some((effect) => effect.id.startsWith("casting:")),
|
||||
lockedUntilLevel: learned ? undefined : ability.unlockLevel,
|
||||
}];
|
||||
@@ -65,12 +67,13 @@ export function ArenaActionBar({ character, actor, layer, inputMode, blocked, on
|
||||
{desktopBars.map((bar, index) => <section key={index} aria-label={`Action bar ${index + 1}`} style={{ gridTemplateColumns: `repeat(${bar.columns}, minmax(0, 1fr))` }}>
|
||||
{bar.slots.map((slot, slotIndex) => {
|
||||
const item = slot.abilityId ? items[slot.abilityId] : null;
|
||||
return <button type="button" key={slotIndex} disabled={!item || item.disabled}
|
||||
return <AbilityTooltipTarget key={slotIndex} ability={item} bindingLabel={slot.label}>
|
||||
<button type="button" aria-disabled={!item || item.disabled}
|
||||
aria-label={`${item?.name ?? "Empty"}, ${slot.label}`} title={item?.description ?? "Empty binding"}
|
||||
onClick={() => item && onCast(item.id)}>
|
||||
onClick={() => item && !item.disabled && onCast(item.id)}>
|
||||
<kbd>{slot.label}</kbd><ActionIcon item={item ?? null} /><span>{item?.name ?? "Empty"}</span>
|
||||
{item && (item.cooldownRemaining ?? 0) > 0 && <em>{item.cooldownRemaining!.toFixed(1)}</em>}
|
||||
</button>;
|
||||
</button></AbilityTooltipTarget>;
|
||||
})}
|
||||
</section>)}
|
||||
</nav>
|
||||
|
||||
@@ -8,12 +8,13 @@ describe("boss card display", () => {
|
||||
it("shows per-rarity copy totals and every dungeon for the visible boss", () => {
|
||||
const boss = BOSS_CARD_CATALOG[0];
|
||||
const markup = renderToStaticMarkup(createElement(BossCardCollection, {
|
||||
onClose: () => {}, characterName: "Collector",
|
||||
onClose: () => {},
|
||||
collection: { counts: { [boss.key]: { normal: 7, rare: 1 } }, lastClaims: {} },
|
||||
}));
|
||||
expect(markup).toContain("7 copies");
|
||||
expect(markup).toContain("1 copy");
|
||||
expect(markup).toContain("8 total copies");
|
||||
expect(markup).toContain("Account-wide collection log");
|
||||
expect(markup).toContain("Group by dungeon");
|
||||
expect(markup).toContain("Group by rarity");
|
||||
for (const dungeon of boss.dungeons) expect(markup).toContain(dungeon.title);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { lazy, Suspense, useMemo, useState, type CSSProperties } from "react";
|
||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { BOSS_CARD_CATALOG, groupBossCards, type BossCardGrouping } from "../game/bossCardCatalog";
|
||||
import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, emptyBossCardCollection, type BossCardCollection as Collection } from "../game/bossCards";
|
||||
import { BOSS_CARD_RARITIES, BOSS_CARD_RARITY_INFO, type BossCardCollection as Collection } from "../game/bossCards";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { dungeonContentIsUsable } from "../content/contentManager";
|
||||
@@ -11,13 +10,11 @@ import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import "./bossCards.css";
|
||||
|
||||
const BossCardPortrait = lazy(() => import("./BossCardPortrait"));
|
||||
const EMPTY_COLLECTION = emptyBossCardCollection();
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
export function BossCardCollection({ onClose, collection, characterName }: {
|
||||
export function BossCardCollection({ onClose, collection }: {
|
||||
readonly onClose: () => void;
|
||||
readonly collection: Collection;
|
||||
readonly characterName: string;
|
||||
}) {
|
||||
useContentManagerSnapshot();
|
||||
const [earnedOnly, setEarnedOnly] = useState(false);
|
||||
@@ -52,7 +49,7 @@ export function BossCardCollection({ onClose, collection, characterName }: {
|
||||
return <div className="modal-backdrop boss-collection-backdrop" role="presentation" onPointerDown={(event) => event.stopPropagation()}>
|
||||
<section className="boss-collection" role="dialog" aria-modal="true" aria-labelledby="boss-collection-title">
|
||||
<header className="boss-collection__header">
|
||||
<div><p className="eyebrow">{characterName}’s collection log</p><h2 id="boss-collection-title">Boss Cards</h2></div>
|
||||
<div><p className="eyebrow">Account-wide collection log</p><h2 id="boss-collection-title">Boss Cards</h2></div>
|
||||
<ControllerButton {...controlProps} controlId="cards-close" className="icon-button" onClick={onClose} aria-label="Close boss card collection">×</ControllerButton>
|
||||
</header>
|
||||
<div className="boss-collection__summary">
|
||||
@@ -100,11 +97,7 @@ export function BossCardCollection({ onClose, collection, characterName }: {
|
||||
}
|
||||
|
||||
export default function BossCardCollectionScreen({ onClose }: { readonly onClose?: () => void }) {
|
||||
const savedCharacter = useShellStore(selectedCharacter);
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const phase = useShellStore((state) => state.phase);
|
||||
const liveCollection = useCombatStore((state) => state.bossCards);
|
||||
const character = activeCharacter ?? savedCharacter;
|
||||
const collection = useShellStore((state) => state.bossCards);
|
||||
return <BossCardCollection onClose={onClose ?? (() => useGameStore.getState().closeOverlay())}
|
||||
collection={phase === "game" ? liveCollection : character?.bossCards ?? EMPTY_COLLECTION} characterName={character?.name ?? "Adventurer"} />;
|
||||
collection={collection} />;
|
||||
}
|
||||
@@ -10,11 +10,12 @@ const GUIDE_CATALOG = buildBossGuideCatalog(DUNGEON_DEFINITIONS, BOSS_GUIDE_OVER
|
||||
|
||||
function BossGuideDialog() {
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const bossTest = useGameStore((state) => state.bossTest);
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === activeDungeonId) ?? GUIDE_CATALOG[0];
|
||||
const initialDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === (bossTest?.sourceDungeonId ?? activeDungeonId)) ?? GUIDE_CATALOG[0];
|
||||
const [selectedDungeonId, setSelectedDungeonId] = useState(initialDungeon?.id ?? "");
|
||||
const selectedDungeon = GUIDE_CATALOG.find((dungeon) => dungeon.id === selectedDungeonId) ?? initialDungeon;
|
||||
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses[0]?.id ?? "");
|
||||
const [selectedBossId, setSelectedBossId] = useState(selectedDungeon?.bosses.find((boss) => boss.entityId === bossTest?.entityId)?.id ?? selectedDungeon?.bosses[0]?.id ?? "");
|
||||
const selectedBoss = selectedDungeon?.bosses.find((boss) => boss.id === selectedBossId)
|
||||
?? selectedDungeon?.bosses[0];
|
||||
const close = () => openOverlay("pause");
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 />} />;
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeterWidget } from "./DpsMeterWidget";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { currentDisplayLayout } from "../platform/displayLayout";
|
||||
import { DungeonCompanionHud } from "./DungeonCompanionHud";
|
||||
|
||||
function safePercent(current: number, maximum: number): number {
|
||||
if (!Number.isFinite(current) || !Number.isFinite(maximum) || maximum <= 0) return 0;
|
||||
@@ -166,12 +168,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
<p className="companion-kicker">Ready on both screens</p>
|
||||
<h2>One roster. Two ways to play.</h2>
|
||||
<div className="companion-feature-grid">
|
||||
<article><b>01</b><span><strong>Local account</strong><small>Salted password verifier on this device</small></span></article>
|
||||
<article><b>01</b><span><strong>Your roster</strong><small>Choose your adventurer after signing in</small></span></article>
|
||||
<article><b>02</b><span><strong>Offline play</strong><small>No sign-in or network required</small></span></article>
|
||||
<article><b>03</b><span><strong>PC fallback</strong><small>This panel opens as an overlay</small></span></article>
|
||||
</div>
|
||||
</main>
|
||||
<footer><span>Top screen owns account entry</span><b>THOR / 620 x 540</b></footer>
|
||||
<footer><span>Sign in on the top screen</span><b>THOR / 3.92 INCHES</b></footer>
|
||||
</FrontSurface>
|
||||
);
|
||||
}
|
||||
@@ -297,6 +299,19 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
return <CompanionDungeonSelection selected={selected} send={send} />;
|
||||
}
|
||||
|
||||
if (snapshot.phase === "class-hall") {
|
||||
const definition = selected ? classById(selected.classId) : null;
|
||||
return <FrontSurface bottom className="companion-panel companion-create" ariaLabel="Class Hall companion screen">
|
||||
<header><BrandMark compact /><b>CLASS HALL</b></header>
|
||||
<main><section><p className="companion-kicker">{selected?.name ?? "Your adventurer"}</p>
|
||||
<h2>{definition?.name ?? "Choose your path"}</h2>
|
||||
<p>Choose your specialization on the top screen.</p>
|
||||
<p>Your specialization determines your role and starting equipment.</p>
|
||||
</section></main>
|
||||
<footer className="companion-actions"><button type="button" onClick={() => send({ name: "returnToMainMenu" })}>Back to main menu</button></footer>
|
||||
</FrontSurface>;
|
||||
}
|
||||
|
||||
if (snapshot.phase === "manastorms") {
|
||||
return <ShelvedCompanionPanel selected={selected} send={send} />;
|
||||
}
|
||||
@@ -510,11 +525,12 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
|
||||
}
|
||||
|
||||
export function LocalCompanionPanel() {
|
||||
useShellStore();
|
||||
const shell = useShellStore();
|
||||
useGameStore();
|
||||
useCombatStore();
|
||||
usePartyStore();
|
||||
useUiLayoutStore();
|
||||
useDpsMeterStore();
|
||||
if (shell.phase === "game" && currentDisplayLayout() !== "single") return <DungeonCompanionHud send={executeCompanionCommand} />;
|
||||
return <CompanionPanel snapshot={currentCompanionSnapshot()} send={executeCompanionCommand} />;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { actionKeyLabel, type HudElementId } from "../game/uiLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { ActionIcon, type ActionBarItem } from "./ActionBar";
|
||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||
|
||||
export function DesktopActionBars({ items }: { items: Readonly<Record<string, ActionBarItem>> }) {
|
||||
const ui = useUiLayoutStore();
|
||||
@@ -19,7 +20,8 @@ export function DesktopActionBars({ items }: { items: Readonly<Record<string, Ac
|
||||
{bar.slots.map((button, s) => {
|
||||
const item = button.abilityId ? items[button.abilityId] : null;
|
||||
const selected = editing && ui.selectedButton?.bar === b && ui.selectedButton.slot === s;
|
||||
return <button type="button" key={s} className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`}
|
||||
return <AbilityTooltipTarget key={s} ability={item} bindingLabel={actionKeyLabel(button.key)}>
|
||||
<button type="button" className={`desktop-action-button ${item?.disabled ? "is-unavailable" : ""} ${selected ? "is-selected" : ""}`}
|
||||
aria-label={`Bar ${b + 1} button ${s + 1}: ${item?.name ?? "Empty"}${button.key ? `, ${actionKeyLabel(button.key)}` : ""}`}
|
||||
aria-disabled={!editing && (!item || item.disabled || blocked)} aria-pressed={editing ? selected : undefined}
|
||||
title={item ? `${item.name}\n${item.description ?? ""}\n${actionKeyLabel(button.key)}${item.lockedUntilLevel ? ` · Level ${item.lockedUntilLevel}` : ""}` : `Empty · ${actionKeyLabel(button.key)}`}
|
||||
@@ -30,7 +32,7 @@ export function DesktopActionBars({ items }: { items: Readonly<Record<string, Ac
|
||||
<ActionIcon item={item ?? null} /><kbd>{actionKeyLabel(button.key)}</kbd>
|
||||
{item && item.cooldownRemaining! > .05 && <span className="desktop-action-button__cooldown">{item.cooldownRemaining! < 10 ? item.cooldownRemaining!.toFixed(1) : Math.ceil(item.cooldownRemaining!)}</span>}
|
||||
{editing && !item && <small>{s + 1}</small>}
|
||||
</button>;
|
||||
</button></AbilityTooltipTarget>;
|
||||
})}
|
||||
</section>
|
||||
</EditableHudElement>; })}</>;
|
||||
|
||||
+3
-1
@@ -4,11 +4,13 @@ import { resolveDpsMeterDisplay } from "../game/uiLayout";
|
||||
import { currentDpsMeterSnapshot, useDpsMeterStore } from "../game/dpsMeterStore";
|
||||
import { EditableHudElement } from "./EditableHudElement";
|
||||
import { DpsMeterWidget } from "./DpsMeterWidget";
|
||||
import { useHudSurface } from "./HudSurface";
|
||||
|
||||
export function DpsMeter() {
|
||||
const { surface } = useHudSurface();
|
||||
useDpsMeterStore();
|
||||
const ui = useUiLayoutStore();
|
||||
const layout = ui.draft ?? ui.preferences.current[currentUiDevice()];
|
||||
if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== "main") return null;
|
||||
if (resolveDpsMeterDisplay(layout, currentDisplayLayout() !== "single") !== (surface === "top" ? "main" : "companion")) return null;
|
||||
return <EditableHudElement id="dps"><DpsMeterWidget snapshot={currentDpsMeterSnapshot()} width={layout.elements.dps.width} /></EditableHudElement>;
|
||||
}
|
||||
@@ -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,7 +10,10 @@ import {
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
|
||||
export function DungeonInteractionPrompt() {
|
||||
export function DungeonInteractionPrompt({ interaction, onInteract }: {
|
||||
interaction?: { prompt: string; name: string; label: string } | null;
|
||||
onInteract?: () => void;
|
||||
} = {}) {
|
||||
const label = useInputBindingLabels();
|
||||
const playerPosition = useGameStore((state) => state.playerPosition);
|
||||
const inputMode = useGameStore((state) => state.inputMode);
|
||||
@@ -21,19 +24,20 @@ export function DungeonInteractionPrompt() {
|
||||
activeDungeonEncounterRevision,
|
||||
);
|
||||
void playerPosition;
|
||||
const candidate = overlay ? null : activeDungeonInteractionCandidate();
|
||||
const local = interaction === undefined && !overlay ? activeDungeonInteractionCandidate() : null;
|
||||
const candidate = interaction ?? (local ? { prompt: local.object.interaction!.prompt, name: local.object.name, label: local.state.label } : null);
|
||||
if (!candidate) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="dungeon-interaction-prompt"
|
||||
onClick={() => interactWithActiveDungeonObject()}
|
||||
aria-label={`${candidate.object.interaction!.prompt}: ${candidate.object.name}`}
|
||||
onClick={onInteract ?? (() => interactWithActiveDungeonObject())}
|
||||
aria-label={`${candidate.prompt}: ${candidate.name}`}
|
||||
>
|
||||
<span className="dungeon-interaction-prompt__key">{label(inputMode === "gamepad" ? "controller" : "pc", "loot")}</span>
|
||||
<span>
|
||||
<strong>{candidate.object.interaction!.prompt}</strong>
|
||||
<small>{candidate.object.name} · {candidate.state.label}</small>
|
||||
<strong>{candidate.prompt}</strong>
|
||||
<small>{candidate.name} · {candidate.label}</small>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ControllerButton } from "./ControllerButton";
|
||||
|
||||
export function DungeonIssueReportPanel() {
|
||||
const dungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const bossTest = useGameStore((state) => state.bossTest);
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const [comment, setComment] = useState("");
|
||||
@@ -16,12 +17,12 @@ export function DungeonIssueReportPanel() {
|
||||
const [error, setError] = useState("");
|
||||
const busy = useRef(false);
|
||||
const form = useRef<HTMLFormElement>(null);
|
||||
const back = () => { if (!busy.current) openOverlay("pause"); };
|
||||
const back = () => { if (!busy.current) openOverlay(bossTest ? "boss-test-review" : "pause"); };
|
||||
const canSubmit = Boolean(comment.trim()) && comment.length <= DUNGEON_REPORT_COMMENT_MAX_LENGTH && !submitting;
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "report-back", run: back, enabled: !submitting },
|
||||
{ id: "report-submit", run: () => form.current?.requestSubmit(), enabled: canSubmit && !submitted },
|
||||
], [canSubmit, openOverlay, submitted, submitting]);
|
||||
], [bossTest, canSubmit, openOverlay, submitted, submitting]);
|
||||
const controller = useMenuController(actions, { initialId: "report-back", onBack: back });
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
@@ -31,7 +32,8 @@ export function DungeonIssueReportPanel() {
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
await submitDungeonReport(session, dungeonId, comment);
|
||||
await submitDungeonReport(session, bossTest?.sourceDungeonId ?? dungeonId, comment,
|
||||
bossTest ? { bossKey: bossTest.key, bossName: bossTest.name } : undefined);
|
||||
setSubmitted(true);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : "Your report could not be submitted. Please try again.");
|
||||
@@ -44,9 +46,9 @@ export function DungeonIssueReportPanel() {
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="pause-menu dungeon-issue-report" role="dialog" aria-modal="true" aria-labelledby="issue-report-title">
|
||||
<p className="eyebrow">Dungeon feedback</p>
|
||||
<h2 id="issue-report-title">Report an issue</h2>
|
||||
<p>{dungeonById(dungeonId)?.title ?? dungeonId}</p>
|
||||
<p className="eyebrow">{bossTest ? "Boss feedback" : "Dungeon feedback"}</p>
|
||||
<h2 id="issue-report-title">{bossTest ? "Submit a boss report" : "Report an issue"}</h2>
|
||||
<p>{bossTest ? `${bossTest.name} · ${dungeonById(bossTest.sourceDungeonId)?.title ?? bossTest.sourceDungeonId}` : dungeonById(dungeonId)?.title ?? dungeonId}</p>
|
||||
{submitted ? <p role="status">Report submitted. You can follow its status in View Reports on the main menu.</p> : (
|
||||
<form ref={form} onSubmit={(event) => void submit(event)}>
|
||||
<label htmlFor="dungeon-issue-comment">Describe the issue</label>
|
||||
@@ -62,7 +64,7 @@ export function DungeonIssueReportPanel() {
|
||||
</form>
|
||||
)}
|
||||
<ControllerButton controlId="report-back" selectedId={controller.selectedId} select={controller.select}
|
||||
className="button" type="button" onClick={back} disabled={submitting}>Back to pause menu</ControllerButton>
|
||||
className="button" type="button" onClick={back} disabled={submitting}>{bossTest ? "Back to boss review" : "Back to pause menu"}</ControllerButton>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ function ReportCard({ report, canManage, saving, onUpdate, selectedId, select }:
|
||||
return (
|
||||
<article className={`dungeon-report-card ${report.status === "Closed" ? "is-closed" : ""}`}>
|
||||
<header><h2>{dungeonById(report.dungeonId)?.title ?? report.dungeonId}</h2><span className="dungeon-report-status">{report.status}</span></header>
|
||||
{report.bossName && <strong>Boss: {report.bossName}</strong>}
|
||||
<p className="dungeon-report-comment">{report.comment}</p>
|
||||
<small>Submitted by {report.submittedBy} · <time dateTime={new Date(report.createdAt).toISOString()}>{new Date(report.createdAt).toLocaleString()}</time></small>
|
||||
{report.updatedBy && <small>Updated by {report.updatedBy} · {new Date(report.updatedAt).toLocaleString()}</small>}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { HUD_ELEMENTS, type HudElementId, type HudElementLayout } from "../game/uiLayout";
|
||||
import { HUD_ELEMENTS, resolveHudScreen, type HudElementId, type HudElementLayout } from "../game/uiLayout";
|
||||
import { currentDisplayLayout } from "../platform/displayLayout";
|
||||
import { useHudSurface } from "./HudSurface";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useGameStore } from "../game/store";
|
||||
import "./uiLayout.css";
|
||||
@@ -22,23 +24,26 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
|
||||
onChange?: (update: Partial<HudElementLayout>) => void;
|
||||
}) {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
const surface = useHudSurface();
|
||||
const dualScreen = currentDisplayLayout() !== "single";
|
||||
const editing = editingOverride ?? overlay === "ui-layout";
|
||||
const state = useUiLayoutStore();
|
||||
const layout = state.draft ?? state.preferences.current[currentUiDevice()];
|
||||
const element = elementLayout ?? layout.elements[id];
|
||||
const screen = resolveHudScreen(id, layout, dualScreen);
|
||||
const [origin, setOrigin] = useState<{ x: number; y: number; width: number } | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [host, setHost] = useState<Element | null>(null);
|
||||
const drag = useRef<{ pointerId: number; dx: number; dy: number } | null>(null);
|
||||
const [bounds, setBounds] = useState({ width: 0, height: 0, panelWidth: 0, panelHeight: 0 });
|
||||
const positioned = editing || element.position !== null;
|
||||
const positioned = editing || element.position !== null || (dualScreen && surface.surface === "bottom" && !["player", "actions", "cast", "experience", "dps"].includes(id));
|
||||
const fallback = FALLBACKS[id] ?? [.5, .35, 250];
|
||||
const position = element.position ?? (bottomAnchor !== undefined
|
||||
? { x: .5, y: 1 - bottomAnchor / Math.max(1, bounds.height) }
|
||||
: origin ?? { x: fallback[0], y: fallback[1] });
|
||||
const width = intrinsicWidth ?? element.width ?? origin?.width ?? fallback[2];
|
||||
|
||||
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector]);
|
||||
useLayoutEffect(() => { if (ref.current) setHost(ref.current.closest(hostSelector)); }, [hostSelector, screen]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (positioned || !ref.current) return;
|
||||
@@ -66,18 +71,19 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(node); observer.observe(host); measure();
|
||||
return () => observer.disconnect();
|
||||
}, [positioned, host, hostSelector]);
|
||||
}, [positioned, host, hostSelector, screen]);
|
||||
|
||||
const updatePosition = (x: number, y: number) => {
|
||||
const update = { position: { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) }, width };
|
||||
if (onChange) onChange(update);
|
||||
else if (surface.onChange) surface.onChange(id, update);
|
||||
else state.updateDraft((draft) => ({ ...draft, elements: { ...draft.elements, [id]: { ...draft.elements[id], ...update } } }));
|
||||
};
|
||||
const left = Math.max(6, Math.min(Math.max(6, bounds.width - bounds.panelWidth - 6), position.x * bounds.width - bounds.panelWidth / 2));
|
||||
const top = Math.max(6, Math.min(Math.max(6, bounds.height - bounds.panelHeight - 6), position.y * bounds.height - bounds.panelHeight / 2));
|
||||
const style = positioned ? { left, top, width: `min(${width}px, calc(100% - 12px))` } as CSSProperties : undefined;
|
||||
const content = <div ref={ref} data-hud-element={id} style={style}
|
||||
className={`editable-hud ${positioned ? "editable-hud--positioned" : "editable-hud--default"} ${editing ? "editable-hud--editing" : ""} ${!element.visible ? editing ? "editable-hud--ghost" : "editable-hud--hidden" : ""}`}
|
||||
className={`editable-hud ${positioned ? "editable-hud--positioned" : "editable-hud--default"} ${editing ? "editable-hud--editing" : ""} ${state.selectedElement === id ? "editable-hud--selected" : ""} ${!element.visible ? editing ? "editable-hud--ghost" : "editable-hud--hidden" : ""}`}
|
||||
onClickCapture={(event) => { if (editing && !id.startsWith("bar-")) { event.preventDefault(); event.stopPropagation(); useUiLayoutStore.setState({ selectedElement: id }); } }}>
|
||||
{children}
|
||||
{editing && <button type="button" className="editable-hud__handle" aria-label={`Move ${HUD_ELEMENTS[id]}`} title="Drag to move; arrow keys nudge"
|
||||
@@ -102,5 +108,6 @@ export function EditableHudElement({ id, children, intrinsicWidth, bottomAnchor,
|
||||
updatePosition(position.x + delta[0] * .01, position.y + delta[1] * .01);
|
||||
}}>{HUD_ELEMENTS[id]}{!element.visible ? " · Hidden" : ""}</button>}
|
||||
</div>;
|
||||
if (dualScreen && screen !== surface.surface) return null;
|
||||
return positioned && host ? createPortal(content, host) : content;
|
||||
}
|
||||
+27
-9
@@ -1,4 +1,7 @@
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { HudSurfaceContext } from "./HudSurface";
|
||||
import type { HudElementId, HudElementLayout } from "../game/uiLayout";
|
||||
import { DungeonInteractionPrompt } from "./DungeonInteractionPrompt";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -168,7 +171,15 @@ export function DungeonCompletePrompt({
|
||||
);
|
||||
}
|
||||
|
||||
export function Hud() {
|
||||
export function Hud({ surface = "top", onLayoutChange, completion, onFinish, onResurrect, interaction, onInteract }: {
|
||||
surface?: "top" | "bottom";
|
||||
onLayoutChange?: (id: HudElementId, update: Partial<HudElementLayout>) => void;
|
||||
completion?: boolean;
|
||||
onFinish?: () => void;
|
||||
onResurrect?: (checkpointId: string) => void;
|
||||
interaction?: { prompt: string; name: string; label: string } | null;
|
||||
onInteract?: () => void;
|
||||
} = {}) {
|
||||
const bindingLabel = useInputBindingLabels();
|
||||
const ui = useUiLayoutStore();
|
||||
const desktopBars = currentUiDevice() === "pc" && (ui.draft ?? ui.preferences.current.pc).wowBars;
|
||||
@@ -230,7 +241,7 @@ export function Hud() {
|
||||
);
|
||||
const runProgress = useDungeonRunStore((state) => state.progress);
|
||||
const finalBossDefeated = runProgress?.dungeonId === activeDungeonId && runProgress.finishedAt !== null;
|
||||
const dungeonComplete = gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId));
|
||||
const dungeonComplete = completion ?? (gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId)));
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setClock(Date.now()), 100);
|
||||
@@ -282,6 +293,10 @@ export function Hud() {
|
||||
disabled: talentLocked || level < ability.unlockLevel || health <= 0 || remaining > 0 || Boolean(activeCast) || cannotPay,
|
||||
lockedUntilLevel: level < ability.unlockLevel ? ability.unlockLevel : undefined,
|
||||
target: ability.target,
|
||||
cooldownSeconds: ability.cooldownMs / 1_000,
|
||||
castSeconds: ability.castTimeMs / 1_000,
|
||||
range: ability.range.max,
|
||||
talentLocked,
|
||||
};
|
||||
return [catalogAbility.id, item];
|
||||
})), [assignedAbilities, activeCast, auras, classColor, classId, clock, cooldowns, globalCooldownEndsAt, health, level, maxHealth, maxResource, maxResourcePools, resource, resourceName, resourcePools, resourceTone, talentModifiers, talentRanks]);
|
||||
@@ -391,12 +406,14 @@ export function Hud() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
|
||||
<HudSurfaceContext.Provider value={{ surface, onChange: onLayoutChange, onResurrect }}>
|
||||
<div className={`hud hud-surface-${surface}`} style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
|
||||
<ShelvedHud />
|
||||
<DpsMeter />
|
||||
<BossLootWindow />
|
||||
{surface === "top" && <BossLootWindow />}
|
||||
<EditableHudElement id="interaction"><DungeonInteractionPrompt interaction={interaction} onInteract={onInteract} /></EditableHudElement>
|
||||
<EditableHudElement id="completion">{dungeonComplete ? (
|
||||
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon}
|
||||
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={onFinish ?? finishDungeon}
|
||||
finalBossOnly={!activeDungeonRunIsComplete(activeDungeonId)} durationMs={runProgress?.timed ? runProgress.durationMs : null}
|
||||
onResults={finalBossDefeated ? () => useGameStore.getState().openOverlay("dungeon-results") : undefined} />
|
||||
) : null}</EditableHudElement>
|
||||
@@ -409,7 +426,7 @@ export function Hud() {
|
||||
<EditableHudElement id="party"><PartyFrames /></EditableHudElement>
|
||||
<EditableHudElement id="summons"><SummonFrames now={clock} /></EditableHudElement>
|
||||
|
||||
{pendingGroundTarget ? (
|
||||
{surface === "top" && pendingGroundTarget ? (
|
||||
<section className="ground-target-reticle" role="status" aria-live="polite">
|
||||
<div aria-hidden="true"><i /><b /></div>
|
||||
<strong>Place ground effect</strong>
|
||||
@@ -507,7 +524,7 @@ export function Hud() {
|
||||
items={actionItems}
|
||||
activeLayer={actionLayer}
|
||||
onActivate={castBinding}
|
||||
inputMode={inputMode}
|
||||
inputMode={surface === "bottom" ? "gamepad" : inputMode}
|
||||
feedback={settings.showFloatingCombatText ? feedback?.message ?? null : null}
|
||||
/></EditableHudElement>}
|
||||
</div>
|
||||
@@ -527,8 +544,8 @@ export function Hud() {
|
||||
].map((asset) => asset.fileName).join(" and ")}.`}</span>
|
||||
</div>
|
||||
)}</EditableHudElement>
|
||||
<LoadingOverlay />
|
||||
{health <= 0 ? (
|
||||
{surface === "top" && <LoadingOverlay />}
|
||||
{surface === "top" && health <= 0 ? (
|
||||
<DefeatedPrompt
|
||||
gameMode={gameMode}
|
||||
resurrectionCharges={resurrectionCharges}
|
||||
@@ -539,5 +556,6 @@ export function Hud() {
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</HudSurfaceContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -26,7 +26,7 @@ const PC_KEYS = [
|
||||
"Insert", "Home", "End", "PageUp", "PageDown", ...Array.from({ length: 10 }, (_, i) => `Numpad${i}`),
|
||||
"NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide", "NumpadDecimal", "NumpadEnter",
|
||||
];
|
||||
function KeybindsDialog() {
|
||||
function KeybindsDialog({ onClose }: { onClose?: () => void }) {
|
||||
const [device, setDevice] = useState<InputDevice>(() => defaultInputDevice(Capacitor.isNativePlatform(), currentUiDevice() === "mobile"));
|
||||
const [draft, setDraft] = useState(() => useUiLayoutStore.getState().preferences);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -57,7 +57,7 @@ function KeybindsDialog() {
|
||||
const shown = INPUT_ACTIONS.filter((entry) => (group === "All actions" || entry.group === group)
|
||||
&& (!conflictsOnly || conflicts.some((conflict) => conflict.id === entry.id))
|
||||
&& `${entry.label} ${detail(entry.id)} ${inputBindingLabel(device, bindings[entry.id])}`.toLowerCase().includes(search.toLowerCase()));
|
||||
const close = () => useGameStore.getState().openOverlay("options");
|
||||
const close = onClose ?? (() => useGameStore.getState().openOverlay("options"));
|
||||
const endCapture = () => { setListening(false); setCapturingInput(false); };
|
||||
const cancelEdit = () => { endCapture(); setEditing(null); setPending(null); setResetPending(false); };
|
||||
const apply = (id: string, key: string | null) => {
|
||||
@@ -211,7 +211,7 @@ function KeybindsDialog() {
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
export function KeybindsPanel() {
|
||||
const open = useGameStore((state) => state.overlay === "keybinds");
|
||||
return open ? <KeybindsDialog /> : null;
|
||||
export function KeybindsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
|
||||
const storeOpen = useGameStore((state) => state.overlay === "keybinds");
|
||||
return (open ?? storeOpen) ? <KeybindsDialog onClose={onClose} /> : null;
|
||||
}
|
||||
@@ -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>
|
||||
} />;
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
|
||||
import { DungeonReportsScreen } from "./DungeonReportsScreen";
|
||||
import { MainMenuCharacterEditor, type MainMenuEditor } from "./MainMenuCharacterEditor";
|
||||
const BossCardCollectionScreen = lazy(() => import("./BossCardCollection"));
|
||||
const BossTestingScreen = lazy(() => import("./BossTestingScreen").then((module) => ({ default: module.BossTestingScreen })));
|
||||
|
||||
const GAME_MODES = [
|
||||
{
|
||||
@@ -68,16 +70,22 @@ export function MainMenuScreen() {
|
||||
const [contentManagerOpen, setContentManagerOpen] = useState(false);
|
||||
const [reportsOpen, setReportsOpen] = useState(false);
|
||||
const [cardsOpen, setCardsOpen] = useState(false);
|
||||
const [bossTestingOpen, setBossTestingOpen] = useState(false);
|
||||
const [editor, setEditor] = useState<MainMenuEditor | null>(null);
|
||||
if (editor) return <MainMenuCharacterEditor editor={editor} onClose={() => setEditor(null)} />;
|
||||
if (bossTestingOpen) return <Suspense fallback={<p role="status">Opening Boss Testing…</p>}><BossTestingScreen onClose={() => setBossTestingOpen(false)} /></Suspense>;
|
||||
if (cardsOpen) return <Suspense fallback={<p role="status">Opening collection log…</p>}><BossCardCollectionScreen onClose={() => setCardsOpen(false)} /></Suspense>;
|
||||
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
|
||||
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
|
||||
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} />;
|
||||
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenEditor={setEditor} />;
|
||||
}
|
||||
|
||||
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
|
||||
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenEditor }: {
|
||||
readonly onOpenDownloads: () => void;
|
||||
readonly onOpenReports: () => void;
|
||||
readonly onOpenCards: () => void;
|
||||
readonly onOpenBossTesting: () => void;
|
||||
readonly onOpenEditor: (editor: MainMenuEditor) => void;
|
||||
}) {
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const rosterCharacter = useShellStore(selectedCharacter);
|
||||
@@ -118,11 +126,17 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
|
||||
run: returnToCharacters,
|
||||
neighbors: { up: "mode-dungeons", left: "mode-dungeons" },
|
||||
},
|
||||
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
|
||||
...(character ? [
|
||||
{ id: "mode-class-hall", run: openClassHall },
|
||||
{ id: "mode-talents", run: () => onOpenEditor("talents") },
|
||||
{ id: "mode-keybinds", run: () => onOpenEditor("keybinds") },
|
||||
{ id: "mode-bindings", run: () => onOpenEditor("bindings") },
|
||||
] : []),
|
||||
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
|
||||
{ id: "mode-reports", run: onOpenReports },
|
||||
{ id: "mode-cards", run: onOpenCards },
|
||||
], [character, isGm, onOpenCards, onOpenDownloads, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
{ id: "mode-boss-testing", run: onOpenBossTesting },
|
||||
], [character, isGm, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: "mode-dungeons",
|
||||
onBack: returnToCharacters,
|
||||
@@ -177,7 +191,25 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards }: {
|
||||
<p>Select a destination. More adventures will unlock here as they are built.</p>
|
||||
</header>
|
||||
<OnlineGroupPanel />
|
||||
<nav className="main-menu-mode-grid" aria-label="Game modes">
|
||||
<nav className="main-menu-mode-grid" aria-label="Main menu destinations">
|
||||
<ControllerButton controlId="mode-boss-testing" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenBossTesting}>
|
||||
<b aria-hidden="true">BT</b><span><strong>Boss Testing</strong><small>Choose a boss or fight a random encounter in an empty testing room.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
{character && <>
|
||||
<ControllerButton controlId="mode-talents" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("talents")}>
|
||||
<b aria-hidden="true">T</b><span><strong>{isRom ? "Skills" : "Talents"}</strong><small>{isRom ? "Review your primary, secondary, and elite skills." : "View your talent trees, spend points, or reset your build."}</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="mode-keybinds" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("keybinds")}>
|
||||
<b aria-hidden="true">K</b><span><strong>Keybinds</strong><small>View and change keyboard or controller bindings.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="mode-bindings" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("bindings")}>
|
||||
<b aria-hidden="true">A</b><span><strong>Button assignments</strong><small>Choose the abilities on your action bars and controller buttons.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
</>}
|
||||
<ControllerButton controlId="mode-cards" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenCards}>
|
||||
<b aria-hidden="true">◆</b><span><strong>Boss Cards</strong><small>Track your collection, rarities, and copies from every dungeon.</small></span><em>Open</em>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
@@ -413,6 +414,7 @@ export function ManastormSelectScreen() {
|
||||
const capped = !selected
|
||||
&& selectedSpellIds.length >= spellLoadoutModel.maxSelections;
|
||||
return (
|
||||
<AbilityTooltipTarget key={spell.id} ability={spell}>
|
||||
<ControllerButton
|
||||
key={spell.id}
|
||||
controlId={`manastorm-spell-${spell.id}`}
|
||||
@@ -420,8 +422,8 @@ export function ManastormSelectScreen() {
|
||||
select={controller.select}
|
||||
className={`manastorm-spell-option ${selected ? "is-active" : ""}`}
|
||||
type="button"
|
||||
disabled={capped}
|
||||
onClick={() => toggleSpell(spell.id)}
|
||||
aria-disabled={capped}
|
||||
onClick={() => { if (!capped) toggleSpell(spell.id); }}
|
||||
title={`${spell.description} · Client Spell.dbc ID ${spell.dbcSpellId}`}
|
||||
>
|
||||
<img src={spell.icon} alt="" aria-hidden="true" />
|
||||
@@ -430,6 +432,7 @@ export function ManastormSelectScreen() {
|
||||
<small>Spell {spell.dbcSpellId}</small>
|
||||
</span>
|
||||
</ControllerButton>
|
||||
</AbilityTooltipTarget>
|
||||
);
|
||||
})}
|
||||
{!spellLoadoutModel?.options.length ? (
|
||||
|
||||
@@ -20,6 +20,7 @@ import { auraDefinitionIsDispellableDebuff } from "./auraDisplay";
|
||||
import { partyResurrectionBlockedByBoss, resurrectDeadPartyMembers } from "../game/partyResurrection";
|
||||
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useHudSurface } from "./HudSurface";
|
||||
|
||||
const PARTY_COMMANDS: readonly {
|
||||
command: PartyCommand;
|
||||
@@ -212,6 +213,7 @@ function OnlineMemberFrame({
|
||||
|
||||
/** Compact roster for the player plus the selected number of targetable AI allies. */
|
||||
export function PartyFrames() {
|
||||
const { onResurrect } = useHudSurface();
|
||||
const character = useShellStore((state) => state.activeCharacter);
|
||||
const playerRole = useShellStore((state) => state.activeDungeonRole);
|
||||
const playerHealth = useCombatStore((state) => state.health);
|
||||
@@ -342,7 +344,7 @@ export function PartyFrames() {
|
||||
title={bossCombat ? "Unavailable during boss combat." : !onlineSessionOwnsWorldSimulation()
|
||||
? "Only the party's simulation host can resurrect NPC teammates."
|
||||
: "Resurrect fallen NPC teammates at the selected checkpoint. They will return to you."}
|
||||
onClick={() => resurrectDeadPartyMembers(checkpointId)}
|
||||
onClick={() => (onResurrect ?? resurrectDeadPartyMembers)(checkpointId)}
|
||||
>
|
||||
<strong>Rez dead members</strong>
|
||||
<small>{bossCombat ? "Unavailable during boss combat" : !onlineSessionOwnsWorldSimulation()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { sessionCanUseShelvedGmTools } from "../app/releaseFeatures";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { INPUT_ACTION_BY_ID } from "../input/keybinds";
|
||||
import { DungeonIssueReportPanel } from "./DungeonIssueReportPanel";
|
||||
import { BossTestingReviewPanel } from "./BossTestingReviewPanel";
|
||||
|
||||
function PauseDialog() {
|
||||
const bindingLabel = useInputBindingLabels();
|
||||
@@ -14,6 +15,7 @@ function PauseDialog() {
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
const reset = useGameStore((state) => state.resetAtEntrance);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const bossTest = useGameStore((state) => state.bossTest);
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||
@@ -50,12 +52,13 @@ function PauseDialog() {
|
||||
{ id: "pause-options", run: () => openOverlay("options") },
|
||||
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
|
||||
...(gameMode === "dungeon" ? [{ id: "pause-report-issue", run: () => openOverlay("report-issue") }] : []),
|
||||
...(bossTest ? [{ id: "pause-boss-testing-review", run: () => openOverlay("boss-test-review") }] : []),
|
||||
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
|
||||
...(gameMode === "manastorm"
|
||||
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
|
||||
: [{ id: "pause-entrance", run: returnToEntrance }]),
|
||||
{ id: "pause-main-menu", run: leaveDungeon },
|
||||
], [abandonManastormRun, closeOverlay, gameMode, isGm, openOverlay, reset, returnToMainMenu]);
|
||||
], [abandonManastormRun, bossTest, closeOverlay, gameMode, isGm, openOverlay, reset, returnToMainMenu]);
|
||||
const controller = useMenuController(actions, { initialId: "pause-resume", onBack: resume });
|
||||
|
||||
const menuButton = (id: string, label: string, run: () => void, primary = false) => (
|
||||
@@ -73,10 +76,11 @@ function PauseDialog() {
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="pause-menu" role="dialog" aria-modal="true" aria-labelledby="pause-title">
|
||||
<p className="eyebrow">Healer Man paused</p>
|
||||
<h2 id="pause-title">The cavern waits.</h2>
|
||||
<h2 id="pause-title">{bossTest ? `${bossTest.name} — Boss Testing` : "The cavern waits."}</h2>
|
||||
<p>Resume play or open any character and system menu from this hub.</p>
|
||||
<div className="pause-menu__actions">
|
||||
{menuButton("pause-resume", "Resume expedition", resume, true)}
|
||||
{bossTest && menuButton("pause-boss-testing-review", "Boss readiness & report", () => openOverlay("boss-test-review"))}
|
||||
{menuButton("pause-party", "Party management", () => openOverlay("party"))}
|
||||
{menuButton("pause-inventory", "Inventory & equipment", () => openOverlay("inventory"))}
|
||||
{menuButton("pause-boss-cards", "Boss card collection", () => openOverlay("boss-cards"))}
|
||||
@@ -92,7 +96,7 @@ function PauseDialog() {
|
||||
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
|
||||
{gameMode === "manastorm"
|
||||
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
|
||||
: menuButton("pause-entrance", "Return to entrance", returnToEntrance)}
|
||||
: menuButton("pause-entrance", bossTest ? "Restart boss test" : "Return to entrance", returnToEntrance)}
|
||||
{menuButton("pause-main-menu", "Return to main menu", leaveDungeon)}
|
||||
</div>
|
||||
<dl className="controls-list">
|
||||
@@ -110,5 +114,6 @@ function PauseDialog() {
|
||||
export function PauseMenu() {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
if (overlay === "report-issue") return <DungeonIssueReportPanel />;
|
||||
if (overlay === "boss-test-review") return <BossTestingReviewPanel />;
|
||||
return overlay === "pause" ? <PauseDialog /> : null;
|
||||
}
|
||||
@@ -20,9 +20,10 @@ function visibleGroup(ability: AbilityDefinition, primaryClassId: string): (type
|
||||
return "primary";
|
||||
}
|
||||
|
||||
export function RomSkillsPanel() {
|
||||
const open = useGameStore((state) => state.overlay === "talents");
|
||||
const close = useGameStore((state) => state.closeOverlay);
|
||||
export function RomSkillsPanel({ open, onClose }: { open?: boolean; onClose?: () => void } = {}) {
|
||||
const storeOpen = useGameStore((state) => state.overlay === "talents");
|
||||
const closeOverlay = useGameStore((state) => state.closeOverlay);
|
||||
const close = onClose ?? closeOverlay;
|
||||
const classId = useCombatStore((state) => state.classId);
|
||||
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
@@ -32,7 +33,7 @@ export function RomSkillsPanel() {
|
||||
{ id: "rom-skills-close", run: close },
|
||||
], [abilities, close]);
|
||||
const controller = useMenuController(actions, { initialId: abilities[0] ? `rom-skill-${abilities[0].id}` : "rom-skills-close", onBack: close });
|
||||
if (!open) return null;
|
||||
if (!(open ?? storeOpen)) return null;
|
||||
|
||||
const primary = classById(classId);
|
||||
const secondary = secondaryClassId ? classById(secondaryClassId) : null;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { assignedActionBarAbilityIds } from "../game/uiLayout";
|
||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||
|
||||
export interface SpellbookAbilityView {
|
||||
id: string;
|
||||
@@ -79,6 +83,11 @@ function SpellbookDialog({
|
||||
unlockedOnly,
|
||||
onUnlockedOnlyChange,
|
||||
}: SpellbookDialogProps) {
|
||||
const bindings = useCombatStore((state) => state.actionBindings);
|
||||
const ui = useUiLayoutStore();
|
||||
const device = currentUiDevice();
|
||||
const layout = ui.draft ?? ui.preferences.current[device];
|
||||
const assignedIds = assignedActionBarAbilityIds(layout, bindings, device === "pc" && layout.wowBars);
|
||||
const orderedAbilities = useMemo(
|
||||
() => spellbookAbilitiesForDisplay(abilities, playerLevel, false),
|
||||
[abilities, playerLevel],
|
||||
@@ -130,6 +139,7 @@ function SpellbookDialog({
|
||||
</header>
|
||||
|
||||
<div className="spellbook-panel__toolbar">
|
||||
<span className="action-bar-missing-legend">Light blue: missing from action bars</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`spellbook-panel__filter ${unlockedOnly ? "is-active" : ""}`}
|
||||
@@ -145,10 +155,15 @@ function SpellbookDialog({
|
||||
<div className="spellbook-panel__list" aria-label={unlockedOnly ? "Unlocked abilities" : "Known and locked abilities"}>
|
||||
{visibleAbilities.map((ability) => {
|
||||
const locked = !ability.known || ability.level > playerLevel;
|
||||
const missing = !locked && !ability.passive && !assignedIds.has(ability.id);
|
||||
const lockLabel = ability.talentLocked && !ability.known
|
||||
? "Learn in talents"
|
||||
: `Unlocks at level ${ability.level}`;
|
||||
return (
|
||||
<AbilityTooltipTarget key={ability.id} ability={{ ...ability,
|
||||
lockedUntilLevel: ability.level > playerLevel ? ability.level : undefined,
|
||||
talentLocked: ability.talentLocked && !ability.known,
|
||||
}}>
|
||||
<ControllerButton
|
||||
key={ability.id}
|
||||
controlId={`spell-${ability.id}`}
|
||||
@@ -158,9 +173,9 @@ function SpellbookDialog({
|
||||
setInspectedId(ability.id);
|
||||
}}
|
||||
type="button"
|
||||
className={`spellbook-ability ${locked ? "spellbook-ability--locked" : ""}`}
|
||||
className={`spellbook-ability ${locked ? "spellbook-ability--locked" : ""} ${missing ? "is-missing-from-bars" : ""}`}
|
||||
aria-disabled={locked}
|
||||
aria-label={locked ? `${ability.name}, ${lockLabel.toLowerCase()}` : `${ability.name}, unlocked`}
|
||||
aria-label={locked ? `${ability.name}, ${lockLabel.toLowerCase()}` : `${ability.name}, unlocked${missing ? ", missing from action bars" : ""}`}
|
||||
onFocus={() => setInspectedId(ability.id)}
|
||||
onClick={() => {
|
||||
setInspectedId(ability.id);
|
||||
@@ -184,6 +199,7 @@ function SpellbookDialog({
|
||||
? <em>{ability.talentLocked && !ability.known ? "Talent" : `Level ${ability.level}`}</em>
|
||||
: <em className="spellbook-ability__known">{ability.passive ? "Passive" : "Known"}</em>}
|
||||
</ControllerButton>
|
||||
</AbilityTooltipTarget>
|
||||
);
|
||||
})}
|
||||
{!visibleAbilities.length && (
|
||||
@@ -208,6 +224,7 @@ function SpellbookDialog({
|
||||
? "Learn in talents"
|
||||
: `Unlocks at level ${inspected.level}`
|
||||
: inspected.passive ? "Passive" : "Unlocked"}</dd></div>
|
||||
{!inspectedLocked && !inspected.passive && <div><dt>Action bars</dt><dd>{assignedIds.has(inspected.id) ? "Assigned" : "Missing from action bars"}</dd></div>}
|
||||
{inspected.rank !== undefined && <div><dt>Current rank</dt><dd>{inspected.rank} / {inspected.rankCount ?? inspected.rank}</dd></div>}
|
||||
{inspected.resourceCost !== undefined && <div><dt>Cost</dt><dd>{inspected.resourceCost} {inspected.resourceLabel}</dd></div>}
|
||||
{inspected.cooldownSeconds !== undefined && <div><dt>Cooldown</dt><dd>{inspected.cooldownSeconds || "None"}{inspected.cooldownSeconds ? " sec" : ""}</dd></div>}
|
||||
|
||||
@@ -13,7 +13,11 @@ function treeSigil(name: string): string {
|
||||
return name.split(/\s+/).map((word) => word[0]).join("").slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export default function TalentGameplayPanel({ readOnly = false }: { readOnly?: boolean } = {}) {
|
||||
export default function TalentGameplayPanel({ readOnly = false, open, onClose }: {
|
||||
readOnly?: boolean;
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
} = {}) {
|
||||
const classId = useCombatStore((state) => state.classId);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
const talentRanks = useCombatStore((state) => state.talentRanks);
|
||||
@@ -45,5 +49,7 @@ export default function TalentGameplayPanel({ readOnly = false }: { readOnly?: b
|
||||
onInvest={(nodeId) => { if (!readOnly) useCombatStore.getState().allocateTalent(nodeId); }}
|
||||
onReset={() => { if (!readOnly) useCombatStore.getState().resetTalents(); }}
|
||||
readOnly={readOnly}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
/>;
|
||||
}
|
||||
@@ -242,7 +242,7 @@ function TalentsDialog({
|
||||
<><strong>{availablePoints}</strong><span>points available</span></>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="icon-button" onClick={onClose} aria-label="Return to pause menu">×</button>
|
||||
<button type="button" className="icon-button" onClick={onClose} aria-label="Close talents">×</button>
|
||||
</header>
|
||||
|
||||
<p className="talents-panel__intro">
|
||||
@@ -436,7 +436,7 @@ function TalentsDialog({
|
||||
>Reset talents</ControllerButton>
|
||||
</div>
|
||||
<footer className="game-panel__footer talents-panel__footer">
|
||||
<span><kbd>D-pad</kbd> Navigate</span><span><kbd>A</kbd> Learn / inspect</span><span><kbd>B</kbd> Pause menu</span>
|
||||
<span><kbd>D-pad</kbd> Navigate</span><span><kbd>A</kbd> Learn / inspect</span><span><kbd>B</kbd> Back</span>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+40
-14
@@ -2,15 +2,18 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { retryCloudSave, useCloudSaveStatus } from "../app/onlineAccountClient";
|
||||
import { useCombatStore } from "../game/combatStore";
|
||||
import { defaultActionBarForCharacter } from "../game/abilityCatalog";
|
||||
import { abilityAtLevel, defaultActionBarForCharacter } from "../game/abilityCatalog";
|
||||
import { useGameStore } from "../game/store";
|
||||
import {
|
||||
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout,
|
||||
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout, resetDesktopAbilities,
|
||||
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
|
||||
resolveHudScreen, type HudScreen,
|
||||
} from "../game/uiLayout";
|
||||
import { INPUT_ACTION_BY_ID, conflictingInputActions } from "../input/keybinds";
|
||||
import { profileInputBindings } from "../game/keybindPreferences";
|
||||
import { useUiLayoutStore } from "../game/uiLayoutStore";
|
||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||
import { currentDisplayLayout } from "../platform/displayLayout";
|
||||
|
||||
export function UiLayoutEditor() {
|
||||
const open = useGameStore((state) => state.overlay === "ui-layout");
|
||||
@@ -18,6 +21,8 @@ export function UiLayoutEditor() {
|
||||
const session = useShellStore((state) => state.session);
|
||||
const cloudSave = useCloudSaveStatus();
|
||||
const abilities = useCombatStore((state) => state.abilities);
|
||||
const level = useCombatStore((state) => state.level);
|
||||
const talentRanks = useCombatStore((state) => state.talentRanks);
|
||||
const [tab, setTab] = useState<UiDevice>(ui.device);
|
||||
const [name, setName] = useState("My UI");
|
||||
const [slot, setSlot] = useState(0);
|
||||
@@ -30,13 +35,13 @@ export function UiLayoutEditor() {
|
||||
const file = useRef<HTMLInputElement>(null);
|
||||
const wasOpen = useRef(false);
|
||||
useEffect(() => {
|
||||
if (open && !wasOpen.current) {
|
||||
if (open && (!wasOpen.current || !useUiLayoutStore.getState().draft)) {
|
||||
useUiLayoutStore.getState().begin();
|
||||
setTab(useUiLayoutStore.getState().device); setMessage("");
|
||||
}
|
||||
if (!open && wasOpen.current) useUiLayoutStore.getState().finish(false);
|
||||
wasOpen.current = open;
|
||||
}, [open]);
|
||||
}, [open, ui.draft !== null]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
@@ -69,6 +74,7 @@ export function UiLayoutEditor() {
|
||||
}, [open]);
|
||||
if (!open || !ui.draft) return null;
|
||||
const draft = ui.draft;
|
||||
const dualScreen = currentDisplayLayout() !== "single";
|
||||
const attempt = (run: () => void) => { try { run(); } catch (error) { setMessage(error instanceof Error ? error.message : "Could not save this UI."); } };
|
||||
const saveAndClose = () => attempt(() => { ui.finish(true); useGameStore.getState().closeOverlay(); });
|
||||
const validName = name.trim().length > 0;
|
||||
@@ -87,7 +93,7 @@ export function UiLayoutEditor() {
|
||||
<button type="button" onClick={() => setDockRight(!dockRight)} aria-label={dockRight ? "Dock editor left" : "Dock editor right"}>{dockRight ? "←" : "→"}</button>
|
||||
<button type="button" onClick={() => setCollapsed(!collapsed)} aria-label={collapsed ? "Expand editor" : "Collapse editor"}>{collapsed ? "+" : "−"}</button></header>
|
||||
{!collapsed && <div className="ui-layout-editor__body">
|
||||
<p>Drag a widget’s label to move it. Hidden and inactive widgets appear as placeholders. Use the list to show them again.</p>
|
||||
<p>Choose a screen for each element below. Collapse this panel and drag a widget’s label to move it. Use the list to show hidden elements again.</p>
|
||||
<details open><summary>UI templates · 6 slots</summary>
|
||||
<div className="ui-layout-editor__tabs">{(["mobile", "pc"] as const).map((device) => <button key={device} type="button" aria-pressed={tab === device} onClick={() => chooseSlot(device, 0)}>{device === "pc" ? "PC" : "Mobile"} · 3 slots</button>)}</div>
|
||||
<div className="ui-layout-editor__slots">{ui.preferences.slots[tab].map((template, index) => <button type="button" key={index} aria-pressed={slot === index} onClick={() => chooseSlot(tab, index)}><small>Slot {index + 1}</small><strong>{template?.name ?? "Empty"}</strong></button>)}</div>
|
||||
@@ -131,20 +137,30 @@ export function UiLayoutEditor() {
|
||||
})}>Replace with imported UI</button><button type="button" onClick={() => setPendingImport(null)}>Keep existing UI</button></div>}
|
||||
<small>{session?.accessToken ? "Templates save to your account and upload with cloud saves. Sign in on another device to load them." : "Offline profile: saved on this device. Use export/import to transfer your UI."}</small>
|
||||
</details>
|
||||
<details><summary>Elements · move / hide</summary>
|
||||
{(Object.entries(HUD_ELEMENTS) as [HudElementId, string][]).filter(([id]) => !id.startsWith("bar-") || ui.device === "pc").map(([id, label]) => <div key={id} className={`ui-layout-editor__element ${ui.selectedElement === id ? "is-selected" : ""}`}>
|
||||
<label><input type="checkbox" checked={draft.elements[id].visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], visible: event.target.checked } } }))} />{label}</label>
|
||||
<details open={dualScreen}><summary>Elements · screen / move / hide</summary>
|
||||
{(Object.entries(HUD_ELEMENTS) as [HudElementId, string][]).filter(([id]) => !id.startsWith("bar-") || ui.device === "pc").map(([id, label]) => <div key={id} className={`ui-layout-editor__element ${ui.selectedElement === id ? "is-selected" : ""}`}
|
||||
onClick={() => useUiLayoutStore.setState({ selectedElement: id })} onFocus={() => useUiLayoutStore.setState({ selectedElement: id })}>
|
||||
<label>{label}</label>
|
||||
<select aria-label={`${label} screen`} value={dualScreen ? resolveHudScreen(id, draft, true) : draft.elements[id].visible ? draft.elements[id].screen ?? "auto" : "hidden"}
|
||||
onChange={(event) => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements,
|
||||
[id]: { ...layout.elements[id], screen: event.target.value as HudScreen, visible: event.target.value !== "hidden", position: null, width: null },
|
||||
} }))}>
|
||||
{!dualScreen && <option value="auto">Automatic</option>}
|
||||
<option value="top">Top</option><option value="bottom">Bottom</option><option value="hidden">Hidden</option>
|
||||
</select>
|
||||
<button type="button" aria-label={`Reset ${label} position`} onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, [id]: { ...layout.elements[id], position: null, width: null } } }))}>Reset</button>
|
||||
</div>)}
|
||||
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements }))}>Reset all positions and visibility</button>
|
||||
<button type="button" onClick={() => ui.updateDraft((layout) => ({ ...layout, elements: defaultUiLayout().elements, dpsMeterDisplay: "auto" }))}>Reset all elements</button>
|
||||
{dualScreen && <p>Top: party health and the dungeon. Bottom: action bars, buffs and debuffs. Collapse this panel to drag labels on either display.</p>}
|
||||
</details>
|
||||
<details open className="ui-layout-editor__dps"><summary>DPS meter</summary>
|
||||
<details className="ui-layout-editor__dps"><summary>DPS meter</summary>
|
||||
<label><input type="checkbox" checked={draft.elements.dps.visible} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
|
||||
elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: event.target.checked } },
|
||||
elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: event.target.checked,
|
||||
screen: event.target.checked && layout.elements.dps.screen === "hidden" ? "auto" : layout.elements.dps.screen } },
|
||||
}))} />Show DPS meter</label>
|
||||
<label>Screen<select aria-label="DPS meter screen" value={draft.dpsMeterDisplay} onChange={(event) => ui.updateDraft((layout) => ({ ...layout,
|
||||
dpsMeterDisplay: event.target.value as typeof layout.dpsMeterDisplay,
|
||||
elements: { ...layout.elements, dps: { ...layout.elements.dps, position: null } },
|
||||
elements: { ...layout.elements, dps: { ...layout.elements.dps, screen: "auto", position: null } },
|
||||
}))}>
|
||||
<option value="auto">Automatic · PC lower left / Thor bottom</option>
|
||||
<option value="main">Main screen</option><option value="companion">Companion screen</option>
|
||||
@@ -160,7 +176,7 @@ export function UiLayoutEditor() {
|
||||
const combat = useCombatStore.getState();
|
||||
const defaults = defaultActionBarForCharacter(combat.classId, combat.secondaryClassId, combat.level);
|
||||
const empty = layout.bars.every((bar) => bar.slots.every((button) => !button.abilityId));
|
||||
return { ...layout, wowBars: event.target.checked, bars: empty && event.target.checked ? layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((button, s) => ({ ...button, abilityId: defaults[b * 12 + s] ?? null })) })) : layout.bars };
|
||||
return { ...(empty && event.target.checked ? resetDesktopAbilities(layout, defaults) : layout), wowBars: event.target.checked };
|
||||
})} />Use WoW action bars</label>
|
||||
{draft.wowBars && <>
|
||||
<label>Visible bar count<select value={draft.barCount} onChange={(event) => { ui.updateDraft((layout) => ({ ...layout, barCount: Number(event.target.value) })); useUiLayoutStore.setState({ selectedButton: null }); }}>{[1, 2, 3, 4, 5].map((count) => <option key={count}>{count}</option>)}</select></label>
|
||||
@@ -173,7 +189,17 @@ export function UiLayoutEditor() {
|
||||
{selected && <div className="ui-layout-editor__assignment"><strong>Bar {selected.bar + 1} · Button {selected.slot + 1}{ui.mode === "bindings" ? " · Press a key…" : ""}</strong>
|
||||
<button type="button" onClick={() => setAbility(null)}>Clear ability</button>
|
||||
<label>Find ability<input value={search} onChange={(event) => setSearch(event.target.value)} /></label>
|
||||
<div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((ability) => <button key={ability.id} type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button>)}</div>
|
||||
<div className="ui-layout-editor__abilities">{abilities.filter((ability) => !ability.passive && ability.name.toLowerCase().includes(search.toLowerCase())).map((definition) => {
|
||||
const talentRank = definition.talentEntryId === undefined ? undefined : talentRanks[`coa-entry-${definition.talentEntryId}`] ?? 0;
|
||||
const ability = abilityAtLevel(definition, level, talentRank);
|
||||
return <AbilityTooltipTarget key={ability.id} ability={{
|
||||
name: ability.name, description: ability.description, target: ability.target,
|
||||
resourceCost: ability.cost.amount, resourceLabel: ability.cost.resource.replace(/-/g, " "),
|
||||
cooldownSeconds: ability.cooldownMs / 1_000, castSeconds: ability.castTimeMs / 1_000, range: ability.range.max,
|
||||
lockedUntilLevel: ability.unlockLevel > level ? ability.unlockLevel : undefined,
|
||||
talentLocked: ability.talentEntryId !== undefined && !talentRank,
|
||||
}}><button type="button" onClick={() => setAbility(ability.id)} aria-pressed={draft.bars[selected.bar].slots[selected.slot].abilityId === ability.id}>{ability.name}</button></AbilityTooltipTarget>;
|
||||
})}</div>
|
||||
</div>}
|
||||
</>}
|
||||
</details>}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,27 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
SpellbookPanel,
|
||||
spellbookAbilitiesForDisplay,
|
||||
type SpellbookAbilityView,
|
||||
} from "./SpellbookPanel";
|
||||
|
||||
const barState = vi.hoisted(() => ({ pcIds: [] as (string | null)[], controllerIds: [] as string[], wowBars: true }));
|
||||
vi.mock("../game/uiLayoutStore", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../game/uiLayoutStore")>();
|
||||
const { normalizeUiPreferences } = await import("../game/uiLayout");
|
||||
return { ...actual, currentUiDevice: () => "pc", useUiLayoutStore: () => {
|
||||
const preferences = normalizeUiPreferences(null);
|
||||
preferences.current.pc.wowBars = barState.wowBars;
|
||||
preferences.current.pc.bars[0].slots.forEach((slot, index) => { slot.abilityId = barState.pcIds[index] ?? null; });
|
||||
return { preferences, draft: null };
|
||||
} };
|
||||
});
|
||||
vi.mock("../game/combatStore", () => ({ useCombatStore: (selector: (state: unknown) => unknown) => selector({
|
||||
actionBindings: Object.fromEntries(barState.controllerIds.map((id, index) => [`layer:${index}`, id])),
|
||||
}) }));
|
||||
|
||||
const abilities: readonly SpellbookAbilityView[] = [
|
||||
{
|
||||
id: "known-one",
|
||||
@@ -47,6 +62,29 @@ const abilities: readonly SpellbookAbilityView[] = [
|
||||
];
|
||||
|
||||
describe("spellbook ability progression", () => {
|
||||
it("highlights only usable spells missing from the active layout and updates after assignment", () => {
|
||||
barState.wowBars = true;
|
||||
barState.pcIds = [null, null, null, null, "known-one"];
|
||||
barState.controllerIds = ["known-two"];
|
||||
try {
|
||||
const render = () => renderToStaticMarkup(createElement(SpellbookPanel, {
|
||||
abilities: [...abilities, { ...abilities[1], id: "passive", passive: true }],
|
||||
playerLevel: 1, onActivate: () => undefined, open: true, onClose: () => undefined,
|
||||
}));
|
||||
let markup = render();
|
||||
expect(markup).toContain("Known Two, unlocked, missing from action bars");
|
||||
expect(markup).not.toContain("Known One, unlocked, missing from action bars");
|
||||
expect(markup.match(/is-missing-from-bars/g)).toHaveLength(1);
|
||||
barState.pcIds[11] = "known-two";
|
||||
markup = render();
|
||||
expect(markup).not.toContain("is-missing-from-bars");
|
||||
barState.wowBars = false;
|
||||
markup = render();
|
||||
expect(markup).toContain("Known One, unlocked, missing from action bars");
|
||||
expect(markup).not.toContain("Known Two, unlocked, missing from action bars");
|
||||
expect(markup.match(/is-missing-from-bars/g)).toHaveLength(1);
|
||||
} finally { barState.pcIds = []; barState.controllerIds = []; barState.wowBars = true; }
|
||||
});
|
||||
it("renders future abilities as inspectable locked buttons with level requirements", () => {
|
||||
const markup = renderToStaticMarkup(createElement(SpellbookPanel, {
|
||||
abilities,
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user