Release Healer Man 0.1.52
This commit is contained in:
1 parent
dd583023ac
commit
d0a02487db
10 files changed
+81
-13
No files matched your search
@@ -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`.
|
||||
+6
-2
@@ -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:
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"versionName": "0.1.51",
|
||||
"versionCode": 1051
|
||||
"versionName": "0.1.52",
|
||||
"versionCode": 1052
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
+1
-1
@@ -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"] : [];
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<section className="balance-results" aria-label="Balance Lab report analysis">
|
||||
<header className="balance-results__header">
|
||||
@@ -229,18 +244,23 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite
|
||||
|
||||
{tab === "classes" && <div className="balance-results-table-panel">
|
||||
<header><div><h3>Every class and specialization</h3><p>{sortedClasses.length} matching builds · output, healing, mitigation, survivability, and role-relative tuning.</p></div><div className="balance-results-sort"><select aria-label="Sort class specs" value={classSort} onChange={(event) => setClassSort(event.currentTarget.value as keyof BalanceLabClassSpecResult)}><option value="bossDps">Boss DPS</option><option value="dps">Total DPS</option><option value="hps">Healing done / sec</option><option value="hpsReceived">Healing received / sec</option><option value="dtps">Damage taken / sec</option><option value="totalDamageTaken">Total damage taken</option><option value="totalDamage">Total damage dealt</option><option value="totalHealing">Total healing done</option><option value="totalHealingReceived">Total healing received</option><option value="totalAbsorbs">Total absorbs</option><option value="deathEvents">Deaths</option></select><button type="button" onClick={() => setAscending((value) => !value)}>{ascending ? "Low → high" : "High → low"}</button></div></header>
|
||||
<div className="balance-results-table-wrap"><table><thead><tr><th>Class / specialization</th><th>Source</th><th>Role</th><th>Parties</th><th>Runs</th><th>Boss DPS</th><th>Total DPS</th><th>Healing done / sec</th><th>Healing received / sec</th><th>Damage taken / sec</th><th>Total damage taken</th><th>Role median</th><th>Total damage dealt</th><th>Total healing done</th><th>Total healing received</th><th>Absorbs</th><th>Deaths</th></tr></thead><tbody>{sortedClasses.map((row) => <tr key={`${row.source}/${row.buildId}`}><td><strong>{row.className}</strong><small>{row.specialization}</small></td><td>{row.source.toUpperCase()}</td><td>{row.role}</td><td>{row.partyConfigurations.toLocaleString()}</td><td>{row.runs.toLocaleString()}</td><td>{formatNumber(row.bossDps)}</td><td>{formatNumber(row.dps)}</td><td>{formatNumber(row.hps)}</td><td>{formatNumber(row.hpsReceived)}</td><td>{formatNumber(row.dtps)}</td><td>{formatWhole(row.totalDamageTaken)}</td><td><span className={`balance-results-flag ${flagClass(row.damageFlag)}`}>{formatRatio(row.damageVsSourceRoleMedian)} · {row.damageFlag}</span></td><td>{formatWhole(row.totalDamage)}</td><td>{formatWhole(row.totalHealing)}</td><td>{formatWhole(row.totalHealingReceived)}</td><td>{formatWhole(row.totalAbsorbs)}</td><td>{row.deathEvents.toLocaleString()}</td></tr>)}</tbody></table></div>
|
||||
<div className="balance-results-table-wrap"><table><thead><tr><th>Class / specialization</th><th>Source</th><th>Role</th><th>Parties</th><th>Runs</th><th>Boss DPS</th><th>Total DPS</th><th>Healing done / sec</th><th>Healing received / sec</th><th>Damage taken / sec</th><th>Total damage taken</th><th>Role median</th><th>Total damage dealt</th><th>Total healing done</th><th>Total healing received</th><th>Absorbs</th><th>Deaths</th></tr></thead><tbody>{sortedClasses.map((row) => {
|
||||
const buildKey = `${row.source}/${row.buildId}`;
|
||||
const hasAbilityData = abilityBuildKeys.has(buildKey);
|
||||
return <tr key={buildKey}><td><strong>{row.className}</strong><small>{row.specialization}</small><button className="balance-results-drilldown" type="button" disabled={!hasAbilityData} onClick={() => openAbilityDrilldown(row)} aria-label={`View ${row.className} ${row.specialization} ability damage`}>{hasAbilityData ? "View ability damage" : "No ability data"}</button></td><td>{row.source.toUpperCase()}</td><td>{row.role}</td><td>{row.partyConfigurations.toLocaleString()}</td><td>{row.runs.toLocaleString()}</td><td>{formatNumber(row.bossDps)}</td><td>{formatNumber(row.dps)}</td><td>{formatNumber(row.hps)}</td><td>{formatNumber(row.hpsReceived)}</td><td>{formatNumber(row.dtps)}</td><td>{formatWhole(row.totalDamageTaken)}</td><td><span className={`balance-results-flag ${flagClass(row.damageFlag)}`}>{formatRatio(row.damageVsSourceRoleMedian)} · {row.damageFlag}</span></td><td>{formatWhole(row.totalDamage)}</td><td>{formatWhole(row.totalHealing)}</td><td>{formatWhole(row.totalHealingReceived)}</td><td>{formatWhole(row.totalAbsorbs)}</td><td>{row.deathEvents.toLocaleString()}</td></tr>;
|
||||
})}</tbody></table></div>
|
||||
</div>}
|
||||
|
||||
{tab === "abilities" && <div className="balance-results-abilities">
|
||||
{allAbilities.length === 0 ? <p className="balance-results-legacy-note">This saved run predates ability-level reporting. Run the same configuration again to populate ability use, unused abilities, damage share, healing, and absorbs.</p> : <>
|
||||
{selectedAbilityBuild && <div className="balance-results-drilldown-banner"><div><span>Spec damage drill-down</span><strong>{selectedAbilityBuild.className} · {selectedAbilityBuild.specialization}</strong><small>{selectedAbilityBuild.source.toUpperCase()} · {selectedAbilityBuild.role} · ability damage ranked by effective damage</small></div><button type="button" onClick={() => { setAbilityBuild("all"); setAbilityStatus("all"); setPartySource("all"); setRole("all"); }}>Show all specs</button></div>}
|
||||
<div className="balance-results-summary balance-results-summary--abilities">
|
||||
<article><span>Matching abilities</span><strong>{abilities.length.toLocaleString()}</strong></article>
|
||||
<article><span>Used in at least one run</span><strong>{usedAbilityCount.toLocaleString()}</strong></article>
|
||||
<article><span>Available but unused</span><strong>{unusedAbilityCount.toLocaleString()}</strong></article>
|
||||
<article><span>Filtered effective damage</span><strong>{formatWhole(filteredAbilityDamage)}</strong></article>
|
||||
</div>
|
||||
<BarChart title="Ability damage contribution" description="Effective damage by ability for the selected source, role, class/spec, status, and text filters." rows={abilityChart("effectiveDamage")} tone="class" />
|
||||
<BarChart title={selectedAbilityBuild ? `${selectedAbilityBuild.className} · ${selectedAbilityBuild.specialization} ability damage` : "Ability damage contribution"} description="Effective damage by ability for the selected source, role, class/spec, status, and text filters. Casts, hits, DPS, damage share, and damage per hit are listed below." rows={abilityChart("effectiveDamage")} tone="class" />
|
||||
<div className="balance-results-table-panel">
|
||||
<header><div><h3>Every class/spec ability</h3><p>{sortedAbilities.length} matching abilities · {availableAbilityCount} are available to the tested builds. Zero-use rows are deliberately retained.</p></div><div className="balance-results-ability-controls">
|
||||
<select aria-label="Filter ability class spec" value={abilityBuild} onChange={(event) => setAbilityBuild(event.currentTarget.value)}><option value="all">All class/specs</option>{abilityBuildOptions.map(([key, row]) => <option key={key} value={key}>{row.source.toUpperCase()} · {row.className} — {row.specialization}</option>)}</select>
|
||||
|
||||
Reference in new issue
Block a user