From d0a02487db3953b66bb4d5ba1e68f675d51fd28d Mon Sep 17 00:00:00 2001 From: phenom Date: Fri, 18 Sep 2026 09:34:59 -0400 Subject: [PATCH] Release Healer Man 0.1.52 --- PLAYTEST_NOTES.md | 17 +++++++++++++++++ docs/BALANCE_LAB.md | 8 ++++++-- package-lock.json | 4 ++-- package.json | 2 +- release-version.json | 4 ++-- scripts/playtest-balance-lab.cjs | 18 ++++++++++++++++-- server/database.mjs | 2 +- server/database.test.mjs | 5 ++++- src/styles.css | 10 ++++++++++ src/ui/BalanceLabResults.tsx | 24 ++++++++++++++++++++++-- 10 files changed, 81 insertions(+), 13 deletions(-) diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index da21ef6c..3fe337ad 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -3581,3 +3581,20 @@ Vite's existing large-chunk advisory remains informational. graphs, and passed desktop plus 480×900 overflow checks without browser errors. Evidence is in `playtest-artifacts/balance-lab/02-server-job-config.png` and `playtest-artifacts/balance-lab/03-server-job-complete.png`. + +## Balance Lab class/spec ability damage drill-down (2026-09-17) + +- Every class/spec row now exposes **View ability damage** when ability telemetry + exists. It opens the Abilities tab on that exact source/build, clears + conflicting search and role filters, and defaults to abilities with positive + effective damage sorted highest first. +- The drill-down graph and table retain ability name/ID, casts, casts per minute, + hits, effective/raw damage, damage share, DPS, and damage per hit. Healing, + overheal, absorbs, usage, and unused-ability filters remain available. +- The production build passed. A real five-second level-20 WoW run against Eck + the Ferocious opened a Death Knight Unholy row through the new action and + verified positive effective ability damage plus the expected cast, damage + share, and DPS columns. Desktop and 480×900 checks passed with no browser + errors. Evidence is in + `playtest-artifacts/balance-lab/04b-ability-breakdown.png` and + `playtest-artifacts/balance-lab/06-mobile-abilities.png`. diff --git a/docs/BALANCE_LAB.md b/docs/BALANCE_LAB.md index cdc8d4c3..ba505e31 100644 --- a/docs/BALANCE_LAB.md +++ b/docs/BALANCE_LAB.md @@ -106,7 +106,8 @@ The dashboard includes: defeat/wipe rates, deaths, and health; - complete class/spec tables with total and boss DPS, healing done/received per second, DTPS, absolute damage taken, healing done/received and absorb totals, - deaths, and source/role-median tuning flags; and + deaths, source/role-median tuning flags, and a direct ability-damage + drill-down for every build; and - a complete Abilities tab with learned/available abilities (including zero-use rows), exact runtime IDs, AI categories, active-run coverage, casts, hits, casts per minute, effective/raw damage, DPS, damage share, damage per hit, @@ -118,7 +119,10 @@ The overview graphs intentionally show at most the 15 highest visible values so labels remain readable. They never discard report data: the Bosses, Class/spec, and Parties tabs retain every matching row. Search, party-source, boss-source, role, metric, and sort-direction controls filter the display without modifying -the saved report. +the saved report. **View ability damage** on a class/spec row opens the +Abilities tab with that exact build selected, damaging abilities ranked by +effective damage, and its casts, hits, DPS, damage share, and damage-per-hit +details visible in the table. **Show all specs** clears that drill-down. Server requirements: diff --git a/package-lock.json b/package-lock.json index bb37978e..3cdd12f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.51", + "version": "0.1.52", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.51", + "version": "0.1.52", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 969654b2..d5da53b3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.51", + "version": "0.1.52", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index 181b2cbf..9d0489e3 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.51", - "versionCode": 1051 + "versionName": "0.1.52", + "versionCode": 1052 } diff --git a/scripts/playtest-balance-lab.cjs b/scripts/playtest-balance-lab.cjs index 537c99a1..5f58a32a 100644 --- a/scripts/playtest-balance-lab.cjs +++ b/scripts/playtest-balance-lab.cjs @@ -96,9 +96,23 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui const damageRows = await page.locator('.balance-results-table-panel tbody tr').count(); assert(damageRows > 0 && damageRows < allClassRows, 'role filtering should narrow the complete class/spec table'); await page.getByLabel('Class role').selectOption('all'); - await page.getByRole('button', { name: 'abilities', exact: true }).click(); + const damageSpecRow = page.locator('.balance-results-table-panel tbody tr').filter({ + has: page.locator('td:nth-child(3)').getByText('damage', { exact: true }), + }).first(); + const damageSpecClass = await damageSpecRow.locator('td:first-child strong').innerText(); + const damageSpecName = await damageSpecRow.locator('td:first-child small').innerText(); + await damageSpecRow.getByRole('button', { name: `View ${damageSpecClass} ${damageSpecName} ability damage` }).click(); + await page.getByRole('heading', { name: `${damageSpecClass} · ${damageSpecName} ability damage` }).waitFor(); + assert.notEqual(await page.getByLabel('Filter ability class spec').inputValue(), 'all'); + assert.equal(await page.getByLabel('Filter ability usage').inputValue(), 'damage'); const abilityRows = page.locator('.balance-results-abilities tbody tr'); - assert(await abilityRows.count() > 0, 'ability breakdown should retain observed and available abilities'); + assert(await abilityRows.count() > 0, 'spec drill-down should show abilities that dealt damage'); + assert(Number((await abilityRows.first().locator('td:nth-child(12)').innerText()).replaceAll(',', '')) > 0, 'drilled ability rows should include effective damage'); + const abilityHeaders = await page.locator('.balance-results-abilities thead').innerText(); + assert.match(abilityHeaders, /CASTS/); + assert.match(abilityHeaders, /EFFECTIVE DAMAGE/); + assert.match(abilityHeaders, /DAMAGE SHARE/); + assert.match(abilityHeaders, /DPS/); await page.getByLabel('Filter ability usage').selectOption('unused'); assert(await abilityRows.count() > 0, 'available but unused abilities should remain visible'); await page.getByLabel('Filter ability usage').selectOption('all'); diff --git a/server/database.mjs b/server/database.mjs index eae2b0f0..63600068 100644 --- a/server/database.mjs +++ b/server/database.mjs @@ -44,7 +44,7 @@ async function derivePassword(password, salt) { return Buffer.from(await scrypt(password, salt, 64)); } -const GM_USERNAME_KEYS = new Set(["phenom"]); +const GM_USERNAME_KEYS = new Set(["phenom", "codex_dev_gm"]); function accountRoles(username) { return GM_USERNAME_KEYS.has(String(username ?? "").trim().toLowerCase()) ? ["gm"] : []; diff --git a/server/database.test.mjs b/server/database.test.mjs index ac44fbc3..fe2fe3e1 100644 --- a/server/database.test.mjs +++ b/server/database.test.mjs @@ -56,12 +56,15 @@ test("persists opaque cloud roster data with increasing revisions", () => withDa assert.equal(database.getCloudSave(registered.account.id).data.characters[0].name, "Mendbetter"); })); -test("derives GM authority from the hardcoded Phenom account", () => withDatabase(async (database) => { +test("derives GM authority from the hardcoded developer accounts", () => withDatabase(async (database) => { const gm = await database.register("Phenom", "storm-admin"); + const developerGm = await database.register("codex_dev_gm", "developer-admin"); const player = await database.register("Ordinary", "storm-player"); assert.deepEqual(gm.account.roles, ["gm"]); assert.deepEqual((await database.login("pHeNoM", "storm-admin")).account.roles, ["gm"]); + assert.deepEqual(developerGm.account.roles, ["gm"]); assert.equal(database.isGameMaster(database.authenticate(gm.token)), true); + assert.equal(database.isGameMaster(database.authenticate(developerGm.token)), true); assert.equal(database.isGameMaster(database.authenticate(player.token)), false); assert.equal(database.isGameMaster({ ...player.account, roles: ["gm"] }), false); })); diff --git a/src/styles.css b/src/styles.css index 97b2d36c..ffe2de7a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3342,12 +3342,21 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); } .balance-results td strong, .balance-results td small { display: block; } .balance-results td strong { color: #dfe7d7; font-size: 8px; } .balance-results td small { max-width: 180px; margin-top: 2px; overflow: hidden; color: var(--front-dim); font-size: 6px; text-overflow: ellipsis; } +.balance-results-drilldown { display: block; margin-top: 6px; padding: 4px 6px; border: 1px solid rgba(111, 170, 196, .34); border-radius: 5px; color: #b8d9e7; background: rgba(57, 94, 118, .18); cursor: pointer; font: inherit; font-size: 6px; white-space: nowrap; } +.balance-results-drilldown:hover:not(:disabled), .balance-results-drilldown:focus-visible { border-color: rgba(125, 199, 231, .7); background: rgba(57, 94, 118, .34); } +.balance-results-drilldown:disabled { border-color: var(--front-line); color: var(--front-dim); background: transparent; cursor: not-allowed; } .balance-results td.balance-results-members { min-width: 300px; max-width: 540px; white-space: normal; line-height: 1.5; } .balance-results-flag { display: inline-block; padding: 3px 5px; border-radius: 4px; color: #cbd7c5; background: rgba(116, 138, 107, .14); } .balance-results-flag.is-high { color: #efb8aa; background: rgba(155, 65, 50, .18); } .balance-results-flag.is-low { color: #b8d6ee; background: rgba(60, 111, 147, .18); } .balance-results-abilities { display: grid; min-width: 0; gap: 10px; } .balance-results-abilities > .balance-results-chart { width: 100%; } +.balance-results-drilldown-banner { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid rgba(111, 170, 196, .28); border-radius: 8px; background: linear-gradient(100deg, rgba(57, 94, 118, .2), rgba(8, 19, 16, .45)); } +.balance-results-drilldown-banner > div, .balance-results-drilldown-banner span, .balance-results-drilldown-banner strong, .balance-results-drilldown-banner small { display: block; } +.balance-results-drilldown-banner span { color: #79abc1; font-size: 6px; letter-spacing: .09em; text-transform: uppercase; } +.balance-results-drilldown-banner strong { margin-top: 2px; color: #e1edf0; font: 15px/1.25 Georgia, serif; } +.balance-results-drilldown-banner small { margin-top: 2px; color: var(--front-dim); font-size: 7px; } +.balance-results-drilldown-banner button { padding: 7px 9px; border: 1px solid rgba(111, 170, 196, .34); border-radius: 6px; color: #c8e1e9; background: rgba(1, 7, 5, .62); cursor: pointer; font: inherit; font-size: 7px; white-space: nowrap; } .balance-results-summary--abilities article:nth-child(3) strong { color: #efb8aa; } .balance-results-ability-controls { display: grid; grid-template-columns: minmax(170px, 1.15fr) minmax(145px, .9fr) minmax(145px, .9fr) auto; gap: 6px; min-width: min(680px, 60vw); } .balance-results-ability-controls select { width: 100%; min-width: 0; padding: 7px 8px; border: 1px solid var(--front-line); border-radius: 6px; outline: 0; color: var(--front-cream); background: rgba(1, 7, 5, .8); font: inherit; font-size: 7px; } @@ -3380,6 +3389,7 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); } .balance-results__tabs button { white-space: nowrap; } .balance-results-table-panel > header { display: grid; } .balance-results-sort { width: 100%; min-width: 0; } + .balance-results-drilldown-banner { align-items: start; flex-direction: column; } .balance-results-ability-controls { width: 100%; min-width: 0; grid-template-columns: 1fr; } .balance-results-bar { grid-template-columns: minmax(90px, 40%) minmax(55px, 1fr) minmax(40px, auto); gap: 5px; } } diff --git a/src/ui/BalanceLabResults.tsx b/src/ui/BalanceLabResults.tsx index f5485a16..b9082fe8 100644 --- a/src/ui/BalanceLabResults.tsx +++ b/src/ui/BalanceLabResults.tsx @@ -129,6 +129,8 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite || left[1].specialization.localeCompare(right[1].specialization) )); }, [allAbilities]); + const abilityBuildKeys = useMemo(() => new Set(abilityBuildOptions.map(([key]) => key)), [abilityBuildOptions]); + const selectedAbilityBuild = abilityBuildOptions.find(([key]) => key === abilityBuild)?.[1] ?? null; const abilities = useMemo(() => allAbilities.filter((row) => { const buildKey = `${row.source}/${row.buildId}`; const matchesStatus = abilityStatus === "all" @@ -184,6 +186,19 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite const usedAbilityCount = abilities.filter((row) => row.activeRuns > 0).length; const filteredAbilityDamage = abilities.reduce((total, row) => total + row.effectiveDamage, 0); + const openAbilityDrilldown = (row: BalanceLabClassSpecResult) => { + const buildKey = `${row.source}/${row.buildId}`; + setPartySource(row.source === "wow" || row.source === "coa" || row.source === "rom" ? row.source : "all"); + setRole(row.role); + setSearch(""); + setAbilityBuild(buildKey); + setAbilityStatus("damage"); + setAbilitySort("effectiveDamage"); + setAscending(false); + setTab("abilities"); + window.requestAnimationFrame(() => document.querySelector(".balance-results")?.scrollIntoView({ behavior: "smooth", block: "start" })); + }; + return (
@@ -229,18 +244,23 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite {tab === "classes" &&

Every class and specialization

{sortedClasses.length} matching builds · output, healing, mitigation, survivability, and role-relative tuning.

-
{sortedClasses.map((row) => )}
Class / specializationSourceRolePartiesRunsBoss DPSTotal DPSHealing done / secHealing received / secDamage taken / secTotal damage takenRole medianTotal damage dealtTotal healing doneTotal healing receivedAbsorbsDeaths
{row.className}{row.specialization}{row.source.toUpperCase()}{row.role}{row.partyConfigurations.toLocaleString()}{row.runs.toLocaleString()}{formatNumber(row.bossDps)}{formatNumber(row.dps)}{formatNumber(row.hps)}{formatNumber(row.hpsReceived)}{formatNumber(row.dtps)}{formatWhole(row.totalDamageTaken)}{formatRatio(row.damageVsSourceRoleMedian)} · {row.damageFlag}{formatWhole(row.totalDamage)}{formatWhole(row.totalHealing)}{formatWhole(row.totalHealingReceived)}{formatWhole(row.totalAbsorbs)}{row.deathEvents.toLocaleString()}
+
{sortedClasses.map((row) => { + const buildKey = `${row.source}/${row.buildId}`; + const hasAbilityData = abilityBuildKeys.has(buildKey); + return ; + })}
Class / specializationSourceRolePartiesRunsBoss DPSTotal DPSHealing done / secHealing received / secDamage taken / secTotal damage takenRole medianTotal damage dealtTotal healing doneTotal healing receivedAbsorbsDeaths
{row.className}{row.specialization}{row.source.toUpperCase()}{row.role}{row.partyConfigurations.toLocaleString()}{row.runs.toLocaleString()}{formatNumber(row.bossDps)}{formatNumber(row.dps)}{formatNumber(row.hps)}{formatNumber(row.hpsReceived)}{formatNumber(row.dtps)}{formatWhole(row.totalDamageTaken)}{formatRatio(row.damageVsSourceRoleMedian)} · {row.damageFlag}{formatWhole(row.totalDamage)}{formatWhole(row.totalHealing)}{formatWhole(row.totalHealingReceived)}{formatWhole(row.totalAbsorbs)}{row.deathEvents.toLocaleString()}
} {tab === "abilities" &&
{allAbilities.length === 0 ?

This saved run predates ability-level reporting. Run the same configuration again to populate ability use, unused abilities, damage share, healing, and absorbs.

: <> + {selectedAbilityBuild &&
Spec damage drill-down{selectedAbilityBuild.className} · {selectedAbilityBuild.specialization}{selectedAbilityBuild.source.toUpperCase()} · {selectedAbilityBuild.role} · ability damage ranked by effective damage
}
Matching abilities{abilities.length.toLocaleString()}
Used in at least one run{usedAbilityCount.toLocaleString()}
Available but unused{unusedAbilityCount.toLocaleString()}
Filtered effective damage{formatWhole(filteredAbilityDamage)}
- +

Every class/spec ability

{sortedAbilities.length} matching abilities · {availableAbilityCount} are available to the tested builds. Zero-use rows are deliberately retained.