From 756ee4f64a0e7aab37df15d5086b63a9bc1af4a0 Mon Sep 17 00:00:00 2001 From: phenom Date: Mon, 14 Sep 2026 22:12:49 -0400 Subject: [PATCH] Release Healer Man 0.1.44 --- PLAYTEST_NOTES.md | 68 +++++++++++++ package-lock.json | 4 +- package.json | 2 +- release-version.json | 4 +- scripts/playtest-boss-testing.cjs | 23 +++-- scripts/playtest-dungeon-health.cjs | 110 +++++++++++++++++++++ src/app/mainMenuCharacterEditing.ts | 2 + src/game/CombatBridge.tsx | 2 + src/game/benchmark/dungeonBossBenchmark.ts | 2 +- src/game/bossTesting.test.ts | 37 +++++++ src/game/dungeonBossClassBalance.test.ts | 10 +- src/game/dungeonBossScaling.test.ts | 53 ++++++++-- src/game/dungeonBossScaling.ts | 46 +++++++-- src/game/uiLayout.test.ts | 56 +++++++++++ src/game/uiLayout.ts | 47 ++++++++- src/game/uiLayoutStore.test.ts | 48 +++++++++ src/game/uiLayoutStore.ts | 27 ++++- src/game/useArenaCombatView.ts | 2 + src/scene/MobPopulation.tsx | 5 +- src/styles.css | 5 +- src/ui/AbilityBindingsPanel.tsx | 9 +- src/ui/SpellbookPanel.tsx | 15 ++- src/ui/UiLayoutEditor.tsx | 4 +- src/ui/spellbookProgression.test.ts | 40 +++++++- 24 files changed, 575 insertions(+), 46 deletions(-) create mode 100644 scripts/playtest-dungeon-health.cjs create mode 100644 src/game/uiLayoutStore.test.ts diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index aa1db759..57befa4b 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -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, camera, input, physics, mob-rendering, or asset-loading code changes. +## 2026-09-14 New-character PC bars and missing-spell borders + +- A temporary browser fixture used the real desktop bars, ability controls, + spellbook, combat store, and character UI activation with memory-only saves. + Fresh Priest and Mage profiles populated their PC bars automatically. The + Mage started with Frostbolt, Fire Blast, Frost Nova, and Arcane Intellect. +- Clearing Lesser Heal gave its spellbook entry and ability-picker entry a + visible light blue border, including the icon border. Both computed border + colors were `rgb(146, 213, 255)`. Assigned and locked spells kept their usual + borders. Reassigning Lesser Heal removed the missing-spell border. +- Switching to the fresh Mage and back retained the Priest's cleared slot. + Screenshots of both missing-spell menus were inspected during the playtest. + The temporary fixture was removed afterward. +- Validation: 62 focused tests, TypeScript, production build including bundle + verification, and the release-candidate check passed. Full-game verification + was limited by a persistent opening-expedition loading screen in both local + development and production; menu visuals were verified in the fixture. + ## 2026-09-14 Ability hover tooltips - Entered Stormwind Stockade with the existing offline level-17 Priest through @@ -3312,3 +3330,53 @@ Vite's existing large-chunk advisory remains informational. an isolated production server and test database. Evidence is in `playtest-artifacts/boss-testing/results.json` and screenshots `01-main-menu.png` through `12-bottom-display-review.png`. + +### Boss Testing health correction + +- Testing rooms now use the boss's authored health at the scaled enemy level, + without the campaign durability floor or final-boss bonus. A lone testing + objective previously made every selected boss qualify as a campaign finale. + At character level 1 this produced a 10,987 HP minimum instead of Celebras + the Cursed's 575 HP from his level-2 native stats and authored multiplier. +- Regression checks passed: 24 tests covering testing, campaign boss balance, + level scaling, asset preparation, and display snapshots. A full production + build passed its asset audits and release bundle validation. +- Production Edge playtest confirmed a level-1 WoW Priest fighting a level-2 + Celebras with 575 maximum health. The boss model and active mechanics were + visible, and readiness labels, reports, retries, victory review, random + selection, mobile layout, automatic downloads, and lower-display review + passed without uncaught errors. Victory still uses deterministic damage; + natural time to defeat was not measured. +- Reproduce in PowerShell with `$env:BOSS_TEST_BOSS = 'Celebras the Cursed'` + followed by `node scripts/playtest-boss-testing.cjs`. Evidence is in + `playtest-artifacts/boss-testing-scaling/results.json` and paired screenshots. + +### Regular dungeon health correction + +- Regular dungeon scaling now selects the durability minimum using the + character's game. WoW uses an interpolated level curve, RoM uses 1.15 times + the expanded-class baseline, and CoA uses 1.75 times that baseline. These + independently calibrated floors keep every canonical class-role build above + the same 25-second ordinary and 45-second final-boss mechanic windows. Native + authored health can still exceed the floor. Final-boss and difficulty bonuses + remain part of campaign scaling. Boss Testing continues using scaled authored + health without a campaign minimum. +- On Easy, a level-3 WoW Priest now encounters a level-4 Targorr the Dread with + 1,300 HP in regular Stockades, compared with the prior shared 10,766 HP + minimum. The WoW final boss has a 2,340 HP minimum at that level; equivalent + RoM and CoA parties receive 7,074.8 and 10,766 HP ordinary-boss floors. +- Level-3 WoW Priest simulations with three companion seeds cleared the opening + boss in 25.7-43.6 seconds and the final boss in 48.2-79.6 seconds. These timing + samples assume companions are in range and omit boss attacks; a separate + level-3 test verifies real boss attacks execute during the shortened fight. +- Validation passed for all 271 application test files: 1,759 tests passed and + six were skipped. The shared balance cohort covers every WoW, RoM, and CoA + class-role build at levels 8, 20, 40, and 80 with three deterministic seeds. +- The earlier production Edge capture recorded the intermediate 1,200 HP WoW + floor and is superseded by the final 1,300 HP calibration. The browser script + now checks the final value; a new live screenshot has not been recorded. +- Reproduce with `node scripts/playtest-dungeon-health.cjs`. Browser evidence is + in `playtest-artifacts/dungeon-health-scaling/results.json` and + `stockades-level-three.png`. Run the scaling tests with `BOSS_BALANCE_REPORT=1` + to record companion timing samples in `artifacts/boss-difficulty/ragefire-balance.json`; + the all-class report is `artifacts/boss-difficulty/wow-rom-coa-balance.json`. diff --git a/package-lock.json b/package-lock.json index f9e784f1..e6abc757 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.43", + "version": "0.1.44", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.43", + "version": "0.1.44", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index abb3e9f3..e371b03a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.43", + "version": "0.1.44", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index 6b517169..38f2668b 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.43", - "versionCode": 1043 + "versionName": "0.1.44", + "versionCode": 1044 } diff --git a/scripts/playtest-boss-testing.cjs b/scripts/playtest-boss-testing.cjs index 6a08226e..5b0fb380 100644 --- a/scripts/playtest-boss-testing.cjs +++ b/scripts/playtest-boss-testing.cjs @@ -2,7 +2,8 @@ const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primar const fs = require('node:fs'); const path = require('node:path'); 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 }); const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187'; (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('heading', { name: 'Boss Testing', exact: true }).waitFor(); const picker = page.getByLabel(/Choose a boss/); - const option = await picker.locator('option').evaluateAll((options) => options.find((entry) => /Lady Anacondra.*Wailing Caverns/.test(entry.textContent))?.value); - assert(option, 'Lady Anacondra is selectable'); + const option = await picker.locator('option').evaluateAll((options, name) => options.find((entry) => entry.textContent.startsWith(`${name} ยท`))?.value, selectedBossName); + assert(option, `${selectedBossName} is selectable`); await picker.selectOption(option); await page.getByRole('button', { name: 'Test selected boss', exact: true }).waitFor({ state: 'visible' }); await capture('02-boss-selection'); @@ -68,9 +69,17 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187'; const game = useGameStore.getState(); const combat = useCombatStore.getState(); const boss = game.bossTest; - return { boss, xp: combat.xp, saved: JSON.stringify(useShellStore.getState().characters), count: Object.keys(combat.mobs).length }; + return { boss, xp: combat.xp, saved: JSON.stringify(useShellStore.getState().characters), count: Object.keys(combat.mobs).length, + classId: combat.classId, level: combat.level, bossLevel: combat.mobs[boss.runtimeIds[0]].level, + bossMaxHealth: combat.mobs[boss.runtimeIds[0]].maxHealth }; }); assert.equal(before.count, 1, 'Only the selected boss spawns initially'); + if (selectedBossName === 'Celebras the Cursed') { + assert.equal(before.classId, 'priest'); + assert.equal(before.level, 1); + assert.equal(before.bossLevel, 2); + assert.equal(before.bossMaxHealth, 575, 'Level-one Celebras uses scaled native health without campaign floors'); + } await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Boss readiness & report', exact: true }).click(); await page.getByRole('button', { name: 'Mark not ready', exact: true }).click(); @@ -115,7 +124,7 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187'; await capture('07-mobile-selection'); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'No mobile horizontal overflow'); await page.setViewportSize({ width: 1440, height: 1000 }); - await page.getByLabel('Search bosses').fill('Lady Anacondra'); + await page.getByLabel('Search bosses').fill(selectedBossName); await page.getByRole('button', { name: 'Fight a random boss', exact: true }).click(); await page.locator('canvas').waitFor(); await page.waitForTimeout(3000); @@ -154,7 +163,9 @@ const base = process.env.BOSS_TEST_URL || 'http://127.0.0.1:4187'; await capture('12-bottom-display-review'); await bottom.close(); assert.deepEqual(errors, [], 'No browser runtime errors'); - fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ passed: true, boss: before.boss, initialMobCount: before.count, xpUnchanged: true, profileUnchanged: true, automaticDownload: 'dungeon:ragefire-chasm', report: reports[0], errors }, null, 2)}\n`); + fs.writeFileSync(path.join(directory, 'results.json'), `${JSON.stringify({ passed: true, boss: before.boss, classId: before.classId, + characterLevel: before.level, bossLevel: before.bossLevel, bossMaxHealth: before.bossMaxHealth, + initialMobCount: before.count, xpUnchanged: true, profileUnchanged: true, automaticDownload: 'dungeon:ragefire-chasm', report: reports[0], errors }, null, 2)}\n`); console.log('Boss Testing live flow passed'); } catch (error) { await capture('failure'); diff --git a/scripts/playtest-dungeon-health.cjs b/scripts/playtest-dungeon-health.cjs new file mode 100644 index 00000000..98202905 --- /dev/null +++ b/scripts/playtest-dungeon-health.cjs @@ -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; }); diff --git a/src/app/mainMenuCharacterEditing.ts b/src/app/mainMenuCharacterEditing.ts index 2ddd3b32..984706d7 100644 --- a/src/app/mainMenuCharacterEditing.ts +++ b/src/app/mainMenuCharacterEditing.ts @@ -5,6 +5,7 @@ import { } from "./accountRepository"; import { selectedCharacter, useShellStore } from "./shellStore"; import { useCombatStore } from "../game/combatStore"; +import { activateCharacterUi } from "../game/uiLayoutStore"; // Reuse the character editors without mounting the dungeon's simulation bridge. export function beginMainMenuCharacterEditing(storage?: StorageLike): (() => void) | null { @@ -16,6 +17,7 @@ export function beginMainMenuCharacterEditing(storage?: StorageLike): (() => voi const ownerId = shell.session.ownerId; const characterId = character.id; useCombatStore.getState().initializeCharacter(character); + activateCharacterUi(character); return useCombatStore.subscribe((combat, previous) => { const current = useShellStore.getState(); diff --git a/src/game/CombatBridge.tsx b/src/game/CombatBridge.tsx index 99436111..06811cc2 100644 --- a/src/game/CombatBridge.tsx +++ b/src/game/CombatBridge.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { useShellStore } from "../app/shellStore"; import { useOnlineSessionStore } from "../app/onlineSessionStore"; import { useCombatStore } from "./combatStore"; +import { activateCharacterUi } from "./uiLayoutStore"; import { advanceDungeonPartyCombat } from "./partyRuntime"; import { useGameStore } from "./store"; import { activateManastormActiveSpellLoadout } from "./manastormSpellLoadout"; @@ -41,6 +42,7 @@ export function CombatBridge() { useEffect(() => { if (!character) return; useCombatStore.getState().initializeCharacter({ ...character, bossCards: useShellStore.getState().bossCards, role: activeRole }); + activateCharacterUi(character); useCombatStore.getState().synchronizeActors(); if (gameMode === "manastorm") { activateManastormActiveSpellLoadout( diff --git a/src/game/benchmark/dungeonBossBenchmark.ts b/src/game/benchmark/dungeonBossBenchmark.ts index 40074f90..a734d89b 100644 --- a/src/game/benchmark/dungeonBossBenchmark.ts +++ b/src/game/benchmark/dungeonBossBenchmark.ts @@ -28,7 +28,7 @@ export async function runDungeonBossBenchmark( const dungeon = requireDungeonDefinition("ragefire-chasm"); const spawn = dungeon.staticSpawns.find((entry) => entry.id === bossId)!; const entity = dungeon.entities[spawn.entityId]; - const scaling = dungeonEnemyScaling(dungeon, entity, bossId, build.level, "normal", 1); + const scaling = dungeonEnemyScaling(dungeon, entity, bossId, build.level, "normal", 1, build.source); const specs = specializationsForClass(build.classId).filter((spec) => spec.role === build.role); const specialization = specs.find((spec) => spec.displayName === build.orientation) ?? specs[0]; clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); resetCombatSpatialProvider(); diff --git a/src/game/bossTesting.test.ts b/src/game/bossTesting.test.ts index bc026378..dc0a50a0 100644 --- a/src/game/bossTesting.test.ts +++ b/src/game/bossTesting.test.ts @@ -6,6 +6,10 @@ import { useGameStore } from "./store"; import { useCombatStore } from "./combatStore"; import { bossTestIsDefeated } from "./BossTestingBridge"; import type { DungeonDefinition } from "./dungeonTypes"; +import { dungeonEnemyScaling } from "./dungeonBossScaling"; +import { creatureRuntimeBaseStats } from "./azerothCoreReference"; +import { bossDifficultyMultipliers } from "./dungeonDifficulty"; +import { finalDungeonBoss } from "./dungeonRunStore"; afterEach(() => { useGameStore.getState().activateDungeon("wailing-caverns"); @@ -56,6 +60,39 @@ describe("Boss Testing", () => { expect(useGameStore.getState()).toMatchObject({ gmTestMode: true, bossTest: room.session, overlay: null }); }); + it("uses Celebras's native health at the player's scaled level without campaign health floors", () => { + const celebras = BOSS_TESTING_CATALOG.find((entry) => entry.name === "Celebras the Cursed")!; + const maraudon = requireDungeonDefinition(celebras.dungeonId); + const room = createBossTestingRoom(maraudon, celebras); + const entity = room.definition.entities[celebras.entityId]; + const scale = (level: number, difficulty = 1) => dungeonEnemyScaling(room.definition, entity, + celebras.spawn.id, level, room.definition.defaultDifficultyId, difficulty); + const nativeHealth = creatureRuntimeBaseStats(entity.combat!.serverEntry!, 2)!.health + * entity.combat!.healthMultiplier!; + expect(scale(1)).toMatchObject({ level: 2, maxHealth: nativeHealth }); + expect(Math.round(scale(1).maxHealth)).toBe(575); + expect(scale(8).maxHealth).toBeGreaterThan(scale(1).maxHealth); + expect(scale(20).maxHealth).toBeGreaterThan(scale(8).maxHealth); + expect(scale(1, 10).maxHealth).toBe(nativeHealth * bossDifficultyMultipliers(10).health); + const campaign = dungeonEnemyScaling(maraudon, maraudon.entities[celebras.entityId], celebras.spawn.id, + 1, maraudon.defaultDifficultyId, 1); + expect(campaign.maxHealth).toBe(900); + expect(scale(1).damageMultiplier).toBe(campaign.damageMultiplier); + expect(entity.combat?.attacks).toEqual(maraudon.entities[celebras.entityId].combat?.attacks); + }); + + it("omits the campaign finale bonus even when testing the source dungeon's final boss", () => { + const final = finalDungeonBoss(source)!; + const finalBoss = BOSS_TESTING_CATALOG.find((entry) => entry.dungeonId === source.id && entry.spawn.id === final.id)!; + const room = createBossTestingRoom(source, finalBoss); + const entity = room.definition.entities[finalBoss.entityId]; + const testing = dungeonEnemyScaling(room.definition, entity, finalBoss.spawn.id, 1, source.defaultDifficultyId, 1); + const campaign = dungeonEnemyScaling(source, entity, finalBoss.spawn.id, 1, source.defaultDifficultyId, 1); + expect(testing.maxHealth).toBe(creatureRuntimeBaseStats(entity.combat!.serverEntry!, 2)!.health + * (entity.combat?.healthMultiplier ?? 1)); + expect(campaign.maxHealth).toBe(Math.max(testing.maxHealth, 900 * 1.8)); + }); + it("recognizes actual boss deaths and awards no progression or collectible rewards", () => { const room = createBossTestingRoom(source, boss); registerBossTestingDungeon(room.definition, source.id); diff --git a/src/game/dungeonBossClassBalance.test.ts b/src/game/dungeonBossClassBalance.test.ts index 773f1841..fd593566 100644 --- a/src/game/dungeonBossClassBalance.test.ts +++ b/src/game/dungeonBossClassBalance.test.ts @@ -1,6 +1,6 @@ import { afterAll, describe, expect, it } from "vitest"; import { mkdirSync, writeFileSync } from "node:fs"; -import { BASE_CLASS_IDS, COA_CLASS_IDS } from "../app/characterCatalog"; +import { BASE_CLASS_IDS, COA_CLASS_IDS, ROM_CLASS_IDS } from "../app/characterCatalog"; import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds"; import { runDungeonBossBenchmark } from "./benchmark/dungeonBossBenchmark"; import { BENCHMARK_POLICY_ID } from "./benchmark/benchmarkController"; @@ -14,16 +14,16 @@ const samples: unknown[] = []; afterAll(() => { if (process.env.BOSS_BALANCE_REPORT !== "1") return; mkdirSync("artifacts/boss-difficulty", { recursive: true }); - writeFileSync("artifacts/boss-difficulty/wow-coa-balance.json", `${JSON.stringify({ + writeFileSync("artifacts/boss-difficulty/wow-rom-coa-balance.json", `${JSON.stringify({ difficulty: 1, dungeon: dungeon.id, durationLimitSeconds: 60, targets: { ordinary: 25, final: 45 }, policy: BENCHMARK_POLICY_ID, gear: "resolved starter heirlooms", samples, }, null, 2)}\n`); }); -describe("default boss difficulty with WoW and CoA classes", () => { +describe("default boss difficulty across WoW, RoM, and CoA classes", () => { for (const level of [8, 20, 40, 80]) { it(`benchmarks every class-role build at level ${level}`, async () => { - const builds = await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...COA_CLASS_IDS]); + const builds = await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...ROM_CLASS_IDS, ...COA_CLASS_IDS]); const tooFast: string[] = []; for (const build of builds) { // Let the test worker deliver progress between CPU-heavy simulations. @@ -43,7 +43,7 @@ describe("default boss difficulty with WoW and CoA classes", () => { for (const level of [8, 80]) { it(`records healer outcomes with the final boss attacking at level ${level}`, async () => { - const builds = (await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...COA_CLASS_IDS])) + const builds = (await loadCanonicalBenchmarkBuilds(level, [...BASE_CLASS_IDS, ...ROM_CLASS_IDS, ...COA_CLASS_IDS])) .filter((build) => build.role === "healer"); for (const build of builds) { const last = await runDungeonBossBenchmark(build, final.id, "class-balance-2", true); diff --git a/src/game/dungeonBossScaling.test.ts b/src/game/dungeonBossScaling.test.ts index ed29e797..2f0234fc 100644 --- a/src/game/dungeonBossScaling.test.ts +++ b/src/game/dungeonBossScaling.test.ts @@ -11,6 +11,7 @@ import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock import { subscribeCombatEvents } from "./combatEvents"; import { bossDifficultyMultipliers } from "./dungeonDifficulty"; import { abilitiesForClass } from "./abilityCatalog"; +import type { ContentCategoryId } from "../app/contentCategories"; const dungeon = requireDungeonDefinition("ragefire-chasm"); const final = finalDungeonBoss(dungeon)!; @@ -21,15 +22,16 @@ afterAll(() => { writeFileSync("artifacts/boss-difficulty/ragefire-balance.json", `${JSON.stringify({ samples }, null, 2)}\n`); }); -function fight(bossId: string, level: number, difficulty: number, seed: string, mechanics = false, playerDamage = false) { +function fight(bossId: string, level: number, difficulty: number, seed: string, mechanics = false, playerDamage = false, + characterSource: ContentCategoryId = "rom", encounterDungeon = dungeon) { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock(); - const entity = dungeon.entities[dungeon.staticSpawns.find((spawn) => spawn.id === bossId)!.entityId]; - const scaling = dungeonEnemyScaling(dungeon, entity, bossId, level, "normal", difficulty); + const entity = encounterDungeon.entities[encounterDungeon.staticSpawns.find((spawn) => spawn.id === bossId)!.entityId]; + const scaling = dungeonEnemyScaling(encounterDungeon, entity, bossId, level, "normal", difficulty, characterSource); const combat = useCombatStore.getState(); - const classId = playerDamage ? "rom-mage" : "rom-priest"; - combat.initializeCharacter({ classId, raceId: "rom-human", level }); + const classId = characterSource === "wow" ? (playerDamage ? "mage" : "priest") : (playerDamage ? "rom-mage" : "rom-priest"); + combat.initializeCharacter({ classId, raceId: characterSource === "wow" ? "human" : "rom-human", level }); combat.setPlayerPosition([-18, 0, 0]); - usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health, playerDamage ? "damage" : "healer", 5, "rom"); + usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health, playerDamage ? "damage" : "healer", 5, characterSource); combat.synchronizeActors(); combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry, ...scaling, attacks: entity.combat?.attacks, xpReward: 0 }); @@ -40,7 +42,8 @@ function fight(bossId: string, level: number, difficulty: number, seed: string, } let firstDamage: number | undefined; let incomingDamage = 0; let healing = 0; - const heal = abilitiesForClass("rom-priest").find((ability) => ability.name === "Urgent Heal")!; + const heal = abilitiesForClass(classId).find((ability) => ability.target === "friendly" + && ability.effects.some((effect) => effect.kind === "heal") && (ability.unlockLevel ?? 1) <= level)!; const damage = abilitiesForClass(classId).find((ability) => ability.target === "hostile" && ability.effects.some((effect) => effect.kind === "damage"))!; combat.selectMob(bossId); @@ -59,7 +62,8 @@ function fight(bossId: string, level: number, difficulty: number, seed: string, const injured = usePartyStore.getState().members.filter((member) => member.health > 0) .sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0]; if (injured && injured.health < injured.maxHealth * .8 && !useCombatStore.getState().activeCast) { - usePartyStore.getState().selectMember(injured.id); combat.castAbility(heal.id, undefined, now); + usePartyStore.getState().selectMember(injured.id); + combat.castAbility(heal.id, { targetActorId: injured.id, now }); } } const mob = useCombatStore.getState().mobs[bossId]; @@ -72,6 +76,39 @@ function fight(bossId: string, level: number, difficulty: number, seed: string, } describe("dungeon boss durability and difficulty", () => { + const stockades = requireDungeonDefinition("stormwind-stockade"); + const stockadesBoss = stockades.bosses[0]; + const stockadesEntity = stockades.entities[stockades.staticSpawns.find((entry) => entry.id === stockadesBoss.id)!.entityId]; + + it("gives each level-three Stockades party its source-calibrated health minimum", () => { + const easy = dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "wow"); + expect(easy).toMatchObject({ level: 4, maxHealth: 1300 }); + expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 10, "wow").maxHealth) + .toBe(1300 * bossDifficultyMultipliers(10).health); + // The dungeon catalog must not choose the companion power curve. + expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "rom").maxHealth).toBeCloseTo(7074.8); + expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "coa").maxHealth).toBe(10766); + }); + + for (const level of [1, 3, 8, 20, 40, 80]) { + it(`preserves WoW boss mechanic windows in Stockades at level ${level}`, () => { + for (const seed of ["wow-health-1", "wow-health-2", "wow-health-3"]) { + const ordinary = fight(stockadesBoss.id, level, 1, seed, false, false, "wow", stockades); + const last = fight(finalDungeonBoss(stockades)!.id, level, 1, seed, false, false, "wow", stockades); + samples.push({ level, seed, boss: ordinary, final: last }); + expect(ordinary.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(25); + expect(last.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(45); + } + }); + } + + it("allows real Stockades boss mechanics during a level-three WoW Priest party fight", () => { + const result = fight(stockadesBoss.id, 3, 1, "wow-health-1", true, false, "wow", stockades); + expect(result.seconds, JSON.stringify(result)).not.toBeNull(); + expect(result.seconds!).toBeLessThanOrEqual(180); + expect(result.attacks).toBeGreaterThan(0); + expect(result.incomingDamage).toBeGreaterThan(0); + }); it("increases boss health and damage at every slider step without changing trash", () => { const spawn = dungeon.staticSpawns.find((entry) => entry.id === final.id)!; const entity = dungeon.entities[spawn.entityId]; diff --git a/src/game/dungeonBossScaling.ts b/src/game/dungeonBossScaling.ts index 35e62b8a..b378693c 100644 --- a/src/game/dungeonBossScaling.ts +++ b/src/game/dungeonBossScaling.ts @@ -4,11 +4,41 @@ import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLe import { finalDungeonBoss } from "./dungeonRunStore"; import type { DungeonDefinition } from "./dungeonTypes"; import type { PopulationEntityDefinition } from "./mobPopulation"; +import type { ContentCategoryId } from "../app/contentCategories"; -/** Shared by rendering and deterministic combat benchmarks; preserves authored stats above the floor. */ +const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [ + [2, 900], + [4, 1_300], + [9, 7_000], + [21, 11_000], + [41, 23_000], + [80, 180_000], +]; + +function interpolatedFloor(level: number, anchors: readonly (readonly [number, number])[]): number { + const upperIndex = anchors.findIndex(([anchorLevel]) => level <= anchorLevel); + if (upperIndex <= 0) return anchors[0][1]; + if (upperIndex < 0) return anchors[anchors.length - 1][1]; + const [lowerLevel, lowerHealth] = anchors[upperIndex - 1]; + const [upperLevel, upperHealth] = anchors[upperIndex]; + const progress = (level - lowerLevel) / (upperLevel - lowerLevel); + return lowerHealth + (upperHealth - lowerHealth) * progress; +} + +function partyBossHealthFloor(level: number, characterSource: ContentCategoryId): number { + if (characterSource === "wow") return interpolatedFloor(level, WOW_BOSS_HEALTH_FLOOR); + const baseline = 1_000 + level * 1_200 + level * level * 22; + // Each catalog is calibrated independently against its strongest canonical + // class-role cohort so every fight retains the 25/45-second mechanic window. + return baseline * (characterSource === "rom" ? 1.15 : 1.75); +} + +/** Campaign fights retain a durability floor; testing rooms use level-scaled authored stats. */ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: PopulationEntityDefinition, - instanceId: string, characterLevel: number, difficultyId: string, bossDifficulty: number) { + instanceId: string, characterLevel: number, difficultyId: string, bossDifficulty: number, + characterSource: ContentCategoryId = "wow") { const boss = entity.kind === "boss"; + const testing = dungeon.id.startsWith("boss-testing:"); const level = scaledDungeonEnemyLevel(characterLevel, boss); const authoredHealth = entity.combat?.healthMultiplier ?? 1; const authoredDamage = entity.combat?.damageMultiplier ?? 1; @@ -16,10 +46,12 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati const native = entity.combat?.serverEntry ? creatureRuntimeBaseStats(entity.combat.serverEntry, level) : null; const originalHealth = (baseline ?? native?.health ?? (boss ? 260 + level * 65 : 72 + level * 22)) * authoredHealth; const multipliers = boss ? bossDifficultyMultipliers(bossDifficulty) : { health: 1, damage: 1 }; - const finalBoss = boss && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId; - // Calibrated against active RoM, WoW and CoA class-role rotations with heirlooms. - // Every class faces the same floor; the margin covers stronger CoA burst builds. - const bossFloor = (1_000 + level * 1_200 + level * level * 22) * 1.75 * (finalBoss ? 1.8 : 1); - return { level, maxHealth: (boss ? Math.max(originalHealth, bossFloor) : originalHealth) * multipliers.health, + const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId; + // Match durability to the character's game, including when crossing dungeon catalogs. + const partyHealthFloor = partyBossHealthFloor(level, characterSource); + const bossFloor = partyHealthFloor * (finalBoss ? 1.8 : 1); + // A test room's sole objective must not promote every boss to a campaign finale + // or replace low-level native health with the campaign's full-party minimum. + return { level, maxHealth: (boss && !testing ? Math.max(originalHealth, bossFloor) : originalHealth) * multipliers.health, healthMultiplier: authoredHealth * multipliers.health, damageMultiplier: authoredDamage * multipliers.damage }; } diff --git a/src/game/uiLayout.test.ts b/src/game/uiLayout.test.ts index 34e95d68..4b7892b3 100644 --- a/src/game/uiLayout.test.ts +++ b/src/game/uiLayout.test.ts @@ -3,9 +3,64 @@ import { actionKeyForEvent, assignDesktopAbility, bindDesktopSlot, defaultUiLayout, desktopSlotForKey, exportUiPreferences, importUiPreferences, normalizeActionKey, normalizeUiPreferences, resetDesktopAbilities, resolveDpsMeterDisplay, HUD_ELEMENTS, resolveHudScreen, + activateDesktopAbilityLoadout, assignedActionBarAbilityIds, } from "./uiLayout"; describe("personal UI layouts", () => { + it("populates fresh PC bars without changing shared keys, geometry, templates, or mobile controls", () => { + const ui = normalizeUiPreferences(null); + ui.current.pc.wowBars = true; + ui.current.pc.barCount = 2; + ui.current.pc.bars[1].columns = 3; + ui.current.pc = bindDesktopSlot(ui.current.pc, 1, 0, "Ctrl+KeyQ"); + ui.slots.pc[0] = { name: "Raid", layout: ui.current.pc }; + const defaults = Array.from({ length: 14 }, (_, i) => `spell-${i}`); + const activated = activateDesktopAbilityLoadout(ui, "new:priest:", defaults, defaults); + expect(activated.current.pc.bars[0].slots[0]).toEqual({ abilityId: "spell-0", key: "Digit1" }); + expect(activated.current.pc.bars[1].slots[0]).toEqual({ abilityId: "spell-12", key: "Ctrl+KeyQ" }); + expect(activated.current.pc.bars[1].columns).toBe(3); + expect(activated.current.pc.barCount).toBe(2); + expect(activated.current.pc.wowBars).toBe(true); + expect(activated.current.mobile).toEqual(ui.current.mobile); + expect(activated.slots).toEqual(ui.slots); + expect(ui.current.pc.bars[0].slots[0].abilityId).toBeNull(); + }); + it("restores separate character and ordered-class loadouts, including intentionally empty bars", () => { + let ui = activateDesktopAbilityLoadout(normalizeUiPreferences(null), "one:priest:", ["heal"], ["heal"]); + ui.desktopAbilityLoadouts["one:priest:"] = Array(60).fill(null); + ui = activateDesktopAbilityLoadout(ui, "two:priest:", ["heal"], ["heal"]); + expect(ui.current.pc.bars[0].slots[0].abilityId).toBe("heal"); + ui = activateDesktopAbilityLoadout(ui, "one:priest:", ["heal"], ["heal"]); + expect(ui.current.pc.bars.every((bar) => bar.slots.every((slot) => slot.abilityId === null))).toBe(true); + ui = activateDesktopAbilityLoadout(ui, "one:rom-priest:rom-warrior", ["secondary-attack"], ["secondary-attack"]); + expect(ui.current.pc.bars[0].slots[0].abilityId).toBe("secondary-attack"); + expect(importUiPreferences(exportUiPreferences(ui))).toEqual(ui); + }); + it("adopts compatible legacy assignments once and repairs bars holding only another class's spells", () => { + const ui = normalizeUiPreferences(null); + ui.current.pc = assignDesktopAbility(ui.current.pc, 0, 2, "custom-heal"); + const migrated = activateDesktopAbilityLoadout(ui, "one:priest:", ["heal"], ["heal", "custom-heal"]); + expect(migrated.current.pc.bars[0].slots[0].abilityId).toBeNull(); + expect(migrated.current.pc.bars[0].slots[2].abilityId).toBe("custom-heal"); + const next = activateDesktopAbilityLoadout(migrated, "two:priest:", ["heal"], ["heal", "custom-heal"]); + expect(next.current.pc.bars[0].slots[0].abilityId).toBe("heal"); + expect(next.current.pc.bars[0].slots[2].abilityId).toBeNull(); + const repaired = activateDesktopAbilityLoadout(ui, "new:mage:", ["fireball"], ["fireball"]); + expect(repaired.current.pc.bars[0].slots[0].abilityId).toBe("fireball"); + const oldExport = JSON.parse(exportUiPreferences(ui)); + delete oldExport.desktopAbilityLoadouts; + expect(importUiPreferences(JSON.stringify(oldExport)).desktopAbilityLoadouts).toEqual({}); + }); + it("checks the selected layout across modifier layers and active PC bars", () => { + const layout = defaultUiLayout(); + layout.bars[0].slots[0].abilityId = "pc-heal"; + layout.bars[1].slots[0].abilityId = "inactive-heal"; + const bindings = { "primary:face-bottom": "controller-heal", "tertiary:r2": "modifier-heal" }; + expect([...assignedActionBarAbilityIds(layout, bindings, false)]).toEqual(["controller-heal", "modifier-heal"]); + expect([...assignedActionBarAbilityIds(layout, bindings, true)]).toEqual(["pc-heal"]); + layout.barCount = 2; + expect(assignedActionBarAbilityIds(layout, bindings, true).has("inactive-heal")).toBe(true); + }); it("assigns every element one display, saves routing, and keeps single-display widgets available", () => { const preferences = normalizeUiPreferences(null); const layout = preferences.current.mobile; @@ -117,6 +172,7 @@ describe("personal UI layouts", () => { ["invalid keys", (data: any) => { data.current.pc.bars[0].slots[0].key = "NotAKey"; }], ["duplicate chords", (data: any) => { data.current.pc.bars[0].slots[1].key = "Digit1"; }], ["missing buttons", (data: any) => { data.current.pc.bars[0].slots.pop(); }], + ["invalid character bars", (data: any) => { data.desktopAbilityLoadouts = { character: ["heal"] }; }], ])("rejects %s without mutating the original preferences", (_, mutate) => { const ui = normalizeUiPreferences(null); const original = exportUiPreferences(ui); diff --git a/src/game/uiLayout.ts b/src/game/uiLayout.ts index dd8feb53..0dc0a1b2 100644 --- a/src/game/uiLayout.ts +++ b/src/game/uiLayout.ts @@ -36,6 +36,8 @@ export interface UiTemplate { name: string; layout: UiLayout } export interface UiPreferences { version: 1; keybinds: InputPreferences; + /** Ability assignments belong to a character and ordered class pair; geometry and keys stay shared. */ + desktopAbilityLoadouts: Record; current: Record; slots: Record; } @@ -139,6 +141,12 @@ export function normalizeUiPreferences(value: unknown): UiPreferences { return { version: 1, keybinds: normalizeInputPreferences(source.keybinds), + desktopAbilityLoadouts: Object.fromEntries(Object.entries(record(source.desktopAbilityLoadouts)) + .filter(([key, value]) => key.length <= 300 && Array.isArray(value)) + .map(([key, value]) => [key, Array.from({ length: 60 }, (_, index) => { + const id = (value as unknown[])[index]; + return typeof id === "string" && id.length <= 200 ? id : null; + })])), current: { mobile: normalizeUiLayout(current.mobile), pc: normalizeUiLayout(current.pc) }, slots: Object.fromEntries(UI_DEVICES.map((device) => [device, Array.from({ length: 3 }, (_, index) => { const slot = record(Array.isArray(slots[device]) ? slots[device][index] : null); @@ -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. */ -export function resetDesktopAbilities(layout: UiLayout, abilityIds: readonly string[], barIndex?: number): UiLayout { +export function resetDesktopAbilities(layout: UiLayout, abilityIds: readonly (string | null)[], barIndex?: number): UiLayout { return { ...layout, bars: layout.bars.map((bar, b) => barIndex !== undefined && b !== barIndex ? bar : { ...bar, slots: bar.slots.map((slot, s) => ({ ...slot, abilityId: abilityIds[b * 12 + s] ?? null })), }) }; } + +/** Use the same class defaults as mobile, while preserving an existing character's empty slots. */ +export function activateDesktopAbilityLoadout( + preferences: UiPreferences, key: string, defaults: readonly string[], availableIds: readonly string[], +): UiPreferences { + const layout = preferences.current.pc; + const available = new Set(availableIds); + const legacy = layout.bars.flatMap((bar) => bar.slots.map((slot) => slot.abilityId)); + const saved = preferences.desktopAbilityLoadouts[key]; + // Adopt the old account-wide assignments once, only for a compatible character. + const abilities = saved ?? (Object.keys(preferences.desktopAbilityLoadouts).length === 0 + && legacy.some((id) => id && available.has(id)) + ? legacy.map((id) => id && available.has(id) ? id : null) + : defaults); + const pc = resetDesktopAbilities(layout, abilities); + return { ...preferences, current: { ...preferences.current, pc }, + desktopAbilityLoadouts: { ...preferences.desktopAbilityLoadouts, + [key]: pc.bars.flatMap((bar) => bar.slots.map((slot) => slot.abilityId)) }, + }; +} + +/** Modifier layers count together; inactive PC bars cannot be used until enabled. */ +export function assignedActionBarAbilityIds( + layout: UiLayout, bindings: Readonly>, desktop: boolean, +): ReadonlySet { + const ids = desktop + ? layout.bars.slice(0, layout.barCount).flatMap((bar) => bar.slots.map((slot) => slot.abilityId)) + : Object.values(bindings); + return new Set(ids.filter((id): id is string => Boolean(id))); +} export function desktopSlotForKey(layout: UiLayout, key: string | null): DesktopActionSlot | null { if (!layout.wowBars || !key) return null; // Hidden bars retain their bindings, like WoW; reducing the bar count deactivates them. @@ -180,6 +218,13 @@ export function importUiPreferences(text: string): UiPreferences { let source: Record; try { source = record(JSON.parse(text)); } catch { throw new Error("This file is not valid JSON."); } if (source.format !== "healer-man-ui" || source.version !== 1) throw new Error("Unsupported UI file format or version."); + if (source.desktopAbilityLoadouts !== undefined) { + if (!source.desktopAbilityLoadouts || typeof source.desktopAbilityLoadouts !== "object" || Array.isArray(source.desktopAbilityLoadouts)) throw new Error("Invalid character action bars."); + for (const [key, ids] of Object.entries(record(source.desktopAbilityLoadouts))) { + if (key.length > 300 || !Array.isArray(ids) || ids.length !== 60 + || ids.some((id) => id !== null && (typeof id !== "string" || id.length > 200))) throw new Error("Invalid character action bars."); + } + } const validateLayout = (value: unknown) => { const layout = record(value); if (layout.dpsMeterDisplay !== undefined && !["auto", "main", "companion"].includes(String(layout.dpsMeterDisplay))) throw new Error("Invalid DPS meter display."); diff --git a/src/game/uiLayoutStore.test.ts b/src/game/uiLayoutStore.test.ts new file mode 100644 index 00000000..579d05d4 --- /dev/null +++ b/src/game/uiLayoutStore.test.ts @@ -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(); + 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); + }); +}); diff --git a/src/game/uiLayoutStore.ts b/src/game/uiLayoutStore.ts index 5a3ca7ad..5d310616 100644 --- a/src/game/uiLayoutStore.ts +++ b/src/game/uiLayoutStore.ts @@ -2,7 +2,9 @@ import { create } from "zustand"; import { Capacitor } from "@capacitor/core"; import { loadUiPreferences, saveUiPreferences } from "../app/accountRepository"; import { useShellStore } from "../app/shellStore"; -import { normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout"; +import type { CharacterProfile } from "../app/types"; +import { abilitiesForCharacter, defaultActionBarForCharacter } from "./abilityCatalog"; +import { activateDesktopAbilityLoadout, normalizeUiPreferences, type UiDevice, type UiLayout, type UiPreferences } from "./uiLayout"; import { publishInputPreferences } from "../input/keybindRuntime"; export function currentUiDevice(): UiDevice { @@ -13,6 +15,7 @@ export function currentUiDevice(): UiDevice { } interface UiLayoutState { ownerId: string | null; + desktopLoadoutKey: string | null; preferences: UiPreferences; draft: UiLayout | null; device: UiDevice; @@ -25,7 +28,7 @@ interface UiLayoutState { finish: (save: boolean) => void; } export const useUiLayoutStore = create((set, get) => ({ - ownerId: null, preferences: normalizeUiPreferences(null), draft: null, device: currentUiDevice(), + ownerId: null, desktopLoadoutKey: null, preferences: normalizeUiPreferences(null), draft: null, device: currentUiDevice(), mode: "layout", selectedButton: null, selectedElement: null, begin: () => { const device = currentUiDevice(); @@ -36,7 +39,11 @@ export const useUiLayoutStore = create((set, get) => ({ const ownerId = get().ownerId; if (!ownerId) throw new Error("Sign in or choose an offline profile before saving your UI."); // Write first: storage errors leave the editor open with its draft intact. - const saved = saveUiPreferences(ownerId, preferences); + const key = get().desktopLoadoutKey; + const saved = saveUiPreferences(ownerId, key ? { ...preferences, + desktopAbilityLoadouts: { ...preferences.desktopAbilityLoadouts, + [key]: preferences.current.pc.bars.flatMap((bar) => bar.slots.map((slot) => slot.abilityId)) }, + } : preferences); set({ preferences: saved }); }, finish: (save) => { @@ -48,7 +55,7 @@ export const useUiLayoutStore = create((set, get) => ({ function activateOwner() { const ownerId = useShellStore.getState().session?.ownerId ?? null; - useUiLayoutStore.setState({ ownerId, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null }); + useUiLayoutStore.setState({ ownerId, desktopLoadoutKey: null, preferences: ownerId ? loadUiPreferences(ownerId) : normalizeUiPreferences(null), draft: null }); } activateOwner(); publishInputPreferences(useUiLayoutStore.getState().preferences.keybinds); @@ -57,6 +64,18 @@ useShellStore.subscribe((state, previous) => { if (state.session !== previous.session) activateOwner(); }); +/** Call after the character's catalogs have loaded, in both menu and gameplay paths. */ +export function activateCharacterUi(character: CharacterProfile): void { + const ui = useUiLayoutStore.getState(); + if (ui.ownerId !== character.ownerId) return; + const key = `${character.id}:${character.classId}:${character.secondaryClassId ?? ""}`; + useUiLayoutStore.setState({ desktopLoadoutKey: key, draft: null, + preferences: activateDesktopAbilityLoadout(ui.preferences, key, + defaultActionBarForCharacter(character.classId, character.secondaryClassId, character.level), + abilitiesForCharacter(character.classId, character.secondaryClassId).map((ability) => ability.id)), + }); +} + export function activeUiLayout(): UiLayout { const state = useUiLayoutStore.getState(); return state.draft ?? state.preferences.current[currentUiDevice()]; diff --git a/src/game/useArenaCombatView.ts b/src/game/useArenaCombatView.ts index c58bb7ff..b1851290 100644 --- a/src/game/useArenaCombatView.ts +++ b/src/game/useArenaCombatView.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import type { CharacterProfile } from "../app/types"; import { useShellStore } from "../app/shellStore"; import { useCombatStore } from "./combatStore"; +import { activateCharacterUi } from "./uiLayoutStore"; import { usePartyStore } from "./partyStore"; import { useGameStore } from "./store"; import { useArenaStore } from "./arenaStore"; @@ -16,6 +17,7 @@ export function useArenaCombatView(character: CharacterProfile | null, actor: Ar const previousParty = usePartyStore.getState(); const previousGame = useGameStore.getState(); useCombatStore.getState().initializeCharacter(character); + activateCharacterUi(character); usePartyStore.setState({ members: [] }); useGameStore.setState({ overlay: null, paused: false, companionOpen: false }); const unsubscribe = useCombatStore.subscribe((state, before) => { diff --git a/src/scene/MobPopulation.tsx b/src/scene/MobPopulation.tsx index 42b6d064..69023a88 100644 --- a/src/scene/MobPopulation.tsx +++ b/src/scene/MobPopulation.tsx @@ -47,6 +47,7 @@ import { combatHasLineOfSight } from "../game/combatSpatial"; import { registerDungeonRuntimeReset } from "../game/dungeonRuntimeReset"; import { scaledDungeonEnemyLevel } from "../game/dungeonLevelScaling"; import { dungeonEnemyScaling } from "../game/dungeonBossScaling"; +import { contentCategoryForClass } from "../app/characterCatalog"; import { requireDungeonDefinition } from "../game/dungeonRegistry"; import { stepFearedMobAway } from "../game/mobMotion"; import { @@ -891,6 +892,7 @@ function ProxyMob({ const difficultyId = useGameStore((state) => state.activeDifficultyId); const bossDifficulty = useGameStore((state) => state.activeBossDifficulty); const characterLevel = useCombatStore((state) => state.level); + const characterSource = useCombatStore((state) => contentCategoryForClass(state.classId)); const manastormLevel = useManastormStore((state) => state.level); const manastormPartySize = useManastormStore((state) => state.partySize); const manastormModeId = useManastormStore((state) => state.modeId); @@ -933,7 +935,7 @@ function ProxyMob({ ? definition.combat?.level : scaledDungeonEnemyLevel(characterLevel, boss); const scaling = gameMode === "dungeon" - ? dungeonEnemyScaling(dungeon, definition, instanceId, characterLevel, difficultyId, bossDifficulty) + ? dungeonEnemyScaling(dungeon, definition, instanceId, characterLevel, difficultyId, bossDifficulty, characterSource) : null; const healthMultiplier = scaling?.healthMultiplier ?? baseHealthMultiplier * (manastormModifiers?.enemyStatMultiplier ?? 1); useCombatStore.getState().registerMob(instanceId, { @@ -965,6 +967,7 @@ function ProxyMob({ }, [ boss, characterLevel, + characterSource, definition.hasLoot, definition.id, definition.name, diff --git a/src/styles.css b/src/styles.css index 20505667..2ecbd7d2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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 strong { color: #e7d491; font: 700 14px Georgia, serif; } .spellbook-panel__progress i { width: 1px; height: 19px; margin: 0 5px; background: rgba(216,226,194,.15); } -.spellbook-panel__toolbar { display: flex; justify-content: flex-end; margin-top: 10px; } +.spellbook-panel__toolbar { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 10px; } .spellbook-panel__filter { display: flex; min-height: 28px; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid rgba(190,210,157,.17); border-radius: 999px; background: rgba(152,181,122,.05); color: var(--muted); font-size: 7px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; cursor: pointer; } .spellbook-panel__filter:hover { border-color: rgba(190,210,157,.32); background: rgba(152,181,122,.11); color: var(--ink); } .spellbook-panel__filter > span { position: relative; width: 22px; height: 12px; border: 1px solid rgba(190,210,157,.26); border-radius: 999px; background: rgba(0,0,0,.26); } @@ -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:hover { background: rgba(138,171,104,.1); } .ability-bindings__choices button.is-current { border-color: rgba(151,199,108,.34); background: rgba(101,151,72,.12); } +.spellbook-ability.is-missing-from-bars, .ability-bindings__choices button.is-missing-from-bars { border-color: #92d5ff; box-shadow: inset 0 0 0 1px rgba(146,213,255,.22), 0 0 6px rgba(146,213,255,.13); } +.is-missing-from-bars :is(.spellbook-ability__icon, .ability-bindings__icon) { border-color: #92d5ff; } +.action-bar-missing-legend, .ability-bindings__picker > p.action-bar-missing-legend { color: #92d5ff; font-size: 9px; line-height: 1.4; } .ability-bindings__choices button.is-locked { filter: grayscale(.85); opacity: .65; } .ability-bindings__choices button.is-danger { border-color: rgba(202,111,87,.32); background: rgba(138,48,34,.14); } .ability-bindings__choices button > span:last-child { min-width: 0; } diff --git a/src/ui/AbilityBindingsPanel.tsx b/src/ui/AbilityBindingsPanel.tsx index 1546bab3..3a82f83f 100644 --- a/src/ui/AbilityBindingsPanel.tsx +++ b/src/ui/AbilityBindingsPanel.tsx @@ -12,7 +12,7 @@ import { import { useCombatStore } from "../game/combatStore"; import { abilityAtLevel, defaultActionBarForCharacter, type AbilityDefinition } from "../game/abilityCatalog"; import { useGameStore } from "../game/store"; -import { actionKeyLabel, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout"; +import { actionKeyLabel, assignedActionBarAbilityIds, assignDesktopAbility, resetDesktopAbilities, type UiLayout } from "../game/uiLayout"; import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore"; import { getControllerHeldTokens, subscribeControllerToken } from "../input/controller"; import { INPUT_ACTIONS, controllerBindingMatches, inputKeyForEvent } from "../input/keybinds"; @@ -130,6 +130,10 @@ function AbilityBindingsDialog({ onClose }: { onClose?: () => void }) { const [notice, setNotice] = useState("Choose a button, then choose the ability it should use."); const classColor = classById(classId).color; const isWow = layout === "wow"; + const assignedIds = assignedActionBarAbilityIds(pcLayout, bindings, isWow); + const isMissing = (ability: AbilityDefinition) => ability.unlockLevel <= level + && (ability.talentEntryId === undefined || Boolean(talentRanks[`coa-entry-${ability.talentEntryId}`])) + && !assignedIds.has(ability.id); const assignment = (control: BindingControl) => typeof control === "number" ? pcLayout.bars[bar].slots[control].abilityId : bindings[actionBindingId(layer, control)]; const controlLabel = (control: BindingControl) => typeof control === "number" @@ -490,10 +494,11 @@ function AbilityBindingsDialog({ onClose }: { onClose?: () => void }) { <>

Assign {controlLabel(editingControl)}

Choose an ability

+

Light blue: missing from action bars

{pickerAbilities.map((ability) => ( - level ? "is-locked" : ""}`} onClick={() => assign(ability.id)}> + level ? "is-locked" : ""} ${isMissing(ability) ? "is-missing-from-bars" : ""}`} aria-label={`${ability.name}${isMissing(ability) ? ", missing from action bars" : ""}`} onClick={() => assign(ability.id)}> {ability.name}{ability.unlockLevel > level ? `Unlocks at level ${ability.unlockLevel}` : ability.id === editingAbilityId ? "Current assignment" : ability.target === "friendly" ? "Friendly / self" : ability.target} diff --git a/src/ui/SpellbookPanel.tsx b/src/ui/SpellbookPanel.tsx index 0367fb17..c687c32b 100644 --- a/src/ui/SpellbookPanel.tsx +++ b/src/ui/SpellbookPanel.tsx @@ -1,5 +1,8 @@ import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { useGameStore } from "../game/store"; +import { useCombatStore } from "../game/combatStore"; +import { assignedActionBarAbilityIds } from "../game/uiLayout"; +import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore"; import { useMenuController, type MenuAction } from "../input/useMenuController"; import { ControllerButton } from "./ControllerButton"; import { AbilityTooltipTarget } from "./AbilityTooltip"; @@ -80,6 +83,11 @@ function SpellbookDialog({ unlockedOnly, onUnlockedOnlyChange, }: SpellbookDialogProps) { + const bindings = useCombatStore((state) => state.actionBindings); + const ui = useUiLayoutStore(); + const device = currentUiDevice(); + const layout = ui.draft ?? ui.preferences.current[device]; + const assignedIds = assignedActionBarAbilityIds(layout, bindings, device === "pc" && layout.wowBars); const orderedAbilities = useMemo( () => spellbookAbilitiesForDisplay(abilities, playerLevel, false), [abilities, playerLevel], @@ -131,6 +139,7 @@ function SpellbookDialog({
+ Light blue: missing from action bars
+ {!inspectedLocked && !inspected.passive &&
Action bars
{assignedIds.has(inspected.id) ? "Assigned" : "Missing from action bars"}
} {inspected.rank !== undefined &&
Current rank
{inspected.rank} / {inspected.rankCount ?? inspected.rank}
} {inspected.resourceCost !== undefined &&
Cost
{inspected.resourceCost} {inspected.resourceLabel}
} {inspected.cooldownSeconds !== undefined &&
Cooldown
{inspected.cooldownSeconds || "None"}{inspected.cooldownSeconds ? " sec" : ""}
} diff --git a/src/ui/UiLayoutEditor.tsx b/src/ui/UiLayoutEditor.tsx index 59602c40..83e978f2 100644 --- a/src/ui/UiLayoutEditor.tsx +++ b/src/ui/UiLayoutEditor.tsx @@ -5,7 +5,7 @@ import { useCombatStore } from "../game/combatStore"; import { abilityAtLevel, defaultActionBarForCharacter } from "../game/abilityCatalog"; import { useGameStore } from "../game/store"; import { - BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout, + BAR_COLUMNS, HUD_ELEMENTS, actionKeyForEvent, bindDesktopSlot, defaultUiLayout, resetDesktopAbilities, exportUiPreferences, importUiPreferences, type HudElementId, type UiDevice, resolveHudScreen, type HudScreen, } from "../game/uiLayout"; @@ -176,7 +176,7 @@ export function UiLayoutEditor() { const combat = useCombatStore.getState(); const defaults = defaultActionBarForCharacter(combat.classId, combat.secondaryClassId, combat.level); const empty = layout.bars.every((bar) => bar.slots.every((button) => !button.abilityId)); - return { ...layout, wowBars: event.target.checked, bars: empty && event.target.checked ? layout.bars.map((bar, b) => ({ ...bar, slots: bar.slots.map((button, s) => ({ ...button, abilityId: defaults[b * 12 + s] ?? null })) })) : layout.bars }; + return { ...(empty && event.target.checked ? resetDesktopAbilities(layout, defaults) : layout), wowBars: event.target.checked }; })} />Use WoW action bars {draft.wowBars && <> diff --git a/src/ui/spellbookProgression.test.ts b/src/ui/spellbookProgression.test.ts index 4079cb51..7230b7ef 100644 --- a/src/ui/spellbookProgression.test.ts +++ b/src/ui/spellbookProgression.test.ts @@ -1,12 +1,27 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { SpellbookPanel, spellbookAbilitiesForDisplay, type SpellbookAbilityView, } from "./SpellbookPanel"; +const barState = vi.hoisted(() => ({ pcIds: [] as (string | null)[], controllerIds: [] as string[], wowBars: true })); +vi.mock("../game/uiLayoutStore", async (importOriginal) => { + const actual = await importOriginal(); + const { normalizeUiPreferences } = await import("../game/uiLayout"); + return { ...actual, currentUiDevice: () => "pc", useUiLayoutStore: () => { + const preferences = normalizeUiPreferences(null); + preferences.current.pc.wowBars = barState.wowBars; + preferences.current.pc.bars[0].slots.forEach((slot, index) => { slot.abilityId = barState.pcIds[index] ?? null; }); + return { preferences, draft: null }; + } }; +}); +vi.mock("../game/combatStore", () => ({ useCombatStore: (selector: (state: unknown) => unknown) => selector({ + actionBindings: Object.fromEntries(barState.controllerIds.map((id, index) => [`layer:${index}`, id])), +}) })); + const abilities: readonly SpellbookAbilityView[] = [ { id: "known-one", @@ -47,6 +62,29 @@ const abilities: readonly SpellbookAbilityView[] = [ ]; describe("spellbook ability progression", () => { + it("highlights only usable spells missing from the active layout and updates after assignment", () => { + barState.wowBars = true; + barState.pcIds = [null, null, null, null, "known-one"]; + barState.controllerIds = ["known-two"]; + try { + const render = () => renderToStaticMarkup(createElement(SpellbookPanel, { + abilities: [...abilities, { ...abilities[1], id: "passive", passive: true }], + playerLevel: 1, onActivate: () => undefined, open: true, onClose: () => undefined, + })); + let markup = render(); + expect(markup).toContain("Known Two, unlocked, missing from action bars"); + expect(markup).not.toContain("Known One, unlocked, missing from action bars"); + expect(markup.match(/is-missing-from-bars/g)).toHaveLength(1); + barState.pcIds[11] = "known-two"; + markup = render(); + expect(markup).not.toContain("is-missing-from-bars"); + barState.wowBars = false; + markup = render(); + expect(markup).toContain("Known One, unlocked, missing from action bars"); + expect(markup).not.toContain("Known Two, unlocked, missing from action bars"); + expect(markup.match(/is-missing-from-bars/g)).toHaveLength(1); + } finally { barState.pcIds = []; barState.controllerIds = []; barState.wowBars = true; } + }); it("renders future abilities as inspectable locked buttons with level requirements", () => { const markup = renderToStaticMarkup(createElement(SpellbookPanel, { abilities,