Release Healer Man 0.1.44
This commit is contained in:
1 parent
ccc01396cd
commit
756ee4f64a
24 files changed
+575
-46
No files matched your search
@@ -4,6 +4,24 @@ This file is the durable record of runtime behavior, regressions, and the
|
|||||||
evidence required before calling a visual bug fixed. Update it whenever scene,
|
evidence required before calling a visual bug fixed. Update it whenever scene,
|
||||||
camera, input, physics, mob-rendering, or asset-loading code changes.
|
camera, input, physics, mob-rendering, or asset-loading code changes.
|
||||||
|
|
||||||
|
## 2026-09-14 New-character PC bars and missing-spell borders
|
||||||
|
|
||||||
|
- A temporary browser fixture used the real desktop bars, ability controls,
|
||||||
|
spellbook, combat store, and character UI activation with memory-only saves.
|
||||||
|
Fresh Priest and Mage profiles populated their PC bars automatically. The
|
||||||
|
Mage started with Frostbolt, Fire Blast, Frost Nova, and Arcane Intellect.
|
||||||
|
- Clearing Lesser Heal gave its spellbook entry and ability-picker entry a
|
||||||
|
visible light blue border, including the icon border. Both computed border
|
||||||
|
colors were `rgb(146, 213, 255)`. Assigned and locked spells kept their usual
|
||||||
|
borders. Reassigning Lesser Heal removed the missing-spell border.
|
||||||
|
- Switching to the fresh Mage and back retained the Priest's cleared slot.
|
||||||
|
Screenshots of both missing-spell menus were inspected during the playtest.
|
||||||
|
The temporary fixture was removed afterward.
|
||||||
|
- Validation: 62 focused tests, TypeScript, production build including bundle
|
||||||
|
verification, and the release-candidate check passed. Full-game verification
|
||||||
|
was limited by a persistent opening-expedition loading screen in both local
|
||||||
|
development and production; menu visuals were verified in the fixture.
|
||||||
|
|
||||||
## 2026-09-14 Ability hover tooltips
|
## 2026-09-14 Ability hover tooltips
|
||||||
|
|
||||||
- Entered Stormwind Stockade with the existing offline level-17 Priest through
|
- Entered Stormwind Stockade with the existing offline level-17 Priest through
|
||||||
@@ -3312,3 +3330,53 @@ Vite's existing large-chunk advisory remains informational.
|
|||||||
an isolated production server and test database. Evidence is in
|
an isolated production server and test database. Evidence is in
|
||||||
`playtest-artifacts/boss-testing/results.json` and screenshots
|
`playtest-artifacts/boss-testing/results.json` and screenshots
|
||||||
`01-main-menu.png` through `12-bottom-display-review.png`.
|
`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`.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.43",
|
"version": "0.1.44",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.43",
|
"version": "0.1.44",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.43",
|
"version": "0.1.44",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.43",
|
"versionName": "0.1.44",
|
||||||
"versionCode": 1043
|
"versionCode": 1044
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,8 @@ const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primar
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const directory = path.resolve('playtest-artifacts/boss-testing');
|
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 });
|
fs.mkdirSync(directory, { recursive: true });
|
||||||
const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
|
const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -48,8 +49,8 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
|
|||||||
await page.getByRole('button', { name: /Boss Testing/ }).click();
|
await page.getByRole('button', { name: /Boss Testing/ }).click();
|
||||||
await page.getByRole('heading', { name: 'Boss Testing', exact: true }).waitFor();
|
await page.getByRole('heading', { name: 'Boss Testing', exact: true }).waitFor();
|
||||||
const picker = page.getByLabel(/Choose a boss/);
|
const picker = page.getByLabel(/Choose a boss/);
|
||||||
const option = await picker.locator('option').evaluateAll((options) => options.find((entry) => /Lady Anacondra.*Wailing Caverns/.test(entry.textContent))?.value);
|
const option = await picker.locator('option').evaluateAll((options, name) => options.find((entry) => entry.textContent.startsWith(`${name} ·`))?.value, selectedBossName);
|
||||||
assert(option, 'Lady Anacondra is selectable');
|
assert(option, `${selectedBossName} is selectable`);
|
||||||
await picker.selectOption(option);
|
await picker.selectOption(option);
|
||||||
await page.getByRole('button', { name: 'Test selected boss', exact: true }).waitFor({ state: 'visible' });
|
await page.getByRole('button', { name: 'Test selected boss', exact: true }).waitFor({ state: 'visible' });
|
||||||
await capture('02-boss-selection');
|
await capture('02-boss-selection');
|
||||||
@@ -68,9 +69,17 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
|
|||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
const boss = game.bossTest;
|
const boss = game.bossTest;
|
||||||
return { boss, xp: combat.xp, saved: JSON.stringify(useShellStore.getState().characters), count: Object.keys(combat.mobs).length };
|
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');
|
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.keyboard.press('Escape');
|
||||||
await page.getByRole('button', { name: 'Boss readiness & report', exact: true }).click();
|
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('button', { name: 'Mark not ready', exact: true }).click();
|
||||||
@@ -115,7 +124,7 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
|
|||||||
await capture('07-mobile-selection');
|
await capture('07-mobile-selection');
|
||||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'No mobile horizontal overflow');
|
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'No mobile horizontal overflow');
|
||||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
await page.getByLabel('Search bosses').fill('Lady Anacondra');
|
await page.getByLabel('Search bosses').fill(selectedBossName);
|
||||||
await page.getByRole('button', { name: 'Fight a random boss', exact: true }).click();
|
await page.getByRole('button', { name: 'Fight a random boss', exact: true }).click();
|
||||||
await page.locator('canvas').waitFor();
|
await page.locator('canvas').waitFor();
|
||||||
await page.waitForTimeout(3000);
|
await page.waitForTimeout(3000);
|
||||||
@@ -154,7 +163,9 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187';
|
|||||||
await capture('12-bottom-display-review');
|
await capture('12-bottom-display-review');
|
||||||
await bottom.close();
|
await bottom.close();
|
||||||
assert.deepEqual(errors, [], 'No browser runtime errors');
|
assert.deepEqual(errors, [], 'No browser runtime errors');
|
||||||
fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ passed: true, boss: before.boss, initialMobCount: before.count, xpUnchanged: true, profileUnchanged: true, automaticDownload: 'dungeon:ragefire-chasm', report: reports[0], errors }, null, 2)}\n`);
|
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');
|
console.log('Boss Testing live flow passed');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await capture('failure');
|
await capture('failure');
|
||||||
|
|||||||
@@ -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; });
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "./accountRepository";
|
} from "./accountRepository";
|
||||||
import { selectedCharacter, useShellStore } from "./shellStore";
|
import { selectedCharacter, useShellStore } from "./shellStore";
|
||||||
import { useCombatStore } from "../game/combatStore";
|
import { useCombatStore } from "../game/combatStore";
|
||||||
|
import { activateCharacterUi } from "../game/uiLayoutStore";
|
||||||
|
|
||||||
// Reuse the character editors without mounting the dungeon's simulation bridge.
|
// Reuse the character editors without mounting the dungeon's simulation bridge.
|
||||||
export function beginMainMenuCharacterEditing(storage?: StorageLike): (() => void) | null {
|
export function beginMainMenuCharacterEditing(storage?: StorageLike): (() => void) | null {
|
||||||
@@ -16,6 +17,7 @@ export function beginMainMenuCharacterEditing(storage?: StorageLike): (() => voi
|
|||||||
const ownerId = shell.session.ownerId;
|
const ownerId = shell.session.ownerId;
|
||||||
const characterId = character.id;
|
const characterId = character.id;
|
||||||
useCombatStore.getState().initializeCharacter(character);
|
useCombatStore.getState().initializeCharacter(character);
|
||||||
|
activateCharacterUi(character);
|
||||||
|
|
||||||
return useCombatStore.subscribe((combat, previous) => {
|
return useCombatStore.subscribe((combat, previous) => {
|
||||||
const current = useShellStore.getState();
|
const current = useShellStore.getState();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useShellStore } from "../app/shellStore";
|
import { useShellStore } from "../app/shellStore";
|
||||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||||
import { useCombatStore } from "./combatStore";
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { activateCharacterUi } from "./uiLayoutStore";
|
||||||
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
import { advanceDungeonPartyCombat } from "./partyRuntime";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
|
import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout";
|
||||||
@@ -41,6 +42,7 @@ export function CombatBridge() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!character) return;
|
if (!character) return;
|
||||||
useCombatStore.getState().initializeCharacter({ ...character, bossCards: useShellStore.getState().bossCards, role: activeRole });
|
useCombatStore.getState().initializeCharacter({ ...character, bossCards: useShellStore.getState().bossCards, role: activeRole });
|
||||||
|
activateCharacterUi(character);
|
||||||
useCombatStore.getState().synchronizeActors();
|
useCombatStore.getState().synchronizeActors();
|
||||||
if (gameMode === "manastorm") {
|
if (gameMode === "manastorm") {
|
||||||
activateManastormActiveSpellLoadout(
|
activateManastormActiveSpellLoadout(
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export async function runDungeonBossBenchmark(
|
|||||||
const dungeon = requireDungeonDefinition("ragefire-chasm");
|
const dungeon = requireDungeonDefinition("ragefire-chasm");
|
||||||
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)!;
|
const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)!;
|
||||||
const entity = dungeon.entities[spawn.entityId];
|
const entity = dungeon.entities[spawn.entityId];
|
||||||
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, build.level, "normal", 1);
|
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, build.level, "normal", 1, build.source);
|
||||||
const specs = specializationsForClass(build.classId).filter((spec) => spec.role === build.role);
|
const specs = specializationsForClass(build.classId).filter((spec) => spec.role === build.role);
|
||||||
const specialization = specs.find((spec) => spec.displayName === build.orientation) ?? specs[0];
|
const specialization = specs.find((spec) => spec.displayName === build.orientation) ?? specs[0];
|
||||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); resetCombatSpatialProvider();
|
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); resetCombatSpatialProvider();
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { useGameStore } from "./store";
|
|||||||
import { useCombatStore } from "./combatStore";
|
import { useCombatStore } from "./combatStore";
|
||||||
import { bossTestIsDefeated } from "./BossTestingBridge";
|
import { bossTestIsDefeated } from "./BossTestingBridge";
|
||||||
import type { DungeonDefinition } from "./dungeonTypes";
|
import type { DungeonDefinition } from "./dungeonTypes";
|
||||||
|
import { dungeonEnemyScaling } from "./dungeonBossScaling";
|
||||||
|
import { creatureRuntimeBaseStats } from "./azerothCoreReference";
|
||||||
|
import { bossDifficultyMultipliers } from "./dungeonDifficulty";
|
||||||
|
import { finalDungeonBoss } from "./dungeonRunStore";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||||
@@ -56,6 +60,39 @@ describe("Boss Testing", () => {
|
|||||||
expect(useGameStore.getState()).toMatchObject({ gmTestMode: true, bossTest: room.session, overlay: null });
|
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", () => {
|
it("recognizes actual boss deaths and awards no progression or collectible rewards", () => {
|
||||||
const room = createBossTestingRoom(source, boss);
|
const room = createBossTestingRoom(source, boss);
|
||||||
registerBossTestingDungeon(room.definition, source.id);
|
registerBossTestingDungeon(room.definition, source.id);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterAll, describe, expect, it } from "vitest";
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
import { mkdirSync, writeFileSync } from "node:fs";
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
import { BASE_CLASS_IDS, COA_CLASS_IDS } from "../app/characterCatalog";
|
import { BASE_CLASS_IDS, COA_CLASS_IDS, ROM_CLASS_IDS } from "../app/characterCatalog";
|
||||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||||
import { runDungeonBossBenchmark } from "./benchmark/dungeonBossBenchmark";
|
import { runDungeonBossBenchmark } from "./benchmark/dungeonBossBenchmark";
|
||||||
import { BENCHMARK_POLICY_ID } from "./benchmark/benchmarkController";
|
import { BENCHMARK_POLICY_ID } from "./benchmark/benchmarkController";
|
||||||
@@ -14,16 +14,16 @@ const samples: unknown[] = [];
|
|||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
if (process.env.BOSS_BALANCE_REPORT !== "1") return;
|
if (process.env.BOSS_BALANCE_REPORT !== "1") return;
|
||||||
mkdirSync("artifacts/boss-difficulty", { recursive: true });
|
mkdirSync("artifacts/boss-difficulty", { recursive: true });
|
||||||
writeFileSync("artifacts/boss-difficulty/wow-coa-balance.json", `${JSON.stringify({
|
writeFileSync("artifacts/boss-difficulty/wow-rom-coa-balance.json", `${JSON.stringify({
|
||||||
difficulty: 1, dungeon: dungeon.id, durationLimitSeconds: 60,
|
difficulty: 1, dungeon: dungeon.id, durationLimitSeconds: 60,
|
||||||
targets: { ordinary: 25, final: 45 }, policy: BENCHMARK_POLICY_ID, gear: "resolved starter heirlooms", samples,
|
targets: { ordinary: 25, final: 45 }, policy: BENCHMARK_POLICY_ID, gear: "resolved starter heirlooms", samples,
|
||||||
}, null, 2)}\n`);
|
}, null, 2)}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("default boss difficulty with WoW and CoA classes", () => {
|
describe("default boss difficulty across WoW, RoM, and CoA classes", () => {
|
||||||
for (const level of [8, 20, 40, 80]) {
|
for (const level of [8, 20, 40, 80]) {
|
||||||
it(`benchmarks every class-role build at level ${level}`, async () => {
|
it(`benchmarks every class-role build at level ${level}`, async () => {
|
||||||
const builds = await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...COA_CLASS_IDS]);
|
const builds = await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...ROM_CLASS_IDS, ...COA_CLASS_IDS]);
|
||||||
const tooFast: string[] = [];
|
const tooFast: string[] = [];
|
||||||
for (const build of builds) {
|
for (const build of builds) {
|
||||||
// Let the test worker deliver progress between CPU-heavy simulations.
|
// Let the test worker deliver progress between CPU-heavy simulations.
|
||||||
@@ -43,7 +43,7 @@ describe("default boss difficulty with WoW and CoA classes", () => {
|
|||||||
|
|
||||||
for (const level of [8, 80]) {
|
for (const level of [8, 80]) {
|
||||||
it(`records healer outcomes with the final boss attacking at level ${level}`, async () => {
|
it(`records healer outcomes with the final boss attacking at level ${level}`, async () => {
|
||||||
const builds = (await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...COA_CLASS_IDS]))
|
const builds = (await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...ROM_CLASS_IDS, ...COA_CLASS_IDS]))
|
||||||
.filter((build) => build.role === "healer");
|
.filter((build) => build.role === "healer");
|
||||||
for (const build of builds) {
|
for (const build of builds) {
|
||||||
const last = await runDungeonBossBenchmark(build, final.id, "class-balance-2", true);
|
const last = await runDungeonBossBenchmark(build, final.id, "class-balance-2", true);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock
|
|||||||
import { subscribeCombatEvents } from "./combatEvents";
|
import { subscribeCombatEvents } from "./combatEvents";
|
||||||
import { bossDifficultyMultipliers } from "./dungeonDifficulty";
|
import { bossDifficultyMultipliers } from "./dungeonDifficulty";
|
||||||
import { abilitiesForClass } from "./abilityCatalog";
|
import { abilitiesForClass } from "./abilityCatalog";
|
||||||
|
import type { ContentCategoryId } from "../app/contentCategories";
|
||||||
|
|
||||||
const dungeon = requireDungeonDefinition("ragefire-chasm");
|
const dungeon = requireDungeonDefinition("ragefire-chasm");
|
||||||
const final = finalDungeonBoss(dungeon)!;
|
const final = finalDungeonBoss(dungeon)!;
|
||||||
@@ -21,15 +22,16 @@ afterAll(() => {
|
|||||||
writeFileSync("artifacts/boss-difficulty/ragefire-balance.json", `${JSON.stringify({ samples }, null, 2)}\n`);
|
writeFileSync("artifacts/boss-difficulty/ragefire-balance.json", `${JSON.stringify({ samples }, null, 2)}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
function fight(bossId: string, level: number, difficulty: number, seed: string, mechanics = false, playerDamage = false) {
|
function fight(bossId: string, level: number, difficulty: number, seed: string, mechanics = false, playerDamage = false,
|
||||||
|
characterSource: ContentCategoryId = "rom", encounterDungeon = dungeon) {
|
||||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||||
const entity = dungeon.entities[dungeon.staticSpawns.find((spawn) => spawn.id === bossId)!.entityId];
|
const entity = encounterDungeon.entities[encounterDungeon.staticSpawns.find((spawn) => spawn.id === bossId)!.entityId];
|
||||||
const scaling = dungeonEnemyScaling(dungeon, entity, bossId, level, "normal", difficulty);
|
const scaling = dungeonEnemyScaling(encounterDungeon, entity, bossId, level, "normal", difficulty, characterSource);
|
||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
const classId = playerDamage ? "rom-mage" : "rom-priest";
|
const classId = characterSource === "wow" ? (playerDamage ? "mage" : "priest") : (playerDamage ? "rom-mage" : "rom-priest");
|
||||||
combat.initializeCharacter({ classId, raceId: "rom-human", level });
|
combat.initializeCharacter({ classId, raceId: characterSource === "wow" ? "human" : "rom-human", level });
|
||||||
combat.setPlayerPosition([-18, 0, 0]);
|
combat.setPlayerPosition([-18, 0, 0]);
|
||||||
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health, playerDamage ? "damage" : "healer", 5, "rom");
|
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health, playerDamage ? "damage" : "healer", 5, characterSource);
|
||||||
combat.synchronizeActors();
|
combat.synchronizeActors();
|
||||||
combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry,
|
combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry,
|
||||||
...scaling, attacks: entity.combat?.attacks, xpReward: 0 });
|
...scaling, attacks: entity.combat?.attacks, xpReward: 0 });
|
||||||
@@ -40,7 +42,8 @@ function fight(bossId: string, level: number, difficulty: number, seed: string,
|
|||||||
}
|
}
|
||||||
let firstDamage: number | undefined;
|
let firstDamage: number | undefined;
|
||||||
let incomingDamage = 0; let healing = 0;
|
let incomingDamage = 0; let healing = 0;
|
||||||
const heal = abilitiesForClass("rom-priest").find((ability) => ability.name === "Urgent Heal")!;
|
const heal = abilitiesForClass(classId).find((ability) => ability.target === "friendly"
|
||||||
|
&& ability.effects.some((effect) => effect.kind === "heal") && (ability.unlockLevel ?? 1) <= level)!;
|
||||||
const damage = abilitiesForClass(classId).find((ability) => ability.target === "hostile"
|
const damage = abilitiesForClass(classId).find((ability) => ability.target === "hostile"
|
||||||
&& ability.effects.some((effect) => effect.kind === "damage"))!;
|
&& ability.effects.some((effect) => effect.kind === "damage"))!;
|
||||||
combat.selectMob(bossId);
|
combat.selectMob(bossId);
|
||||||
@@ -59,7 +62,8 @@ function fight(bossId: string, level: number, difficulty: number, seed: string,
|
|||||||
const injured = usePartyStore.getState().members.filter((member) => member.health > 0)
|
const injured = usePartyStore.getState().members.filter((member) => member.health > 0)
|
||||||
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0];
|
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0];
|
||||||
if (injured && injured.health < injured.maxHealth * .8 && !useCombatStore.getState().activeCast) {
|
if (injured && injured.health < injured.maxHealth * .8 && !useCombatStore.getState().activeCast) {
|
||||||
usePartyStore.getState().selectMember(injured.id); combat.castAbility(heal.id, undefined, now);
|
usePartyStore.getState().selectMember(injured.id);
|
||||||
|
combat.castAbility(heal.id, { targetActorId: injured.id, now });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const mob = useCombatStore.getState().mobs[bossId];
|
const mob = useCombatStore.getState().mobs[bossId];
|
||||||
@@ -72,6 +76,39 @@ function fight(bossId: string, level: number, difficulty: number, seed: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("dungeon boss durability and difficulty", () => {
|
describe("dungeon boss durability and difficulty", () => {
|
||||||
|
const stockades = requireDungeonDefinition("stormwind-stockade");
|
||||||
|
const stockadesBoss = stockades.bosses[0];
|
||||||
|
const stockadesEntity = stockades.entities[stockades.staticSpawns.find((entry) => entry.id === stockadesBoss.id)!.entityId];
|
||||||
|
|
||||||
|
it("gives each level-three Stockades party its source-calibrated health minimum", () => {
|
||||||
|
const easy = dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "wow");
|
||||||
|
expect(easy).toMatchObject({ level: 4, maxHealth: 1300 });
|
||||||
|
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 10, "wow").maxHealth)
|
||||||
|
.toBe(1300 * bossDifficultyMultipliers(10).health);
|
||||||
|
// The dungeon catalog must not choose the companion power curve.
|
||||||
|
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "rom").maxHealth).toBeCloseTo(7074.8);
|
||||||
|
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "coa").maxHealth).toBe(10766);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const level of [1, 3, 8, 20, 40, 80]) {
|
||||||
|
it(`preserves WoW boss mechanic windows in Stockades at level ${level}`, () => {
|
||||||
|
for (const seed of ["wow-health-1", "wow-health-2", "wow-health-3"]) {
|
||||||
|
const ordinary = fight(stockadesBoss.id, level, 1, seed, false, false, "wow", stockades);
|
||||||
|
const last = fight(finalDungeonBoss(stockades)!.id, level, 1, seed, false, false, "wow", stockades);
|
||||||
|
samples.push({ level, seed, boss: ordinary, final: last });
|
||||||
|
expect(ordinary.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(25);
|
||||||
|
expect(last.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(45);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("allows real Stockades boss mechanics during a level-three WoW Priest party fight", () => {
|
||||||
|
const result = fight(stockadesBoss.id, 3, 1, "wow-health-1", true, false, "wow", stockades);
|
||||||
|
expect(result.seconds, JSON.stringify(result)).not.toBeNull();
|
||||||
|
expect(result.seconds!).toBeLessThanOrEqual(180);
|
||||||
|
expect(result.attacks).toBeGreaterThan(0);
|
||||||
|
expect(result.incomingDamage).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
it("increases boss health and damage at every slider step without changing trash", () => {
|
it("increases boss health and damage at every slider step without changing trash", () => {
|
||||||
const spawn = dungeon.staticSpawns.find((entry) => entry.id === final.id)!;
|
const spawn = dungeon.staticSpawns.find((entry) => entry.id === final.id)!;
|
||||||
const entity = dungeon.entities[spawn.entityId];
|
const entity = dungeon.entities[spawn.entityId];
|
||||||
|
|||||||
@@ -4,11 +4,41 @@ import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLe
|
|||||||
import { finalDungeonBoss } from "./dungeonRunStore";
|
import { finalDungeonBoss } from "./dungeonRunStore";
|
||||||
import type { DungeonDefinition } from "./dungeonTypes";
|
import type { DungeonDefinition } from "./dungeonTypes";
|
||||||
import type { PopulationEntityDefinition } from "./mobPopulation";
|
import type { PopulationEntityDefinition } from "./mobPopulation";
|
||||||
|
import type { ContentCategoryId } from "../app/contentCategories";
|
||||||
|
|
||||||
/** Shared by rendering and deterministic combat benchmarks; preserves authored stats above the floor. */
|
const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [
|
||||||
|
[2, 900],
|
||||||
|
[4, 1_300],
|
||||||
|
[9, 7_000],
|
||||||
|
[21, 11_000],
|
||||||
|
[41, 23_000],
|
||||||
|
[80, 180_000],
|
||||||
|
];
|
||||||
|
|
||||||
|
function interpolatedFloor(level: number, anchors: readonly (readonly [number, number])[]): number {
|
||||||
|
const upperIndex = anchors.findIndex(([anchorLevel]) => level <= anchorLevel);
|
||||||
|
if (upperIndex <= 0) return anchors[0][1];
|
||||||
|
if (upperIndex < 0) return anchors[anchors.length - 1][1];
|
||||||
|
const [lowerLevel, lowerHealth] = anchors[upperIndex - 1];
|
||||||
|
const [upperLevel, upperHealth] = anchors[upperIndex];
|
||||||
|
const progress = (level - lowerLevel) / (upperLevel - lowerLevel);
|
||||||
|
return lowerHealth + (upperHealth - lowerHealth) * progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
function partyBossHealthFloor(level: number, characterSource: ContentCategoryId): number {
|
||||||
|
if (characterSource === "wow") return interpolatedFloor(level, WOW_BOSS_HEALTH_FLOOR);
|
||||||
|
const baseline = 1_000 + level * 1_200 + level * level * 22;
|
||||||
|
// Each catalog is calibrated independently against its strongest canonical
|
||||||
|
// class-role cohort so every fight retains the 25/45-second mechanic window.
|
||||||
|
return baseline * (characterSource === "rom" ? 1.15 : 1.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Campaign fights retain a durability floor; testing rooms use level-scaled authored stats. */
|
||||||
export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: PopulationEntityDefinition,
|
export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: PopulationEntityDefinition,
|
||||||
instanceId: string, characterLevel: number, difficultyId: string, bossDifficulty: number) {
|
instanceId: string, characterLevel: number, difficultyId: string, bossDifficulty: number,
|
||||||
|
characterSource: ContentCategoryId = "wow") {
|
||||||
const boss = entity.kind === "boss";
|
const boss = entity.kind === "boss";
|
||||||
|
const testing = dungeon.id.startsWith("boss-testing:");
|
||||||
const level = scaledDungeonEnemyLevel(characterLevel, boss);
|
const level = scaledDungeonEnemyLevel(characterLevel, boss);
|
||||||
const authoredHealth = entity.combat?.healthMultiplier ?? 1;
|
const authoredHealth = entity.combat?.healthMultiplier ?? 1;
|
||||||
const authoredDamage = entity.combat?.damageMultiplier ?? 1;
|
const authoredDamage = entity.combat?.damageMultiplier ?? 1;
|
||||||
@@ -16,10 +46,12 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
|
|||||||
const native = entity.combat?.serverEntry ? creatureRuntimeBaseStats(entity.combat.serverEntry, level) : null;
|
const native = entity.combat?.serverEntry ? creatureRuntimeBaseStats(entity.combat.serverEntry, level) : null;
|
||||||
const originalHealth = (baseline ?? native?.health ?? (boss ? 260 + level * 65 : 72 + level * 22)) * authoredHealth;
|
const originalHealth = (baseline ?? native?.health ?? (boss ? 260 + level * 65 : 72 + level * 22)) * authoredHealth;
|
||||||
const multipliers = boss ? bossDifficultyMultipliers(bossDifficulty) : { health: 1, damage: 1 };
|
const multipliers = boss ? bossDifficultyMultipliers(bossDifficulty) : { health: 1, damage: 1 };
|
||||||
const finalBoss = boss && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
|
const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
|
||||||
// Calibrated against active RoM, WoW and CoA class-role rotations with heirlooms.
|
// Match durability to the character's game, including when crossing dungeon catalogs.
|
||||||
// Every class faces the same floor; the margin covers stronger CoA burst builds.
|
const partyHealthFloor = partyBossHealthFloor(level, characterSource);
|
||||||
const bossFloor = (1_000 + level * 1_200 + level * level * 22) * 1.75 * (finalBoss ? 1.8 : 1);
|
const bossFloor = partyHealthFloor * (finalBoss ? 1.8 : 1);
|
||||||
return { level, maxHealth: (boss ? Math.max(originalHealth, bossFloor) : originalHealth) * multipliers.health,
|
// A test room's sole objective must not promote every boss to a campaign finale
|
||||||
|
// or replace low-level native health with the campaign's full-party minimum.
|
||||||
|
return { level, maxHealth: (boss && !testing ? Math.max(originalHealth, bossFloor) : originalHealth) * multipliers.health,
|
||||||
healthMultiplier: authoredHealth * multipliers.health, damageMultiplier: authoredDamage * multipliers.damage };
|
healthMultiplier: authoredHealth * multipliers.health, damageMultiplier: authoredDamage * multipliers.damage };
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,64 @@ import {
|
|||||||
actionKeyForEvent, assignDesktopAbility, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
|
actionKeyForEvent, assignDesktopAbility, bindDesktopSlot, defaultUiLayout, desktopSlotForKey,
|
||||||
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resetDesktopAbilities, resolveDpsMeterDisplay,
|
exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resetDesktopAbilities, resolveDpsMeterDisplay,
|
||||||
HUD_ELEMENTS, resolveHudScreen,
|
HUD_ELEMENTS, resolveHudScreen,
|
||||||
|
activateDesktopAbilityLoadout, assignedActionBarAbilityIds,
|
||||||
} from "./uiLayout";
|
} from "./uiLayout";
|
||||||
|
|
||||||
describe("personal UI layouts", () => {
|
describe("personal UI layouts", () => {
|
||||||
|
it("populates fresh PC bars without changing shared keys, geometry, templates, or mobile controls", () => {
|
||||||
|
const ui = normalizeUiPreferences(null);
|
||||||
|
ui.current.pc.wowBars = true;
|
||||||
|
ui.current.pc.barCount = 2;
|
||||||
|
ui.current.pc.bars[1].columns = 3;
|
||||||
|
ui.current.pc = bindDesktopSlot(ui.current.pc, 1, 0, "Ctrl+KeyQ");
|
||||||
|
ui.slots.pc[0] = { name: "Raid", layout: ui.current.pc };
|
||||||
|
const defaults = Array.from({ length: 14 }, (_, i) => `spell-${i}`);
|
||||||
|
const activated = activateDesktopAbilityLoadout(ui, "new:priest:", defaults, defaults);
|
||||||
|
expect(activated.current.pc.bars[0].slots[0]).toEqual({ abilityId: "spell-0", key: "Digit1" });
|
||||||
|
expect(activated.current.pc.bars[1].slots[0]).toEqual({ abilityId: "spell-12", key: "Ctrl+KeyQ" });
|
||||||
|
expect(activated.current.pc.bars[1].columns).toBe(3);
|
||||||
|
expect(activated.current.pc.barCount).toBe(2);
|
||||||
|
expect(activated.current.pc.wowBars).toBe(true);
|
||||||
|
expect(activated.current.mobile).toEqual(ui.current.mobile);
|
||||||
|
expect(activated.slots).toEqual(ui.slots);
|
||||||
|
expect(ui.current.pc.bars[0].slots[0].abilityId).toBeNull();
|
||||||
|
});
|
||||||
|
it("restores separate character and ordered-class loadouts, including intentionally empty bars", () => {
|
||||||
|
let ui = activateDesktopAbilityLoadout(normalizeUiPreferences(null), "one:priest:", ["heal"], ["heal"]);
|
||||||
|
ui.desktopAbilityLoadouts["one:priest:"] = Array(60).fill(null);
|
||||||
|
ui = activateDesktopAbilityLoadout(ui, "two:priest:", ["heal"], ["heal"]);
|
||||||
|
expect(ui.current.pc.bars[0].slots[0].abilityId).toBe("heal");
|
||||||
|
ui = activateDesktopAbilityLoadout(ui, "one:priest:", ["heal"], ["heal"]);
|
||||||
|
expect(ui.current.pc.bars.every((bar) => bar.slots.every((slot) => slot.abilityId === null))).toBe(true);
|
||||||
|
ui = activateDesktopAbilityLoadout(ui, "one:rom-priest:rom-warrior", ["secondary-attack"], ["secondary-attack"]);
|
||||||
|
expect(ui.current.pc.bars[0].slots[0].abilityId).toBe("secondary-attack");
|
||||||
|
expect(importUiPreferences(exportUiPreferences(ui))).toEqual(ui);
|
||||||
|
});
|
||||||
|
it("adopts compatible legacy assignments once and repairs bars holding only another class's spells", () => {
|
||||||
|
const ui = normalizeUiPreferences(null);
|
||||||
|
ui.current.pc = assignDesktopAbility(ui.current.pc, 0, 2, "custom-heal");
|
||||||
|
const migrated = activateDesktopAbilityLoadout(ui, "one:priest:", ["heal"], ["heal", "custom-heal"]);
|
||||||
|
expect(migrated.current.pc.bars[0].slots[0].abilityId).toBeNull();
|
||||||
|
expect(migrated.current.pc.bars[0].slots[2].abilityId).toBe("custom-heal");
|
||||||
|
const next = activateDesktopAbilityLoadout(migrated, "two:priest:", ["heal"], ["heal", "custom-heal"]);
|
||||||
|
expect(next.current.pc.bars[0].slots[0].abilityId).toBe("heal");
|
||||||
|
expect(next.current.pc.bars[0].slots[2].abilityId).toBeNull();
|
||||||
|
const repaired = activateDesktopAbilityLoadout(ui, "new:mage:", ["fireball"], ["fireball"]);
|
||||||
|
expect(repaired.current.pc.bars[0].slots[0].abilityId).toBe("fireball");
|
||||||
|
const oldExport = JSON.parse(exportUiPreferences(ui));
|
||||||
|
delete oldExport.desktopAbilityLoadouts;
|
||||||
|
expect(importUiPreferences(JSON.stringify(oldExport)).desktopAbilityLoadouts).toEqual({});
|
||||||
|
});
|
||||||
|
it("checks the selected layout across modifier layers and active PC bars", () => {
|
||||||
|
const layout = defaultUiLayout();
|
||||||
|
layout.bars[0].slots[0].abilityId = "pc-heal";
|
||||||
|
layout.bars[1].slots[0].abilityId = "inactive-heal";
|
||||||
|
const bindings = { "primary:face-bottom": "controller-heal", "tertiary:r2": "modifier-heal" };
|
||||||
|
expect([...assignedActionBarAbilityIds(layout, bindings, false)]).toEqual(["controller-heal", "modifier-heal"]);
|
||||||
|
expect([...assignedActionBarAbilityIds(layout, bindings, true)]).toEqual(["pc-heal"]);
|
||||||
|
layout.barCount = 2;
|
||||||
|
expect(assignedActionBarAbilityIds(layout, bindings, true).has("inactive-heal")).toBe(true);
|
||||||
|
});
|
||||||
it("assigns every element one display, saves routing, and keeps single-display widgets available", () => {
|
it("assigns every element one display, saves routing, and keeps single-display widgets available", () => {
|
||||||
const preferences = normalizeUiPreferences(null);
|
const preferences = normalizeUiPreferences(null);
|
||||||
const layout = preferences.current.mobile;
|
const layout = preferences.current.mobile;
|
||||||
@@ -117,6 +172,7 @@ describe("personal UI layouts", () => {
|
|||||||
["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "NotAKey"; }],
|
["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "NotAKey"; }],
|
||||||
["duplicate chords", (data: any) => { data.current.pc.bars[0].slots[1].key = "Digit1"; }],
|
["duplicate chords", (data: any) => { data.current.pc.bars[0].slots[1].key = "Digit1"; }],
|
||||||
["missing buttons", (data: any) => { data.current.pc.bars[0].slots.pop(); }],
|
["missing buttons", (data: any) => { data.current.pc.bars[0].slots.pop(); }],
|
||||||
|
["invalid character bars", (data: any) => { data.desktopAbilityLoadouts = { character: ["heal"] }; }],
|
||||||
])("rejects %s without mutating the original preferences", (_, mutate) => {
|
])("rejects %s without mutating the original preferences", (_, mutate) => {
|
||||||
const ui = normalizeUiPreferences(null);
|
const ui = normalizeUiPreferences(null);
|
||||||
const original = exportUiPreferences(ui);
|
const original = exportUiPreferences(ui);
|
||||||
|
|||||||
+46
-1
@@ -36,6 +36,8 @@ export interface UiTemplate { name: string; layout: UiLayout }
|
|||||||
export interface UiPreferences {
|
export interface UiPreferences {
|
||||||
version: 1;
|
version: 1;
|
||||||
keybinds: InputPreferences;
|
keybinds: InputPreferences;
|
||||||
|
/** Ability assignments belong to a character and ordered class pair; geometry and keys stay shared. */
|
||||||
|
desktopAbilityLoadouts: Record<string, (string | null)[]>;
|
||||||
current: Record<UiDevice, UiLayout>;
|
current: Record<UiDevice, UiLayout>;
|
||||||
slots: Record<UiDevice, (UiTemplate | null)[]>;
|
slots: Record<UiDevice, (UiTemplate | null)[]>;
|
||||||
}
|
}
|
||||||
@@ -139,6 +141,12 @@ export function normalizeUiPreferences(value: unknown): UiPreferences {
|
|||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
keybinds: normalizeInputPreferences(source.keybinds),
|
keybinds: normalizeInputPreferences(source.keybinds),
|
||||||
|
desktopAbilityLoadouts: Object.fromEntries(Object.entries(record(source.desktopAbilityLoadouts))
|
||||||
|
.filter(([key, value]) => key.length <= 300 && Array.isArray(value))
|
||||||
|
.map(([key, value]) => [key, Array.from({ length: 60 }, (_, index) => {
|
||||||
|
const id = (value as unknown[])[index];
|
||||||
|
return typeof id === "string" && id.length <= 200 ? id : null;
|
||||||
|
})])),
|
||||||
current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) },
|
current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) },
|
||||||
slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => {
|
slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => {
|
||||||
const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null);
|
const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null);
|
||||||
@@ -162,11 +170,41 @@ export function assignDesktopAbility(layout: UiLayout, barIndex: number, slotInd
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Restore abilities only; key chords and the arrangement belong to the UI template. */
|
/** Restore abilities only; key chords and the arrangement belong to the UI template. */
|
||||||
export function resetDesktopAbilities(layout: UiLayout, abilityIds: readonly string[], barIndex?: number): UiLayout {
|
export function resetDesktopAbilities(layout: UiLayout, abilityIds: readonly (string | null)[], barIndex?: number): UiLayout {
|
||||||
return { ...layout, bars: layout.bars.map((bar, b) => barIndex !== undefined && b !== barIndex ? bar : {
|
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 })),
|
...bar, slots: bar.slots.map((slot, s) => ({ ...slot, abilityId: abilityIds[b * 12 + s] ?? null })),
|
||||||
}) };
|
}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Use the same class defaults as mobile, while preserving an existing character's empty slots. */
|
||||||
|
export function activateDesktopAbilityLoadout(
|
||||||
|
preferences: UiPreferences, key: string, defaults: readonly string[], availableIds: readonly string[],
|
||||||
|
): UiPreferences {
|
||||||
|
const layout = preferences.current.pc;
|
||||||
|
const available = new Set(availableIds);
|
||||||
|
const legacy = layout.bars.flatMap((bar) => bar.slots.map((slot) => slot.abilityId));
|
||||||
|
const saved = preferences.desktopAbilityLoadouts[key];
|
||||||
|
// Adopt the old account-wide assignments once, only for a compatible character.
|
||||||
|
const abilities = saved ?? (Object.keys(preferences.desktopAbilityLoadouts).length === 0
|
||||||
|
&& legacy.some((id) => id && available.has(id))
|
||||||
|
? legacy.map((id) => id && available.has(id) ? id : null)
|
||||||
|
: defaults);
|
||||||
|
const pc = resetDesktopAbilities(layout, abilities);
|
||||||
|
return { ...preferences, current: { ...preferences.current, pc },
|
||||||
|
desktopAbilityLoadouts: { ...preferences.desktopAbilityLoadouts,
|
||||||
|
[key]: pc.bars.flatMap((bar) => bar.slots.map((slot) => slot.abilityId)) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modifier layers count together; inactive PC bars cannot be used until enabled. */
|
||||||
|
export function assignedActionBarAbilityIds(
|
||||||
|
layout: UiLayout, bindings: Readonly<Record<string, string | null>>, desktop: boolean,
|
||||||
|
): ReadonlySet<string> {
|
||||||
|
const ids = desktop
|
||||||
|
? layout.bars.slice(0, layout.barCount).flatMap((bar) => bar.slots.map((slot) => slot.abilityId))
|
||||||
|
: Object.values(bindings);
|
||||||
|
return new Set(ids.filter((id): id is string => Boolean(id)));
|
||||||
|
}
|
||||||
export function desktopSlotForKey(layout: UiLayout, key: string | null): DesktopActionSlot | null {
|
export function desktopSlotForKey(layout: UiLayout, key: string | null): DesktopActionSlot | null {
|
||||||
if (!layout.wowBars || !key) return null;
|
if (!layout.wowBars || !key) return null;
|
||||||
// Hidden bars retain their bindings, like WoW; reducing the bar count deactivates them.
|
// Hidden bars retain their bindings, like WoW; reducing the bar count deactivates them.
|
||||||
@@ -180,6 +218,13 @@ export function importUiPreferences(text: string): UiPreferences {
|
|||||||
let source: Record<string, unknown>;
|
let source: Record<string, unknown>;
|
||||||
try { source = record(JSON.parse(text)); } catch { throw new Error("This file is not valid JSON."); }
|
try { source = record(JSON.parse(text)); } catch { throw new Error("This file is not valid JSON."); }
|
||||||
if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version.");
|
if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version.");
|
||||||
|
if (source.desktopAbilityLoadouts !== undefined) {
|
||||||
|
if (!source.desktopAbilityLoadouts || typeof source.desktopAbilityLoadouts !== "object" || Array.isArray(source.desktopAbilityLoadouts)) throw new Error("Invalid character action bars.");
|
||||||
|
for (const [key, ids] of Object.entries(record(source.desktopAbilityLoadouts))) {
|
||||||
|
if (key.length > 300 || !Array.isArray(ids) || ids.length !== 60
|
||||||
|
|| ids.some((id) => id !== null && (typeof id !== "string" || id.length > 200))) throw new Error("Invalid character action bars.");
|
||||||
|
}
|
||||||
|
}
|
||||||
const validateLayout = (value: unknown) => {
|
const validateLayout = (value: unknown) => {
|
||||||
const layout = record(value);
|
const layout = record(value);
|
||||||
if (layout.dpsMeterDisplay !== undefined && !["auto", "main", "companion"].includes(String(layout.dpsMeterDisplay))) throw new Error("Invalid DPS meter display.");
|
if (layout.dpsMeterDisplay !== undefined && !["auto", "main", "companion"].includes(String(layout.dpsMeterDisplay))) throw new Error("Invalid DPS meter display.");
|
||||||
|
|||||||
@@ -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,7 +2,9 @@ import { create } from "zustand";
|
|||||||
import { Capacitor } from "@capacitor/core";
|
import { Capacitor } from "@capacitor/core";
|
||||||
import { loadUiPreferences, saveUiPreferences } from "../app/accountRepository";
|
import { loadUiPreferences, saveUiPreferences } from "../app/accountRepository";
|
||||||
import { useShellStore } from "../app/shellStore";
|
import { useShellStore } from "../app/shellStore";
|
||||||
import { normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout";
|
import type { CharacterProfile } from "../app/types";
|
||||||
|
import { abilitiesForCharacter, defaultActionBarForCharacter } from "./abilityCatalog";
|
||||||
|
import { activateDesktopAbilityLoadout, normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout";
|
||||||
import { publishInputPreferences } from "../input/keybindRuntime";
|
import { publishInputPreferences } from "../input/keybindRuntime";
|
||||||
|
|
||||||
export function currentUiDevice(): UiDevice {
|
export function currentUiDevice(): UiDevice {
|
||||||
@@ -13,6 +15,7 @@ export function currentUiDevice(): UiDevice {
|
|||||||
}
|
}
|
||||||
interface UiLayoutState {
|
interface UiLayoutState {
|
||||||
ownerId: string | null;
|
ownerId: string | null;
|
||||||
|
desktopLoadoutKey: string | null;
|
||||||
preferences: UiPreferences;
|
preferences: UiPreferences;
|
||||||
draft: UiLayout | null;
|
draft: UiLayout | null;
|
||||||
device: UiDevice;
|
device: UiDevice;
|
||||||
@@ -25,7 +28,7 @@ interface UiLayoutState {
|
|||||||
finish: (save: boolean) => void;
|
finish: (save: boolean) => void;
|
||||||
}
|
}
|
||||||
export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
|
export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
|
||||||
ownerId: null, preferences: normalizeUiPreferences(null), draft: null, device: currentUiDevice(),
|
ownerId: null, desktopLoadoutKey: null, preferences: normalizeUiPreferences(null), draft: null, device: currentUiDevice(),
|
||||||
mode: "layout", selectedButton: null, selectedElement: null,
|
mode: "layout", selectedButton: null, selectedElement: null,
|
||||||
begin: () => {
|
begin: () => {
|
||||||
const device = currentUiDevice();
|
const device = currentUiDevice();
|
||||||
@@ -36,7 +39,11 @@ export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
|
|||||||
const ownerId = get().ownerId;
|
const ownerId = get().ownerId;
|
||||||
if (!ownerId) throw new Error("Sign in or choose an offline profile before saving your UI.");
|
if (!ownerId) throw new Error("Sign in or choose an offline profile before saving your UI.");
|
||||||
// Write first: storage errors leave the editor open with its draft intact.
|
// Write first: storage errors leave the editor open with its draft intact.
|
||||||
const saved = saveUiPreferences(ownerId, preferences);
|
const key = get().desktopLoadoutKey;
|
||||||
|
const saved = saveUiPreferences(ownerId, key ? { ...preferences,
|
||||||
|
desktopAbilityLoadouts: { ...preferences.desktopAbilityLoadouts,
|
||||||
|
[key]: preferences.current.pc.bars.flatMap((bar) => bar.slots.map((slot) => slot.abilityId)) },
|
||||||
|
} : preferences);
|
||||||
set({ preferences: saved });
|
set({ preferences: saved });
|
||||||
},
|
},
|
||||||
finish: (save) => {
|
finish: (save) => {
|
||||||
@@ -48,7 +55,7 @@ export const useUiLayoutStore = create<UiLayoutState>((set, get) => ({
|
|||||||
|
|
||||||
function activateOwner() {
|
function activateOwner() {
|
||||||
const ownerId = useShellStore.getState().session?.ownerId ?? null;
|
const ownerId = useShellStore.getState().session?.ownerId ?? null;
|
||||||
useUiLayoutStore.setState({ ownerId, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null });
|
useUiLayoutStore.setState({ ownerId, desktopLoadoutKey: null, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null });
|
||||||
}
|
}
|
||||||
activateOwner();
|
activateOwner();
|
||||||
publishInputPreferences(useUiLayoutStore.getState().preferences.keybinds);
|
publishInputPreferences(useUiLayoutStore.getState().preferences.keybinds);
|
||||||
@@ -57,6 +64,18 @@ useShellStore.subscribe((state, previous) => {
|
|||||||
if (state.session !== previous.session) activateOwner();
|
if (state.session !== previous.session) activateOwner();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Call after the character's catalogs have loaded, in both menu and gameplay paths. */
|
||||||
|
export function activateCharacterUi(character: CharacterProfile): void {
|
||||||
|
const ui = useUiLayoutStore.getState();
|
||||||
|
if (ui.ownerId !== character.ownerId) return;
|
||||||
|
const key = `${character.id}:${character.classId}:${character.secondaryClassId ?? ""}`;
|
||||||
|
useUiLayoutStore.setState({ desktopLoadoutKey: key, draft: null,
|
||||||
|
preferences: activateDesktopAbilityLoadout(ui.preferences, key,
|
||||||
|
defaultActionBarForCharacter(character.classId, character.secondaryClassId, character.level),
|
||||||
|
abilitiesForCharacter(character.classId, character.secondaryClassId).map((ability) => ability.id)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function activeUiLayout(): UiLayout {
|
export function activeUiLayout(): UiLayout {
|
||||||
const state = useUiLayoutStore.getState();
|
const state = useUiLayoutStore.getState();
|
||||||
return state.draft ?? state.preferences.current[currentUiDevice()];
|
return state.draft ?? state.preferences.current[currentUiDevice()];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import type { CharacterProfile } from "../app/types";
|
import type { CharacterProfile } from "../app/types";
|
||||||
import { useShellStore } from "../app/shellStore";
|
import { useShellStore } from "../app/shellStore";
|
||||||
import { useCombatStore } from "./combatStore";
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import { activateCharacterUi } from "./uiLayoutStore";
|
||||||
import { usePartyStore } from "./partyStore";
|
import { usePartyStore } from "./partyStore";
|
||||||
import { useGameStore } from "./store";
|
import { useGameStore } from "./store";
|
||||||
import { useArenaStore } from "./arenaStore";
|
import { useArenaStore } from "./arenaStore";
|
||||||
@@ -16,6 +17,7 @@ export function useArenaCombatView(character: CharacterProfile | null, actor: Ar
|
|||||||
const previousParty = usePartyStore.getState();
|
const previousParty = usePartyStore.getState();
|
||||||
const previousGame = useGameStore.getState();
|
const previousGame = useGameStore.getState();
|
||||||
useCombatStore.getState().initializeCharacter(character);
|
useCombatStore.getState().initializeCharacter(character);
|
||||||
|
activateCharacterUi(character);
|
||||||
usePartyStore.setState({ members: [] });
|
usePartyStore.setState({ members: [] });
|
||||||
useGameStore.setState({ overlay: null, paused: false, companionOpen: false });
|
useGameStore.setState({ overlay: null, paused: false, companionOpen: false });
|
||||||
const unsubscribe = useCombatStore.subscribe((state, before) => {
|
const unsubscribe = useCombatStore.subscribe((state, before) => {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { combatHasLineOfSight } from "../game/combatSpatial";
|
|||||||
import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset";
|
import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset";
|
||||||
import { scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling";
|
import { scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling";
|
||||||
import { dungeonEnemyScaling } from "../game/dungeonBossScaling";
|
import { dungeonEnemyScaling } from "../game/dungeonBossScaling";
|
||||||
|
import { contentCategoryForClass } from "../app/characterCatalog";
|
||||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||||
import { stepFearedMobAway } from "../game/mobMotion";
|
import { stepFearedMobAway } from "../game/mobMotion";
|
||||||
import {
|
import {
|
||||||
@@ -891,6 +892,7 @@ function ProxyMob({
|
|||||||
const difficultyId = useGameStore((state) => state.activeDifficultyId);
|
const difficultyId = useGameStore((state) => state.activeDifficultyId);
|
||||||
const bossDifficulty = useGameStore((state) => state.activeBossDifficulty);
|
const bossDifficulty = useGameStore((state) => state.activeBossDifficulty);
|
||||||
const characterLevel = useCombatStore((state) => state.level);
|
const characterLevel = useCombatStore((state) => state.level);
|
||||||
|
const characterSource = useCombatStore((state) => contentCategoryForClass(state.classId));
|
||||||
const manastormLevel = useManastormStore((state) => state.level);
|
const manastormLevel = useManastormStore((state) => state.level);
|
||||||
const manastormPartySize = useManastormStore((state) => state.partySize);
|
const manastormPartySize = useManastormStore((state) => state.partySize);
|
||||||
const manastormModeId = useManastormStore((state) => state.modeId);
|
const manastormModeId = useManastormStore((state) => state.modeId);
|
||||||
@@ -933,7 +935,7 @@ function ProxyMob({
|
|||||||
? definition.combat?.level
|
? definition.combat?.level
|
||||||
: scaledDungeonEnemyLevel(characterLevel, boss);
|
: scaledDungeonEnemyLevel(characterLevel, boss);
|
||||||
const scaling = gameMode === "dungeon"
|
const scaling = gameMode === "dungeon"
|
||||||
? dungeonEnemyScaling(dungeon, definition, instanceId, characterLevel, difficultyId, bossDifficulty)
|
? dungeonEnemyScaling(dungeon, definition, instanceId, characterLevel, difficultyId, bossDifficulty, characterSource)
|
||||||
: null;
|
: null;
|
||||||
const healthMultiplier = scaling?.healthMultiplier ?? baseHealthMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1);
|
const healthMultiplier = scaling?.healthMultiplier ?? baseHealthMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1);
|
||||||
useCombatStore.getState().registerMob(instanceId, {
|
useCombatStore.getState().registerMob(instanceId, {
|
||||||
@@ -965,6 +967,7 @@ function ProxyMob({
|
|||||||
}, [
|
}, [
|
||||||
boss,
|
boss,
|
||||||
characterLevel,
|
characterLevel,
|
||||||
|
characterSource,
|
||||||
definition.hasLoot,
|
definition.hasLoot,
|
||||||
definition.id,
|
definition.id,
|
||||||
definition.name,
|
definition.name,
|
||||||
|
|||||||
+4
-1
@@ -1302,7 +1302,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
|||||||
.spellbook-panel__progress { display: grid; grid-template-columns: auto auto 1px auto auto; align-items: baseline; gap: 4px; color: var(--muted); font-size: 7px; text-transform: uppercase; }
|
.spellbook-panel__progress { display: grid; grid-template-columns: auto auto 1px auto auto; align-items: baseline; gap: 4px; color: var(--muted); font-size: 7px; text-transform: uppercase; }
|
||||||
.spellbook-panel__progress strong { color: #e7d491; font: 700 14px Georgia, serif; }
|
.spellbook-panel__progress strong { color: #e7d491; font: 700 14px Georgia, serif; }
|
||||||
.spellbook-panel__progress i { width: 1px; height: 19px; margin: 0 5px; background: rgba(216,226,194,.15); }
|
.spellbook-panel__progress i { width: 1px; height: 19px; margin: 0 5px; background: rgba(216,226,194,.15); }
|
||||||
.spellbook-panel__toolbar { display: flex; justify-content: flex-end; margin-top: 10px; }
|
.spellbook-panel__toolbar { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 10px; }
|
||||||
.spellbook-panel__filter { display: flex; min-height: 28px; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid rgba(190,210,157,.17); border-radius: 999px; background: rgba(152,181,122,.05); color: var(--muted); font-size: 7px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; cursor: pointer; }
|
.spellbook-panel__filter { display: flex; min-height: 28px; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid rgba(190,210,157,.17); border-radius: 999px; background: rgba(152,181,122,.05); color: var(--muted); font-size: 7px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; cursor: pointer; }
|
||||||
.spellbook-panel__filter:hover { border-color: rgba(190,210,157,.32); background: rgba(152,181,122,.11); color: var(--ink); }
|
.spellbook-panel__filter:hover { border-color: rgba(190,210,157,.32); background: rgba(152,181,122,.11); color: var(--ink); }
|
||||||
.spellbook-panel__filter > span { position: relative; width: 22px; height: 12px; border: 1px solid rgba(190,210,157,.26); border-radius: 999px; background: rgba(0,0,0,.26); }
|
.spellbook-panel__filter > span { position: relative; width: 22px; height: 12px; border: 1px solid rgba(190,210,157,.26); border-radius: 999px; background: rgba(0,0,0,.26); }
|
||||||
@@ -1481,6 +1481,9 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
|
|||||||
.ability-bindings__choices button { display: grid; width: 100%; min-height: 47px; grid-template-columns: 35px minmax(0, 1fr); align-items: center; gap: 7px; padding: 6px; border: 1px solid rgba(194,213,164,.13); border-radius: 7px; background: rgba(255,255,255,.025); color: var(--ink); cursor: pointer; text-align: left; }
|
.ability-bindings__choices button { display: grid; width: 100%; min-height: 47px; grid-template-columns: 35px minmax(0, 1fr); align-items: center; gap: 7px; padding: 6px; border: 1px solid rgba(194,213,164,.13); border-radius: 7px; background: rgba(255,255,255,.025); color: var(--ink); cursor: pointer; text-align: left; }
|
||||||
.ability-bindings__choices button:hover { background: rgba(138,171,104,.1); }
|
.ability-bindings__choices button:hover { background: rgba(138,171,104,.1); }
|
||||||
.ability-bindings__choices button.is-current { border-color: rgba(151,199,108,.34); background: rgba(101,151,72,.12); }
|
.ability-bindings__choices button.is-current { border-color: rgba(151,199,108,.34); background: rgba(101,151,72,.12); }
|
||||||
|
.spellbook-ability.is-missing-from-bars, .ability-bindings__choices button.is-missing-from-bars { border-color: #92d5ff; box-shadow: inset 0 0 0 1px rgba(146,213,255,.22), 0 0 6px rgba(146,213,255,.13); }
|
||||||
|
.is-missing-from-bars :is(.spellbook-ability__icon, .ability-bindings__icon) { border-color: #92d5ff; }
|
||||||
|
.action-bar-missing-legend, .ability-bindings__picker > p.action-bar-missing-legend { color: #92d5ff; font-size: 9px; line-height: 1.4; }
|
||||||
.ability-bindings__choices button.is-locked { filter: grayscale(.85); opacity: .65; }
|
.ability-bindings__choices button.is-locked { filter: grayscale(.85); opacity: .65; }
|
||||||
.ability-bindings__choices button.is-danger { border-color: rgba(202,111,87,.32); background: rgba(138,48,34,.14); }
|
.ability-bindings__choices button.is-danger { border-color: rgba(202,111,87,.32); background: rgba(138,48,34,.14); }
|
||||||
.ability-bindings__choices button > span:last-child { min-width: 0; }
|
.ability-bindings__choices button > span:last-child { min-width: 0; }
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { useCombatStore } from "../game/combatStore";
|
import { useCombatStore } from "../game/combatStore";
|
||||||
import { abilityAtLevel, defaultActionBarForCharacter, type AbilityDefinition } from "../game/abilityCatalog";
|
import { abilityAtLevel, defaultActionBarForCharacter, type AbilityDefinition } from "../game/abilityCatalog";
|
||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
import { actionKeyLabel, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout";
|
import { actionKeyLabel, assignedActionBarAbilityIds, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout";
|
||||||
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||||
import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller";
|
import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller";
|
||||||
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
|
import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "../input/keybinds";
|
||||||
@@ -130,6 +130,10 @@ function AbilityBindingsDialog({ onClose }: { onClose?: () => void }) {
|
|||||||
const [notice, setNotice] = useState("Choose a button, then choose the ability it should use.");
|
const [notice, setNotice] = useState("Choose a button, then choose the ability it should use.");
|
||||||
const classColor = classById(classId).color;
|
const classColor = classById(classId).color;
|
||||||
const isWow = layout === "wow";
|
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"
|
const assignment = (control: BindingControl) => typeof control === "number"
|
||||||
? pcLayout.bars[bar].slots[control].abilityId : bindings[actionBindingId(layer, control)];
|
? pcLayout.bars[bar].slots[control].abilityId : bindings[actionBindingId(layer, control)];
|
||||||
const controlLabel = (control: BindingControl) => typeof control === "number"
|
const controlLabel = (control: BindingControl) => typeof control === "number"
|
||||||
@@ -490,10 +494,11 @@ function AbilityBindingsDialog({ onClose }: { onClose?: () => void }) {
|
|||||||
<>
|
<>
|
||||||
<p className="eyebrow">Assign {controlLabel(editingControl)}</p>
|
<p className="eyebrow">Assign {controlLabel(editingControl)}</p>
|
||||||
<h3>Choose an ability</h3>
|
<h3>Choose an ability</h3>
|
||||||
|
<p className="action-bar-missing-legend">Light blue: missing from action bars</p>
|
||||||
<div className="ability-bindings__choices">
|
<div className="ability-bindings__choices">
|
||||||
{pickerAbilities.map((ability) => (
|
{pickerAbilities.map((ability) => (
|
||||||
<AbilityTooltipTarget key={ability.id} ability={tooltipData(ability)}>
|
<AbilityTooltipTarget key={ability.id} ability={tooltipData(ability)}>
|
||||||
<ControllerButton controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""}`} onClick={() => assign(ability.id)}>
|
<ControllerButton controlId={choiceControlId(ability.id)} selectedId={controller.selectedId} select={controller.select} type="button" className={`${ability.id === editingAbilityId ? "is-current" : ""} ${ability.unlockLevel > level ? "is-locked" : ""} ${isMissing(ability) ? "is-missing-from-bars" : ""}`} aria-label={`${ability.name}${isMissing(ability) ? ", missing from action bars" : ""}`} onClick={() => assign(ability.id)}>
|
||||||
<AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} />
|
<AbilityBindingIcon icon={ability.icon} sigil={ability.sigil} color={classColor} />
|
||||||
<span><strong>{ability.name}</strong><small>{ability.unlockLevel > level ? `Unlocks at level ${ability.unlockLevel}` : ability.id === editingAbilityId ? "Current assignment" : ability.target === "friendly" ? "Friendly / self" : ability.target}</small></span>
|
<span><strong>{ability.name}</strong><small>{ability.unlockLevel > level ? `Unlocks at level ${ability.unlockLevel}` : ability.id === editingAbilityId ? "Current assignment" : ability.target === "friendly" ? "Friendly / self" : ability.target}</small></span>
|
||||||
</ControllerButton>
|
</ControllerButton>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
|
import { useCombatStore } from "../game/combatStore";
|
||||||
|
import { assignedActionBarAbilityIds } from "../game/uiLayout";
|
||||||
|
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
|
||||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||||
import { ControllerButton } from "./ControllerButton";
|
import { ControllerButton } from "./ControllerButton";
|
||||||
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
import { AbilityTooltipTarget } from "./AbilityTooltip";
|
||||||
@@ -80,6 +83,11 @@ function SpellbookDialog({
|
|||||||
unlockedOnly,
|
unlockedOnly,
|
||||||
onUnlockedOnlyChange,
|
onUnlockedOnlyChange,
|
||||||
}: SpellbookDialogProps) {
|
}: SpellbookDialogProps) {
|
||||||
|
const bindings = useCombatStore((state) => state.actionBindings);
|
||||||
|
const ui = useUiLayoutStore();
|
||||||
|
const device = currentUiDevice();
|
||||||
|
const layout = ui.draft ?? ui.preferences.current[device];
|
||||||
|
const assignedIds = assignedActionBarAbilityIds(layout, bindings, device === "pc" && layout.wowBars);
|
||||||
const orderedAbilities = useMemo(
|
const orderedAbilities = useMemo(
|
||||||
() => spellbookAbilitiesForDisplay(abilities, playerLevel, false),
|
() => spellbookAbilitiesForDisplay(abilities, playerLevel, false),
|
||||||
[abilities, playerLevel],
|
[abilities, playerLevel],
|
||||||
@@ -131,6 +139,7 @@ function SpellbookDialog({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="spellbook-panel__toolbar">
|
<div className="spellbook-panel__toolbar">
|
||||||
|
<span className="action-bar-missing-legend">Light blue: missing from action bars</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`spellbook-panel__filter ${unlockedOnly ? "is-active" : ""}`}
|
className={`spellbook-panel__filter ${unlockedOnly ? "is-active" : ""}`}
|
||||||
@@ -146,6 +155,7 @@ function SpellbookDialog({
|
|||||||
<div className="spellbook-panel__list" aria-label={unlockedOnly ? "Unlocked abilities" : "Known and locked abilities"}>
|
<div className="spellbook-panel__list" aria-label={unlockedOnly ? "Unlocked abilities" : "Known and locked abilities"}>
|
||||||
{visibleAbilities.map((ability) => {
|
{visibleAbilities.map((ability) => {
|
||||||
const locked = !ability.known || ability.level > playerLevel;
|
const locked = !ability.known || ability.level > playerLevel;
|
||||||
|
const missing = !locked && !ability.passive && !assignedIds.has(ability.id);
|
||||||
const lockLabel = ability.talentLocked && !ability.known
|
const lockLabel = ability.talentLocked && !ability.known
|
||||||
? "Learn in talents"
|
? "Learn in talents"
|
||||||
: `Unlocks at level ${ability.level}`;
|
: `Unlocks at level ${ability.level}`;
|
||||||
@@ -163,9 +173,9 @@ function SpellbookDialog({
|
|||||||
setInspectedId(ability.id);
|
setInspectedId(ability.id);
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
className={`spellbook-ability ${locked ? "spellbook-ability--locked" : ""}`}
|
className={`spellbook-ability ${locked ? "spellbook-ability--locked" : ""} ${missing ? "is-missing-from-bars" : ""}`}
|
||||||
aria-disabled={locked}
|
aria-disabled={locked}
|
||||||
aria-label={locked ? `${ability.name}, ${lockLabel.toLowerCase()}` : `${ability.name}, unlocked`}
|
aria-label={locked ? `${ability.name}, ${lockLabel.toLowerCase()}` : `${ability.name}, unlocked${missing ? ", missing from action bars" : ""}`}
|
||||||
onFocus={() => setInspectedId(ability.id)}
|
onFocus={() => setInspectedId(ability.id)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setInspectedId(ability.id);
|
setInspectedId(ability.id);
|
||||||
@@ -214,6 +224,7 @@ function SpellbookDialog({
|
|||||||
? "Learn in talents"
|
? "Learn in talents"
|
||||||
: `Unlocks at level ${inspected.level}`
|
: `Unlocks at level ${inspected.level}`
|
||||||
: inspected.passive ? "Passive" : "Unlocked"}</dd></div>
|
: inspected.passive ? "Passive" : "Unlocked"}</dd></div>
|
||||||
|
{!inspectedLocked && !inspected.passive && <div><dt>Action bars</dt><dd>{assignedIds.has(inspected.id) ? "Assigned" : "Missing from action bars"}</dd></div>}
|
||||||
{inspected.rank !== undefined && <div><dt>Current rank</dt><dd>{inspected.rank} / {inspected.rankCount ?? inspected.rank}</dd></div>}
|
{inspected.rank !== undefined && <div><dt>Current rank</dt><dd>{inspected.rank} / {inspected.rankCount ?? inspected.rank}</dd></div>}
|
||||||
{inspected.resourceCost !== undefined && <div><dt>Cost</dt><dd>{inspected.resourceCost} {inspected.resourceLabel}</dd></div>}
|
{inspected.resourceCost !== undefined && <div><dt>Cost</dt><dd>{inspected.resourceCost} {inspected.resourceLabel}</dd></div>}
|
||||||
{inspected.cooldownSeconds !== undefined && <div><dt>Cooldown</dt><dd>{inspected.cooldownSeconds || "None"}{inspected.cooldownSeconds ? " sec" : ""}</dd></div>}
|
{inspected.cooldownSeconds !== undefined && <div><dt>Cooldown</dt><dd>{inspected.cooldownSeconds || "None"}{inspected.cooldownSeconds ? " sec" : ""}</dd></div>}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useCombatStore } from "../game/combatStore";
|
|||||||
import { abilityAtLevel, defaultActionBarForCharacter } from "../game/abilityCatalog";
|
import { abilityAtLevel, defaultActionBarForCharacter } from "../game/abilityCatalog";
|
||||||
import { useGameStore } from "../game/store";
|
import { useGameStore } from "../game/store";
|
||||||
import {
|
import {
|
||||||
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout,
|
BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout, resetDesktopAbilities,
|
||||||
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
|
exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice,
|
||||||
resolveHudScreen, type HudScreen,
|
resolveHudScreen, type HudScreen,
|
||||||
} from "../game/uiLayout";
|
} from "../game/uiLayout";
|
||||||
@@ -176,7 +176,7 @@ export function UiLayoutEditor() {
|
|||||||
const combat = useCombatStore.getState();
|
const combat = useCombatStore.getState();
|
||||||
const defaults = defaultActionBarForCharacter(combat.classId, combat.secondaryClassId, combat.level);
|
const defaults = defaultActionBarForCharacter(combat.classId, combat.secondaryClassId, combat.level);
|
||||||
const empty = layout.bars.every((bar) => bar.slots.every((button) => !button.abilityId));
|
const empty = layout.bars.every((bar) => bar.slots.every((button) => !button.abilityId));
|
||||||
return { ...layout, wowBars: event.target.checked, bars: empty && event.target.checked ? layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((button, s) => ({ ...button, abilityId: defaults[b * 12 + s] ?? null })) })) : layout.bars };
|
return { ...(empty && event.target.checked ? resetDesktopAbilities(layout, defaults) : layout), wowBars: event.target.checked };
|
||||||
})} />Use WoW action bars</label>
|
})} />Use WoW action bars</label>
|
||||||
{draft.wowBars && <>
|
{draft.wowBars && <>
|
||||||
<label>Visible bar count<select value={draft.barCount} onChange={(event) => { ui.updateDraft((layout) => ({ ...layout, barCount: Number(event.target.value) })); useUiLayoutStore.setState({ selectedButton: null }); }}>{[1, 2, 3, 4, 5].map((count) => <option key={count}>{count}</option>)}</select></label>
|
<label>Visible bar count<select value={draft.barCount} onChange={(event) => { ui.updateDraft((layout) => ({ ...layout, barCount: Number(event.target.value) })); useUiLayoutStore.setState({ selectedButton: null }); }}>{[1, 2, 3, 4, 5].map((count) => <option key={count}>{count}</option>)}</select></label>
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
SpellbookPanel,
|
SpellbookPanel,
|
||||||
spellbookAbilitiesForDisplay,
|
spellbookAbilitiesForDisplay,
|
||||||
type SpellbookAbilityView,
|
type SpellbookAbilityView,
|
||||||
} from "./SpellbookPanel";
|
} from "./SpellbookPanel";
|
||||||
|
|
||||||
|
const barState = vi.hoisted(() => ({ pcIds: [] as (string | null)[], controllerIds: [] as string[], wowBars: true }));
|
||||||
|
vi.mock("../game/uiLayoutStore", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../game/uiLayoutStore")>();
|
||||||
|
const { normalizeUiPreferences } = await import("../game/uiLayout");
|
||||||
|
return { ...actual, currentUiDevice: () => "pc", useUiLayoutStore: () => {
|
||||||
|
const preferences = normalizeUiPreferences(null);
|
||||||
|
preferences.current.pc.wowBars = barState.wowBars;
|
||||||
|
preferences.current.pc.bars[0].slots.forEach((slot, index) => { slot.abilityId = barState.pcIds[index] ?? null; });
|
||||||
|
return { preferences, draft: null };
|
||||||
|
} };
|
||||||
|
});
|
||||||
|
vi.mock("../game/combatStore", () => ({ useCombatStore: (selector: (state: unknown) => unknown) => selector({
|
||||||
|
actionBindings: Object.fromEntries(barState.controllerIds.map((id, index) => [`layer:${index}`, id])),
|
||||||
|
}) }));
|
||||||
|
|
||||||
const abilities: readonly SpellbookAbilityView[] = [
|
const abilities: readonly SpellbookAbilityView[] = [
|
||||||
{
|
{
|
||||||
id: "known-one",
|
id: "known-one",
|
||||||
@@ -47,6 +62,29 @@ const abilities: readonly SpellbookAbilityView[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe("spellbook ability progression", () => {
|
describe("spellbook ability progression", () => {
|
||||||
|
it("highlights only usable spells missing from the active layout and updates after assignment", () => {
|
||||||
|
barState.wowBars = true;
|
||||||
|
barState.pcIds = [null, null, null, null, "known-one"];
|
||||||
|
barState.controllerIds = ["known-two"];
|
||||||
|
try {
|
||||||
|
const render = () => renderToStaticMarkup(createElement(SpellbookPanel, {
|
||||||
|
abilities: [...abilities, { ...abilities[1], id: "passive", passive: true }],
|
||||||
|
playerLevel: 1, onActivate: () => undefined, open: true, onClose: () => undefined,
|
||||||
|
}));
|
||||||
|
let markup = render();
|
||||||
|
expect(markup).toContain("Known Two, unlocked, missing from action bars");
|
||||||
|
expect(markup).not.toContain("Known One, unlocked, missing from action bars");
|
||||||
|
expect(markup.match(/is-missing-from-bars/g)).toHaveLength(1);
|
||||||
|
barState.pcIds[11] = "known-two";
|
||||||
|
markup = render();
|
||||||
|
expect(markup).not.toContain("is-missing-from-bars");
|
||||||
|
barState.wowBars = false;
|
||||||
|
markup = render();
|
||||||
|
expect(markup).toContain("Known One, unlocked, missing from action bars");
|
||||||
|
expect(markup).not.toContain("Known Two, unlocked, missing from action bars");
|
||||||
|
expect(markup.match(/is-missing-from-bars/g)).toHaveLength(1);
|
||||||
|
} finally { barState.pcIds = []; barState.controllerIds = []; barState.wowBars = true; }
|
||||||
|
});
|
||||||
it("renders future abilities as inspectable locked buttons with level requirements", () => {
|
it("renders future abilities as inspectable locked buttons with level requirements", () => {
|
||||||
const markup = renderToStaticMarkup(createElement(SpellbookPanel, {
|
const markup = renderToStaticMarkup(createElement(SpellbookPanel, {
|
||||||
abilities,
|
abilities,
|
||||||
|
|||||||
Reference in new issue
Block a user