Release Healer Man 0.1.50

This commit is contained in:
phenom committed 2026-09-17 13:33:55 -04:00
1 parent 717d12e5af
commit 79153ca40d
11 files changed
+89 -38

No files matched your search

+16
View File
@@ -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/`.
+11 -3
View File
@@ -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/<party-id>/` 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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.49",
"versionCode": 1049
"versionName": "0.1.50",
"versionCode": 1050
}
+16 -9
View File
@@ -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;
+27 -15
View File
@@ -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, (
+5 -3
View File
@@ -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")
+2
View File
@@ -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;
+2
View File
@@ -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; }
+5 -3
View File
@@ -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
<BarChart title="Kill times" description="Average seconds to defeat; bosses never defeated are listed in the boss table as —." rows={bossChart("averageTimeToDefeat")} format={formatSeconds} tone="time" />
<BarChart title="Class/spec boss damage" description="Every tested build ranked by DPS applied directly to bosses." rows={classChart("bossDps")} tone="class" />
<BarChart title="Damage taken by class/spec" description="Incoming damage per second by build; useful for survivability and healer-load checks." rows={classChart("dtps")} tone="taken" />
<BarChart title="Healing done by class/spec" description="Effective healing per second, excluding overhealing. Compare healer output with boss pressure and healer DPS." rows={classChart("hps")} tone="healing" />
<BarChart title="Healing received by class/spec" description="Effective healing received per second. New runs attribute every heal to its party target, including self-heals." rows={classChart("hpsReceived")} tone="received" />
</div>
</div>}
@@ -226,8 +228,8 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite
</div>}
{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">HPS</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</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>HPS</th><th>Damage taken / sec</th><th>Total damage taken</th><th>Role median</th><th>Total damage dealt</th><th>Total healing</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.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.totalAbsorbs)}</td><td>{row.deathEvents.toLocaleString()}</td></tr>)}</tbody></table></div>
<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>}
{tab === "abilities" && <div className="balance-results-abilities">