diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index 1fbb6be1..6ffe30e0 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -3545,3 +3545,19 @@ Vite's existing large-chunk advisory remains informational. errors; all five bodies and their equipped weapon/staff silhouettes rendered intact. Evidence is in `playtest-artifacts/dungeon-health-scaling/stockades-level-three.png`. + +## Balance Lab healing done and received analytics (2026-09-17) + +- Raw Balance Lab member metrics now attribute effective healing to both the + caster and the party target. Party and suite reports expose healing done and + received per active second plus absolute totals; self-heals count on both + sides of the attribution. +- The GM overview adds separate **Healing done by class/spec** and **Healing + received by class/spec** graphs. The class/spec table can display and sort + both rates and totals. Older saved reports remain readable but show no target + healing until they are rerun. +- All six Python controller tests and the production build passed. A live + one-seed, 15-second Eck the Ferocious run recorded the Holy Paladin's 867 + effective healing as 57.8 HPS done and the Blood Death Knight's same 867 as + 57.8 HPS received. A matching automatic-suite run verified the new JSON and + CSV fields under `playtest-artifacts/balance-lab-healing-suite/`. diff --git a/docs/BALANCE_LAB.md b/docs/BALANCE_LAB.md index 3de69659..bba03369 100644 --- a/docs/BALANCE_LAB.md +++ b/docs/BALANCE_LAB.md @@ -80,11 +80,13 @@ The dashboard includes: - party damage applied directly to each boss; - average successful kill time; - boss DPS and damage taken for every class/specialization; +- effective healing done and effective healing received per second for every + class/specialization; - complete boss tables with pressure versus median, total incoming damage, defeat/wipe rates, deaths, and health; -- complete class/spec tables with total and boss DPS, HPS, DTPS, absolute - damage taken, damage/healing/absorb totals, deaths, and source/role-median - tuning flags; and +- 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 - 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, @@ -250,6 +252,12 @@ under `parties//` and adds these suite-level files: direct boss damage. Suite class/spec `dtps` is damage taken per active second; `totalDamageTaken` is the absolute healer/survivability burden across every included run. +- Member `hps` is effective healing done per active second and excludes + overhealing. `hpsReceived` is effective healing received per active second, + attributed from each healing event's target (including self-heals). + `totalHealing` and `totalHealingReceived` are the corresponding absolute + values across the included runs. Reports created before target attribution + was added remain readable but require a rerun to populate healing received. - Ability `activeRuns` counts simulations with at least one cast, hit, damage, healing, overheal, or absorb event. `usageRate` divides that by every run for that build; `damageShare` divides the ability's effective damage by the diff --git a/package-lock.json b/package-lock.json index cb859927..5e893a3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.49", + "version": "0.1.50", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.49", + "version": "0.1.50", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index dc9a5398..557aec0d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.49", + "version": "0.1.50", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/release-version.json b/release-version.json index 4cca4a45..3de517b7 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.49", - "versionCode": 1049 + "versionName": "0.1.50", + "versionCode": 1050 } diff --git a/scripts/balance-lab/runtime.ts b/scripts/balance-lab/runtime.ts index 488691d5..756aa53d 100644 --- a/scripts/balance-lab/runtime.ts +++ b/scripts/balance-lab/runtime.ts @@ -90,6 +90,7 @@ interface MutableMemberMetrics { healing: number; rawHealing: number; overheal: number; + healingReceived: number; absorbs: number; damageTaken: number; rawDamageTaken: number; @@ -286,6 +287,8 @@ function serializedMember( rawHealing: metrics.rawHealing, overheal: metrics.overheal, hps: metrics.healing / Math.max(0.1, activeSeconds), + healingReceived: metrics.healingReceived, + hpsReceived: metrics.healingReceived / Math.max(0.1, activeSeconds), absorbs: metrics.absorbs, damageTaken: metrics.damageTaken, rawDamageTaken: metrics.rawDamageTaken, @@ -356,6 +359,7 @@ async function runBoss( healing: 0, rawHealing: 0, overheal: 0, + healingReceived: 0, absorbs: 0, damageTaken: 0, rawDamageTaken: 0, @@ -395,15 +399,18 @@ async function runBoss( ability.hits += 1; bossAbilities.set(key, ability); } - } else if (event.kind === "healing" && source) { - source.rawHealing += event.rawAmount; - source.healing += event.effectiveAmount; - source.overheal += event.overheal; - const ability = abilityMetrics(source, event.abilityId); - ability.rawHealing += event.rawAmount; - ability.effectiveHealing += event.effectiveAmount; - ability.overheal += event.overheal; - ability.hits += 1; + } else if (event.kind === "healing") { + if (source) { + source.rawHealing += event.rawAmount; + source.healing += event.effectiveAmount; + source.overheal += event.overheal; + const ability = abilityMetrics(source, event.abilityId); + ability.rawHealing += event.rawAmount; + ability.effectiveHealing += event.effectiveAmount; + ability.overheal += event.overheal; + ability.hits += 1; + } + if (target) target.healingReceived += event.effectiveAmount; } else if (event.kind === "absorb" && source) { source.absorbs += event.effectiveAmount; abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount; diff --git a/scripts/balance_lab.py b/scripts/balance_lab.py index 63a43375..c7684429 100644 --- a/scripts/balance_lab.py +++ b/scripts/balance_lab.py @@ -320,7 +320,7 @@ def interactive_arguments( def weighted_rate(rows: Iterable[dict[str, Any]], amount: str) -> float: materialized = list(rows) seconds = sum(float(row["elapsedSeconds"]) for row in materialized) - return sum(float(row[amount]) for row in materialized) / max(0.1, seconds) + return sum(float(row.get(amount, 0)) for row in materialized) / max(0.1, seconds) def mean(values: Iterable[float]) -> float: @@ -402,9 +402,11 @@ def aggregate(raw: dict[str, Any]) -> dict[str, Any]: "dps": weighted_rate(rows, "damage"), "bossDps": weighted_rate(rows, "bossDamage"), "hps": weighted_rate(rows, "healing"), + "hpsReceived": weighted_rate(rows, "healingReceived"), "dtps": weighted_rate(rows, "damageTaken"), "totalDamage": sum(row["damage"] for row in rows), "totalHealing": sum(row["healing"] for row in rows), + "totalHealingReceived": sum(row.get("healingReceived", 0) for row in rows), "totalAbsorbs": sum(row["absorbs"] for row in rows), "deathEvents": sum(row["deathEvents"] for row in rows), "minimumHealthPercent": min(row["minimumHealthPercent"] for row in rows), @@ -435,6 +437,7 @@ def aggregate(raw: dict[str, Any]) -> dict[str, Any]: "dps": weighted_rate(rows, "damage"), "bossDps": weighted_rate(rows, "bossDamage"), "hps": weighted_rate(rows, "healing"), + "hpsReceived": weighted_rate(rows, "healingReceived"), "dtps": weighted_rate(rows, "damageTaken"), "deathEvents": sum(row["deathEvents"] for row in rows), }) @@ -520,15 +523,16 @@ def markdown_report(report: dict[str, Any]) -> str: "", "## Every party member", "", - "| Slot | Member | Role | DPS | Boss DPS | HPS | DTPS | Role median | Flag | Death events |", - "| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | ---: |", + "| Slot | Member | Role | DPS | Boss DPS | HPS done | HPS received | DTPS | Role median | Flag | Death events |", + "| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: |", ] for member in report["members"]: marker = " (active)" if member["activePlayer"] else "" lines.append( f"| {member['slot']} | {member['className']} — {member['specialization']}{marker} | " f"{member['role']} | {format_number(member['dps'])} | {format_number(member['bossDps'])} | " - f"{format_number(member['hps'])} | {format_number(member['dtps'])} | " + f"{format_number(member['hps'])} | {format_number(member['hpsReceived'])} | " + f"{format_number(member['dtps'])} | " f"{format_ratio(member['damageVsRoleMedian'])} | {member['damageFlag']} | {member['deathEvents']} |" ) lines.extend([ @@ -592,12 +596,14 @@ def html_report(report: dict[str, Any]) -> str: "dps": format_number(row["dps"]), "bossDps": format_number(row["bossDps"]), "hps": format_number(row["hps"]), + "hpsReceived": format_number(row["hpsReceived"]), "dtps": format_number(row["dtps"]), "damageVsRoleMedian": format_number(row["damageVsRoleMedian"], 2), } for row in report["members"]] member_table = html_table("members", "Every party member", members, ( ("slot", "Slot"), ("member", "Member"), ("role", "Role"), ("dps", "DPS"), - ("bossDps", "Boss DPS"), ("hps", "HPS"), ("dtps", "DTPS"), + ("bossDps", "Boss DPS"), ("hps", "HPS done"), ("hpsReceived", "HPS received"), + ("dtps", "DTPS"), ("damageVsRoleMedian", "Role median ×"), ("damageFlag", "Flag"), ("deathEvents", "Death events"), )) boss_table = html_table("bosses", "Every boss — highest pressure first", bosses, ( @@ -639,12 +645,12 @@ def create_reports(raw_path: Path, output_dir: Path) -> dict[str, Any]: )) write_csv(output_dir / "party_members.csv", report["members"], ( "slot", "activePlayer", "classId", "className", "specialization", "role", "dps", "bossDps", - "hps", "dtps", "damageVsRoleMedian", "damageFlag", "totalDamage", "totalHealing", - "totalAbsorbs", "deathEvents", "minimumHealthPercent", + "hps", "hpsReceived", "dtps", "damageVsRoleMedian", "damageFlag", "totalDamage", "totalHealing", + "totalHealingReceived", "totalAbsorbs", "deathEvents", "minimumHealthPercent", )) write_csv(output_dir / "member_by_boss.csv", report["memberByBoss"], ( "bossKey", "bossName", "dungeonTitle", "slot", "className", "specialization", "role", - "dps", "bossDps", "hps", "dtps", "deathEvents", + "dps", "bossDps", "hps", "hpsReceived", "dtps", "deathEvents", )) run_rows = [{ "policyId": raw["policyId"], @@ -688,10 +694,12 @@ def aggregate_class_specs( "dps": weighted_rate(observations, "damage"), "bossDps": weighted_rate(observations, "bossDamage"), "hps": weighted_rate(observations, "healing"), + "hpsReceived": weighted_rate(observations, "healingReceived"), "dtps": weighted_rate(observations, "damageTaken"), "totalDamage": sum(row["damage"] for row in observations), "totalDamageTaken": sum(row["damageTaken"] for row in observations), "totalHealing": sum(row["healing"] for row in observations), + "totalHealingReceived": sum(row.get("healingReceived", 0) for row in observations), "totalAbsorbs": sum(row["absorbs"] for row in observations), "deathEvents": sum(row["deathEvents"] for row in observations), }) @@ -805,14 +813,15 @@ def suite_markdown(report: dict[str, Any]) -> str: "", "## Every tested class/spec", "", - "| Source | Class/spec | Role | Parties | Runs | DPS | Boss DPS | HPS | DTPS | Total taken | Role median | Flag | Deaths |", - "| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: |", + "| Source | Class/spec | Role | Parties | Runs | DPS | Boss DPS | HPS done | HPS received | DTPS | Total taken | Role median | Flag | Deaths |", + "| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | ---: |", ] for row in report["classSpecs"]: lines.append( f"| {row['source']} | {row['className']} — {row['specialization']} | {row['role']} | " f"{row['partyConfigurations']} | {row['runs']} | {format_number(row['dps'])} | " - f"{format_number(row['bossDps'])} | {format_number(row['hps'])} | {format_number(row['dtps'])} | " + f"{format_number(row['bossDps'])} | {format_number(row['hps'])} | " + f"{format_number(row['hpsReceived'])} | {format_number(row['dtps'])} | " f"{format_number(row['totalDamageTaken'])} | " f"{format_ratio(row['damageVsSourceRoleMedian'])} | {row['damageFlag']} | {row['deathEvents']} |" ) @@ -873,6 +882,7 @@ def suite_html(report: dict[str, Any]) -> str: "dps": format_number(row["dps"]), "bossDps": format_number(row["bossDps"]), "hps": format_number(row["hps"]), + "hpsReceived": format_number(row["hpsReceived"]), "dtps": format_number(row["dtps"]), "totalDamageTaken": format_number(row["totalDamageTaken"]), "damageVsSourceRoleMedian": format_ratio(row["damageVsSourceRoleMedian"]), @@ -911,7 +921,8 @@ def suite_html(report: dict[str, Any]) -> str: class_table = html_table("class-specs", "Every tested class/spec", class_specs, ( ("source", "Source"), ("classSpec", "Class/spec"), ("role", "Role"), ("partyConfigurations", "Parties"), ("runs", "Runs"), ("dps", "DPS"), - ("bossDps", "Boss DPS"), ("hps", "HPS"), ("dtps", "DTPS"), + ("bossDps", "Boss DPS"), ("hps", "HPS done"), ("hpsReceived", "HPS received"), + ("dtps", "DTPS"), ("totalDamageTaken", "Total taken"), ("damageVsSourceRoleMedian", "Role median"), ("damageFlag", "Flag"), ("deathEvents", "Deaths"), )) @@ -1064,6 +1075,7 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le "dps": weighted_rate(observations, "damage"), "bossDps": weighted_rate(observations, "bossDamage"), "hps": weighted_rate(observations, "healing"), + "hpsReceived": weighted_rate(observations, "healingReceived"), "dtps": weighted_rate(observations, "damageTaken"), "totalDamageTaken": sum(row["damageTaken"] for row in observations), "deathEvents": sum(row["deathEvents"] for row in observations), @@ -1106,8 +1118,8 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le )) write_csv(output_dir / "class_specs_across_parties.csv", class_specs, ( "source", "buildId", "classId", "className", "specialization", "role", "partyConfigurations", - "runs", "dps", "bossDps", "hps", "dtps", "damageVsSourceRoleMedian", "damageFlag", - "totalDamage", "totalDamageTaken", "totalHealing", "totalAbsorbs", "deathEvents", + "runs", "dps", "bossDps", "hps", "hpsReceived", "dtps", "damageVsSourceRoleMedian", "damageFlag", + "totalDamage", "totalDamageTaken", "totalHealing", "totalHealingReceived", "totalAbsorbs", "deathEvents", )) write_csv(output_dir / "class_abilities_across_parties.csv", class_abilities, ( "source", "buildId", "classId", "className", "specialization", "role", "abilityId", @@ -1129,7 +1141,7 @@ def create_suite_reports(entries: Sequence[dict[str, Any]], output_dir: Path, le )) write_csv(output_dir / "class_spec_by_boss.csv", class_spec_by_boss, ( "source", "buildId", "classId", "className", "specialization", "role", "bossKey", "bossName", - "dungeonTitle", "partyConfigurations", "runs", "dps", "bossDps", "hps", "dtps", + "dungeonTitle", "partyConfigurations", "runs", "dps", "bossDps", "hps", "hpsReceived", "dtps", "totalDamageTaken", "deathEvents", )) write_csv(output_dir / "suite_runs.csv", compact_runs, ( diff --git a/scripts/test_balance_lab.py b/scripts/test_balance_lab.py index 8fc64e91..f5e31a4d 100644 --- a/scripts/test_balance_lab.py +++ b/scripts/test_balance_lab.py @@ -66,23 +66,25 @@ class BalanceLabControllerTests(unittest.TestCase): self.assertEqual([boss["key"] for boss in search_bosses("forsaken", bosses)], ["abbey/duke"]) self.assertEqual([boss["key"] for boss in search_bosses("rom", bosses)], ["abbey/duke"]) - def test_class_spec_aggregate_keeps_rate_and_absolute_damage_taken(self) -> None: + def test_class_spec_aggregate_keeps_damage_and_healing_received_metrics(self) -> None: key = ("wow", "priest-holy", "priest", "Priest", "Holy", "healer") rows = aggregate_class_specs({key: [ { "partyConfigurationId": "wow-party-001", "elapsedSeconds": 10, - "damage": 200, "bossDamage": 180, "healing": 500, + "damage": 200, "bossDamage": 180, "healing": 500, "healingReceived": 300, "damageTaken": 120, "absorbs": 25, "deathEvents": 0, }, { "partyConfigurationId": "wow-party-002", "elapsedSeconds": 20, - "damage": 400, "bossDamage": 360, "healing": 900, + "damage": 400, "bossDamage": 360, "healing": 900, "healingReceived": 600, "damageTaken": 330, "absorbs": 50, "deathEvents": 1, }, ]}) self.assertEqual(len(rows), 1) self.assertEqual(rows[0]["dtps"], 15) self.assertEqual(rows[0]["totalDamageTaken"], 450) + self.assertEqual(rows[0]["hpsReceived"], 30) + self.assertEqual(rows[0]["totalHealingReceived"], 900) def test_ability_aggregate_keeps_used_and_available_but_unused_rows(self) -> None: class_key = ("wow", "mage-fire", "mage", "Mage", "Fire", "damage") diff --git a/src/app/balanceLabClient.ts b/src/app/balanceLabClient.ts index b573b87f..7cfdcea2 100644 --- a/src/app/balanceLabClient.ts +++ b/src/app/balanceLabClient.ts @@ -84,10 +84,12 @@ export interface BalanceLabClassSpecResult { readonly dps: number; readonly bossDps: number; readonly hps: number; + readonly hpsReceived?: number; readonly dtps: number; readonly totalDamage: number; readonly totalDamageTaken: number; readonly totalHealing: number; + readonly totalHealingReceived?: number; readonly totalAbsorbs: number; readonly deathEvents: number; readonly damageVsSourceRoleMedian: number | null; diff --git a/src/styles.css b/src/styles.css index 28e8c2dc..36bfcdd5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3317,6 +3317,8 @@ button.equipment-slot:hover { background: rgba(174,191,131,.1); } .balance-results-chart.is-time .balance-results-bar__track i { background: linear-gradient(90deg, #766130, #d1aa51); } .balance-results-chart.is-class .balance-results-bar__track i { background: linear-gradient(90deg, #395e76, #6faac4); } .balance-results-chart.is-taken .balance-results-bar__track i { background: linear-gradient(90deg, #6b3d70, #b376b5); } +.balance-results-chart.is-healing .balance-results-bar__track i { background: linear-gradient(90deg, #327058, #73c995); } +.balance-results-chart.is-received .balance-results-bar__track i { background: linear-gradient(90deg, #426a7a, #82c5c7); } .balance-results-bar > strong { color: #d8d2b5; font-size: 7px; text-align: right; white-space: nowrap; } .balance-results-chart > footer { padding: 7px 11px; border-top: 1px solid rgba(190, 213, 172, .08); color: var(--front-dim); font-size: 6px; } .balance-results-empty { padding: 18px 8px; color: var(--front-dim); font-size: 8px; text-align: center; } diff --git a/src/ui/BalanceLabResults.tsx b/src/ui/BalanceLabResults.tsx index 48c86fe4..f5485a16 100644 --- a/src/ui/BalanceLabResults.tsx +++ b/src/ui/BalanceLabResults.tsx @@ -58,7 +58,7 @@ function BarChart({ readonly description: string; readonly rows: readonly ChartDatum[]; readonly format?: (value: number) => string; - readonly tone?: "damage" | "party" | "time" | "class" | "taken"; + readonly tone?: "damage" | "party" | "time" | "class" | "taken" | "healing" | "received"; }) { const sorted = [...rows].filter((row) => Number.isFinite(row.value)).sort((left, right) => right.value - left.value); const visible = sorted.slice(0, 15); @@ -217,6 +217,8 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite + + } @@ -226,8 +228,8 @@ 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 DPSHPSDamage taken / secTotal damage takenRole medianTotal damage dealtTotal healingAbsorbsDeaths
{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.dtps)}{formatWhole(row.totalDamageTaken)}{formatRatio(row.damageVsSourceRoleMedian)} · {row.damageFlag}{formatWhole(row.totalDamage)}{formatWhole(row.totalHealing)}{formatWhole(row.totalAbsorbs)}{row.deathEvents.toLocaleString()}
+

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()}
} {tab === "abilities" &&