Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79153ca40d | ||
|
|
717d12e5af |
No files matched your search
@@ -11,10 +11,11 @@ These rules apply to every change under this project root.
|
|||||||
|
|
||||||
## Workspace and reference boundaries
|
## Workspace and reference boundaries
|
||||||
|
|
||||||
- The active project root is `D:\Projects\HealerMan`. All implementation,
|
- The active project root is the directory containing this `AGENTS.md`
|
||||||
generated content, scripts, tests, builds, documentation, and validation for
|
(currently `F:\Projects\HealerMan`); do not assume a fixed drive letter. All
|
||||||
this project belong here.
|
implementation, generated content, scripts, tests, builds, documentation, and
|
||||||
- `D:\Projects\wow-pvt-server-backups-2` is historical reference material only.
|
validation for this project belong here.
|
||||||
|
- The sibling `../wow-pvt-server-backups-2` tree is historical reference material only.
|
||||||
Inspect it only when old-server behavior, AzerothCore data, creature IDs,
|
Inspect it only when old-server behavior, AzerothCore data, creature IDs,
|
||||||
spells, encounters, maps, or assets are useful as source information.
|
spells, encounters, maps, or assets are useful as source information.
|
||||||
- Never treat `wow-pvt-server-backups-2` as the active project, primary checkout,
|
- Never treat `wow-pvt-server-backups-2` as the active project, primary checkout,
|
||||||
@@ -27,6 +28,22 @@ These rules apply to every change under this project root.
|
|||||||
structure as appropriate, and validate it through HealerMan's tests/build and
|
structure as appropriate, and validate it through HealerMan's tests/build and
|
||||||
playtest workflow.
|
playtest workflow.
|
||||||
|
|
||||||
|
## Obsidian project memory
|
||||||
|
|
||||||
|
- The separate Healer-Man knowledge vault lives at the sibling path
|
||||||
|
`../ObsidianNotes/ObsidianVault1`. Start at
|
||||||
|
`00 Home/Healer-Man Hub.md` and follow
|
||||||
|
`40 Agent Workflows/Agent Memory Protocol.md` for broad or cross-system work.
|
||||||
|
- The code and tests in this repository remain authoritative. Check a note's
|
||||||
|
`last_verified` date and verify material claims against current code before
|
||||||
|
acting on them.
|
||||||
|
- Update the relevant vault note in the same task when a change alters feature
|
||||||
|
behavior, architectural ownership, entry points, cross-file relationships,
|
||||||
|
invariants, or required validation. Use repository-relative code paths and
|
||||||
|
keep transient investigation details in the vault's `50 Worklog/` area.
|
||||||
|
- The vault is its own Git repository. Review and commit its changes separately
|
||||||
|
from HealerMan changes; never stage one repository from the other.
|
||||||
|
|
||||||
## Dungeon report triage
|
## Dungeon report triage
|
||||||
|
|
||||||
- For any request to inspect or fix current dungeon reports, start with
|
- For any request to inspect or fix current dungeon reports, start with
|
||||||
|
|||||||
@@ -3545,3 +3545,19 @@ Vite's existing large-chunk advisory remains informational.
|
|||||||
errors; all five bodies and their equipped weapon/staff silhouettes rendered
|
errors; all five bodies and their equipped weapon/staff silhouettes rendered
|
||||||
intact. Evidence is in
|
intact. Evidence is in
|
||||||
`playtest-artifacts/dungeon-health-scaling/stockades-level-three.png`.
|
`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
@@ -80,11 +80,13 @@ The dashboard includes:
|
|||||||
- party damage applied directly to each boss;
|
- party damage applied directly to each boss;
|
||||||
- average successful kill time;
|
- average successful kill time;
|
||||||
- boss DPS and damage taken for every class/specialization;
|
- 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,
|
- complete boss tables with pressure versus median, total incoming damage,
|
||||||
defeat/wipe rates, deaths, and health;
|
defeat/wipe rates, deaths, and health;
|
||||||
- complete class/spec tables with total and boss DPS, HPS, DTPS, absolute
|
- complete class/spec tables with total and boss DPS, healing done/received per
|
||||||
damage taken, damage/healing/absorb totals, deaths, and source/role-median
|
second, DTPS, absolute damage taken, healing done/received and absorb totals,
|
||||||
tuning flags; and
|
deaths, and source/role-median tuning flags; and
|
||||||
- a complete Abilities tab with learned/available abilities (including zero-use
|
- a complete Abilities tab with learned/available abilities (including zero-use
|
||||||
rows), exact runtime IDs, AI categories, active-run coverage, casts, hits,
|
rows), exact runtime IDs, AI categories, active-run coverage, casts, hits,
|
||||||
casts per minute, effective/raw damage, DPS, damage share, damage per hit,
|
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;
|
direct boss damage. Suite class/spec `dtps` is damage taken per active second;
|
||||||
`totalDamageTaken` is the absolute healer/survivability burden across every
|
`totalDamageTaken` is the absolute healer/survivability burden across every
|
||||||
included run.
|
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,
|
- Ability `activeRuns` counts simulations with at least one cast, hit, damage,
|
||||||
healing, overheal, or absorb event. `usageRate` divides that by every run for
|
healing, overheal, or absorb event. `usageRate` divides that by every run for
|
||||||
that build; `damageShare` divides the ability's effective damage by the
|
that build; `damageShare` divides the ability's effective damage by the
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.49",
|
"version": "0.1.50",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"version": "0.1.49",
|
"version": "0.1.50",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "8.4.1",
|
"@capacitor/android": "8.4.1",
|
||||||
"@capacitor/core": "8.4.1",
|
"@capacitor/core": "8.4.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "healer-man",
|
"name": "healer-man",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.49",
|
"version": "0.1.50",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"versionName": "0.1.49",
|
"versionName": "0.1.50",
|
||||||
"versionCode": 1049
|
"versionCode": 1050
|
||||||
}
|
}
|
||||||
@@ -90,6 +90,7 @@ interface MutableMemberMetrics {
|
|||||||
healing: number;
|
healing: number;
|
||||||
rawHealing: number;
|
rawHealing: number;
|
||||||
overheal: number;
|
overheal: number;
|
||||||
|
healingReceived: number;
|
||||||
absorbs: number;
|
absorbs: number;
|
||||||
damageTaken: number;
|
damageTaken: number;
|
||||||
rawDamageTaken: number;
|
rawDamageTaken: number;
|
||||||
@@ -286,6 +287,8 @@ function serializedMember(
|
|||||||
rawHealing: metrics.rawHealing,
|
rawHealing: metrics.rawHealing,
|
||||||
overheal: metrics.overheal,
|
overheal: metrics.overheal,
|
||||||
hps: metrics.healing / Math.max(0.1, activeSeconds),
|
hps: metrics.healing / Math.max(0.1, activeSeconds),
|
||||||
|
healingReceived: metrics.healingReceived,
|
||||||
|
hpsReceived: metrics.healingReceived / Math.max(0.1, activeSeconds),
|
||||||
absorbs: metrics.absorbs,
|
absorbs: metrics.absorbs,
|
||||||
damageTaken: metrics.damageTaken,
|
damageTaken: metrics.damageTaken,
|
||||||
rawDamageTaken: metrics.rawDamageTaken,
|
rawDamageTaken: metrics.rawDamageTaken,
|
||||||
@@ -356,6 +359,7 @@ async function runBoss(
|
|||||||
healing: 0,
|
healing: 0,
|
||||||
rawHealing: 0,
|
rawHealing: 0,
|
||||||
overheal: 0,
|
overheal: 0,
|
||||||
|
healingReceived: 0,
|
||||||
absorbs: 0,
|
absorbs: 0,
|
||||||
damageTaken: 0,
|
damageTaken: 0,
|
||||||
rawDamageTaken: 0,
|
rawDamageTaken: 0,
|
||||||
@@ -395,15 +399,18 @@ async function runBoss(
|
|||||||
ability.hits += 1;
|
ability.hits += 1;
|
||||||
bossAbilities.set(key, ability);
|
bossAbilities.set(key, ability);
|
||||||
}
|
}
|
||||||
} else if (event.kind === "healing" && source) {
|
} else if (event.kind === "healing") {
|
||||||
source.rawHealing += event.rawAmount;
|
if (source) {
|
||||||
source.healing += event.effectiveAmount;
|
source.rawHealing += event.rawAmount;
|
||||||
source.overheal += event.overheal;
|
source.healing += event.effectiveAmount;
|
||||||
const ability = abilityMetrics(source, event.abilityId);
|
source.overheal += event.overheal;
|
||||||
ability.rawHealing += event.rawAmount;
|
const ability = abilityMetrics(source, event.abilityId);
|
||||||
ability.effectiveHealing += event.effectiveAmount;
|
ability.rawHealing += event.rawAmount;
|
||||||
ability.overheal += event.overheal;
|
ability.effectiveHealing += event.effectiveAmount;
|
||||||
ability.hits += 1;
|
ability.overheal += event.overheal;
|
||||||
|
ability.hits += 1;
|
||||||
|
}
|
||||||
|
if (target) target.healingReceived += event.effectiveAmount;
|
||||||
} else if (event.kind === "absorb" && source) {
|
} else if (event.kind === "absorb" && source) {
|
||||||
source.absorbs += event.effectiveAmount;
|
source.absorbs += event.effectiveAmount;
|
||||||
abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount;
|
abilityMetrics(source, event.abilityId).absorbs += event.effectiveAmount;
|
||||||
|
|||||||
+27
-15
@@ -320,7 +320,7 @@ def interactive_arguments(
|
|||||||
def weighted_rate(rows: Iterable[dict[str, Any]], amount: str) -> float:
|
def weighted_rate(rows: Iterable[dict[str, Any]], amount: str) -> float:
|
||||||
materialized = list(rows)
|
materialized = list(rows)
|
||||||
seconds = sum(float(row["elapsedSeconds"]) for row in materialized)
|
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:
|
def mean(values: Iterable[float]) -> float:
|
||||||
@@ -402,9 +402,11 @@ def aggregate(raw: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"dps": weighted_rate(rows, "damage"),
|
"dps": weighted_rate(rows, "damage"),
|
||||||
"bossDps": weighted_rate(rows, "bossDamage"),
|
"bossDps": weighted_rate(rows, "bossDamage"),
|
||||||
"hps": weighted_rate(rows, "healing"),
|
"hps": weighted_rate(rows, "healing"),
|
||||||
|
"hpsReceived": weighted_rate(rows, "healingReceived"),
|
||||||
"dtps": weighted_rate(rows, "damageTaken"),
|
"dtps": weighted_rate(rows, "damageTaken"),
|
||||||
"totalDamage": sum(row["damage"] for row in rows),
|
"totalDamage": sum(row["damage"] for row in rows),
|
||||||
"totalHealing": sum(row["healing"] 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),
|
"totalAbsorbs": sum(row["absorbs"] for row in rows),
|
||||||
"deathEvents": sum(row["deathEvents"] for row in rows),
|
"deathEvents": sum(row["deathEvents"] for row in rows),
|
||||||
"minimumHealthPercent": min(row["minimumHealthPercent"] 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"),
|
"dps": weighted_rate(rows, "damage"),
|
||||||
"bossDps": weighted_rate(rows, "bossDamage"),
|
"bossDps": weighted_rate(rows, "bossDamage"),
|
||||||
"hps": weighted_rate(rows, "healing"),
|
"hps": weighted_rate(rows, "healing"),
|
||||||
|
"hpsReceived": weighted_rate(rows, "healingReceived"),
|
||||||
"dtps": weighted_rate(rows, "damageTaken"),
|
"dtps": weighted_rate(rows, "damageTaken"),
|
||||||
"deathEvents": sum(row["deathEvents"] for row in rows),
|
"deathEvents": sum(row["deathEvents"] for row in rows),
|
||||||
})
|
})
|
||||||
@@ -520,15 +523,16 @@ def markdown_report(report: dict[str, Any]) -> str:
|
|||||||
"",
|
"",
|
||||||
"## Every party member",
|
"## 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"]:
|
for member in report["members"]:
|
||||||
marker = " (active)" if member["activePlayer"] else ""
|
marker = " (active)" if member["activePlayer"] else ""
|
||||||
lines.append(
|
lines.append(
|
||||||
f"| {member['slot']} | {member['className']} — {member['specialization']}{marker} | "
|
f"| {member['slot']} | {member['className']} — {member['specialization']}{marker} | "
|
||||||
f"{member['role']} | {format_number(member['dps'])} | {format_number(member['bossDps'])} | "
|
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']} |"
|
f"{format_ratio(member['damageVsRoleMedian'])} | {member['damageFlag']} | {member['deathEvents']} |"
|
||||||
)
|
)
|
||||||
lines.extend([
|
lines.extend([
|
||||||
@@ -592,12 +596,14 @@ def html_report(report: dict[str, Any]) -> str:
|
|||||||
"dps": format_number(row["dps"]),
|
"dps": format_number(row["dps"]),
|
||||||
"bossDps": format_number(row["bossDps"]),
|
"bossDps": format_number(row["bossDps"]),
|
||||||
"hps": format_number(row["hps"]),
|
"hps": format_number(row["hps"]),
|
||||||
|
"hpsReceived": format_number(row["hpsReceived"]),
|
||||||
"dtps": format_number(row["dtps"]),
|
"dtps": format_number(row["dtps"]),
|
||||||
"damageVsRoleMedian": format_number(row["damageVsRoleMedian"], 2),
|
"damageVsRoleMedian": format_number(row["damageVsRoleMedian"], 2),
|
||||||
} for row in report["members"]]
|
} for row in report["members"]]
|
||||||
member_table = html_table("members", "Every party member", members, (
|
member_table = html_table("members", "Every party member", members, (
|
||||||
("slot", "Slot"), ("member", "Member"), ("role", "Role"), ("dps", "DPS"),
|
("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"),
|
("damageVsRoleMedian", "Role median ×"), ("damageFlag", "Flag"), ("deathEvents", "Death events"),
|
||||||
))
|
))
|
||||||
boss_table = html_table("bosses", "Every boss — highest pressure first", bosses, (
|
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"], (
|
write_csv(output_dir / "party_members.csv", report["members"], (
|
||||||
"slot", "activePlayer", "classId", "className", "specialization", "role", "dps", "bossDps",
|
"slot", "activePlayer", "classId", "className", "specialization", "role", "dps", "bossDps",
|
||||||
"hps", "dtps", "damageVsRoleMedian", "damageFlag", "totalDamage", "totalHealing",
|
"hps", "hpsReceived", "dtps", "damageVsRoleMedian", "damageFlag", "totalDamage", "totalHealing",
|
||||||
"totalAbsorbs", "deathEvents", "minimumHealthPercent",
|
"totalHealingReceived", "totalAbsorbs", "deathEvents", "minimumHealthPercent",
|
||||||
))
|
))
|
||||||
write_csv(output_dir / "member_by_boss.csv", report["memberByBoss"], (
|
write_csv(output_dir / "member_by_boss.csv", report["memberByBoss"], (
|
||||||
"bossKey", "bossName", "dungeonTitle", "slot", "className", "specialization", "role",
|
"bossKey", "bossName", "dungeonTitle", "slot", "className", "specialization", "role",
|
||||||
"dps", "bossDps", "hps", "dtps", "deathEvents",
|
"dps", "bossDps", "hps", "hpsReceived", "dtps", "deathEvents",
|
||||||
))
|
))
|
||||||
run_rows = [{
|
run_rows = [{
|
||||||
"policyId": raw["policyId"],
|
"policyId": raw["policyId"],
|
||||||
@@ -688,10 +694,12 @@ def aggregate_class_specs(
|
|||||||
"dps": weighted_rate(observations, "damage"),
|
"dps": weighted_rate(observations, "damage"),
|
||||||
"bossDps": weighted_rate(observations, "bossDamage"),
|
"bossDps": weighted_rate(observations, "bossDamage"),
|
||||||
"hps": weighted_rate(observations, "healing"),
|
"hps": weighted_rate(observations, "healing"),
|
||||||
|
"hpsReceived": weighted_rate(observations, "healingReceived"),
|
||||||
"dtps": weighted_rate(observations, "damageTaken"),
|
"dtps": weighted_rate(observations, "damageTaken"),
|
||||||
"totalDamage": sum(row["damage"] for row in observations),
|
"totalDamage": sum(row["damage"] for row in observations),
|
||||||
"totalDamageTaken": sum(row["damageTaken"] for row in observations),
|
"totalDamageTaken": sum(row["damageTaken"] for row in observations),
|
||||||
"totalHealing": sum(row["healing"] 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),
|
"totalAbsorbs": sum(row["absorbs"] for row in observations),
|
||||||
"deathEvents": sum(row["deathEvents"] 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",
|
"## 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"]:
|
for row in report["classSpecs"]:
|
||||||
lines.append(
|
lines.append(
|
||||||
f"| {row['source']} | {row['className']} — {row['specialization']} | {row['role']} | "
|
f"| {row['source']} | {row['className']} — {row['specialization']} | {row['role']} | "
|
||||||
f"{row['partyConfigurations']} | {row['runs']} | {format_number(row['dps'])} | "
|
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_number(row['totalDamageTaken'])} | "
|
||||||
f"{format_ratio(row['damageVsSourceRoleMedian'])} | {row['damageFlag']} | {row['deathEvents']} |"
|
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"]),
|
"dps": format_number(row["dps"]),
|
||||||
"bossDps": format_number(row["bossDps"]),
|
"bossDps": format_number(row["bossDps"]),
|
||||||
"hps": format_number(row["hps"]),
|
"hps": format_number(row["hps"]),
|
||||||
|
"hpsReceived": format_number(row["hpsReceived"]),
|
||||||
"dtps": format_number(row["dtps"]),
|
"dtps": format_number(row["dtps"]),
|
||||||
"totalDamageTaken": format_number(row["totalDamageTaken"]),
|
"totalDamageTaken": format_number(row["totalDamageTaken"]),
|
||||||
"damageVsSourceRoleMedian": format_ratio(row["damageVsSourceRoleMedian"]),
|
"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, (
|
class_table = html_table("class-specs", "Every tested class/spec", class_specs, (
|
||||||
("source", "Source"), ("classSpec", "Class/spec"), ("role", "Role"),
|
("source", "Source"), ("classSpec", "Class/spec"), ("role", "Role"),
|
||||||
("partyConfigurations", "Parties"), ("runs", "Runs"), ("dps", "DPS"),
|
("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"),
|
("totalDamageTaken", "Total taken"), ("damageVsSourceRoleMedian", "Role median"),
|
||||||
("damageFlag", "Flag"), ("deathEvents", "Deaths"),
|
("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"),
|
"dps": weighted_rate(observations, "damage"),
|
||||||
"bossDps": weighted_rate(observations, "bossDamage"),
|
"bossDps": weighted_rate(observations, "bossDamage"),
|
||||||
"hps": weighted_rate(observations, "healing"),
|
"hps": weighted_rate(observations, "healing"),
|
||||||
|
"hpsReceived": weighted_rate(observations, "healingReceived"),
|
||||||
"dtps": weighted_rate(observations, "damageTaken"),
|
"dtps": weighted_rate(observations, "damageTaken"),
|
||||||
"totalDamageTaken": sum(row["damageTaken"] for row in observations),
|
"totalDamageTaken": sum(row["damageTaken"] for row in observations),
|
||||||
"deathEvents": sum(row["deathEvents"] 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, (
|
write_csv(output_dir / "class_specs_across_parties.csv", class_specs, (
|
||||||
"source", "buildId", "classId", "className", "specialization", "role", "partyConfigurations",
|
"source", "buildId", "classId", "className", "specialization", "role", "partyConfigurations",
|
||||||
"runs", "dps", "bossDps", "hps", "dtps", "damageVsSourceRoleMedian", "damageFlag",
|
"runs", "dps", "bossDps", "hps", "hpsReceived", "dtps", "damageVsSourceRoleMedian", "damageFlag",
|
||||||
"totalDamage", "totalDamageTaken", "totalHealing", "totalAbsorbs", "deathEvents",
|
"totalDamage", "totalDamageTaken", "totalHealing", "totalHealingReceived", "totalAbsorbs", "deathEvents",
|
||||||
))
|
))
|
||||||
write_csv(output_dir / "class_abilities_across_parties.csv", class_abilities, (
|
write_csv(output_dir / "class_abilities_across_parties.csv", class_abilities, (
|
||||||
"source", "buildId", "classId", "className", "specialization", "role", "abilityId",
|
"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, (
|
write_csv(output_dir / "class_spec_by_boss.csv", class_spec_by_boss, (
|
||||||
"source", "buildId", "classId", "className", "specialization", "role", "bossKey", "bossName",
|
"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",
|
"totalDamageTaken", "deathEvents",
|
||||||
))
|
))
|
||||||
write_csv(output_dir / "suite_runs.csv", compact_runs, (
|
write_csv(output_dir / "suite_runs.csv", compact_runs, (
|
||||||
|
|||||||
@@ -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("forsaken", bosses)], ["abbey/duke"])
|
||||||
self.assertEqual([boss["key"] for boss in search_bosses("rom", 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")
|
key = ("wow", "priest-holy", "priest", "Priest", "Holy", "healer")
|
||||||
rows = aggregate_class_specs({key: [
|
rows = aggregate_class_specs({key: [
|
||||||
{
|
{
|
||||||
"partyConfigurationId": "wow-party-001", "elapsedSeconds": 10,
|
"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,
|
"damageTaken": 120, "absorbs": 25, "deathEvents": 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"partyConfigurationId": "wow-party-002", "elapsedSeconds": 20,
|
"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,
|
"damageTaken": 330, "absorbs": 50, "deathEvents": 1,
|
||||||
},
|
},
|
||||||
]})
|
]})
|
||||||
self.assertEqual(len(rows), 1)
|
self.assertEqual(len(rows), 1)
|
||||||
self.assertEqual(rows[0]["dtps"], 15)
|
self.assertEqual(rows[0]["dtps"], 15)
|
||||||
self.assertEqual(rows[0]["totalDamageTaken"], 450)
|
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:
|
def test_ability_aggregate_keeps_used_and_available_but_unused_rows(self) -> None:
|
||||||
class_key = ("wow", "mage-fire", "mage", "Mage", "Fire", "damage")
|
class_key = ("wow", "mage-fire", "mage", "Mage", "Fire", "damage")
|
||||||
|
|||||||
@@ -84,10 +84,12 @@ export interface BalanceLabClassSpecResult {
|
|||||||
readonly dps: number;
|
readonly dps: number;
|
||||||
readonly bossDps: number;
|
readonly bossDps: number;
|
||||||
readonly hps: number;
|
readonly hps: number;
|
||||||
|
readonly hpsReceived?: number;
|
||||||
readonly dtps: number;
|
readonly dtps: number;
|
||||||
readonly totalDamage: number;
|
readonly totalDamage: number;
|
||||||
readonly totalDamageTaken: number;
|
readonly totalDamageTaken: number;
|
||||||
readonly totalHealing: number;
|
readonly totalHealing: number;
|
||||||
|
readonly totalHealingReceived?: number;
|
||||||
readonly totalAbsorbs: number;
|
readonly totalAbsorbs: number;
|
||||||
readonly deathEvents: number;
|
readonly deathEvents: number;
|
||||||
readonly damageVsSourceRoleMedian: number | null;
|
readonly damageVsSourceRoleMedian: number | null;
|
||||||
|
|||||||
@@ -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-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-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-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-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-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; }
|
.balance-results-empty { padding: 18px 8px; color: var(--front-dim); font-size: 8px; text-align: center; }
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function BarChart({
|
|||||||
readonly description: string;
|
readonly description: string;
|
||||||
readonly rows: readonly ChartDatum[];
|
readonly rows: readonly ChartDatum[];
|
||||||
readonly format?: (value: number) => string;
|
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 sorted = [...rows].filter((row) => Number.isFinite(row.value)).sort((left, right) => right.value - left.value);
|
||||||
const visible = sorted.slice(0, 15);
|
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="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="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="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>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
@@ -226,8 +228,8 @@ export function BalanceLabResults({ report }: { readonly report: BalanceLabSuite
|
|||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{tab === "classes" && <div className="balance-results-table-panel">
|
{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>
|
<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>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>
|
<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>}
|
</div>}
|
||||||
|
|
||||||
{tab === "abilities" && <div className="balance-results-abilities">
|
{tab === "abilities" && <div className="balance-results-abilities">
|
||||||
|
|||||||
Reference in new issue
Block a user