Skip to content

Add per-minute hero damage, healing and camp stack series - #87

Open
geracosta wants to merge 2 commits into
odota:masterfrom
geracosta:minute-series
Open

Add per-minute hero damage, healing and camp stack series#87
geracosta wants to merge 2 commits into
odota:masterfrom
geracosta:minute-series

Conversation

@geracosta

@geracosta geracosta commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

This adds three new per-player arrays to the parsed data blob, sampled on minute boundaries exactly like the existing gold_t / lh_t / xp_t / dn_t series:

  • camps_stacked_t — cumulative camps stacked at each minute. The value already exists on every interval entry; this just samples it into a series. Left empty for old replays that don't carry camps_stacked in interval data.
  • hero_damage_t — cumulative damage dealt to enemy heroes at each minute, accumulated from DOTA_COMBATLOG_DAMAGE. Follows the Valve hero_damage scoreboard definition (verified empirically): real non-illusion hero targets, self-damage excluded, illusion attacker damage counted toward the owning hero. The final element matches the scoreboard hero_damage exactly, up to damage dealt after the last minute boundary.
  • hero_healing_t — cumulative healing done to other heroes at each minute, accumulated from DOTA_COMBATLOG_HEAL. Follows the Valve hero_healing scoreboard definition (verified empirically): hero targets excluding self-healing.

Implementation note: damage/heal events are bucketed by event time in a pre-scan instead of accumulated in stream order — combat log entries can appear in the stream after the interval entry for the same game time (notably the final teamfight of a match), which would silently drop the last minute of damage.

Motivation: these series enable time-phased analysis (laning / mid / late) of damage output, healing and stacking, which currently is only possible for gold/xp/cs. They close one of the data gaps relative to Stratz's heroDamagePerMinute / healPerMinute / campStack fields, and pair with a small odota/core PR that documents the new fields in the OpenAPI spec. Related demand: odota/web#1834 asks for damage-per-minute display, including "DPM at the specific time interval" — these series make that possible.

The implementation lives in the Java port (CreateParsedDataBlob.java), which is the live path since "implement processors in Java"; the legacy JS processors (processExpand.mjs / parseSchema.mjs) are updated in the same way to keep the reference implementation in sync.

No schema version bump (additive fields only, same approach as the pause timings addition). Old parsed blobs simply won't have the fields.

Testing:

  • Synthetic event stream through the JS reference processors covering the edge cases: hero damage counted (including illusion attacker damage), damage to creeps excluded, self-damage excluded, self-heal excluded, cross-hero heal counted, and a combat log entry appearing after the minute tick in stream order but with an earlier event time (the ordering bug above). Existing damage / healing dicts unchanged.
  • End-to-end regression: Docker image built from this branch, standard test replay (odota.github.io/testfiles/1781962623_1.dem) parsed via /blob?replay_url= and diffed against the reference blob checked into odota/core (json/1781962623_parsed.json) — all pre-existing fields byte-identical; camps_stacked_t correctly empty (old replay).
  • End-to-end scoreboard cross-check on a recent real match (8919875707): hero_damage_t final element matches the Valve scoreboard hero_damage exactly for 9/10 players (the 10th is 0.999 — damage dealt after the last minute boundary, by design, same as gold_t vs total gold); hero_healing_t final matches hero_healing exactly for 9/10 (one off by 208 out of 4111); camps_stacked_t final matches camps_stacked for 10/10.

Performance: benchmarked master vs this branch on the same real replay (18 min match), 9 measured runs each after a JIT warmup run, comparing the server-side parse: stderr timing (CPU work only — download and decompression excluded):

master this branch
min 1686 ms 1722 ms
median 1876 ms 1752 ms
mean 1833 ms 1889 ms

The delta (~2% on the min; medians overlap) is below the ±10% run-to-run variance. The added work is one extra O(n) pre-scan over the entries plus three interval entries per player per minute, while total parse time is dominated by the .dem decode. Blob size grows by a few KB on a ~150 KB blob.

Emit camps_stacked_t, hero_damage_t and hero_healing_t arrays per
player, sampled on minute boundaries like gold_t/lh_t/xp_t/dn_t.

- camps_stacked_t mirrors the cumulative camps_stacked interval value;
  left empty for old replays without camps_stacked interval data
- hero_damage_t follows the hero_damage scoreboard definition, verified
  empirically against a real match: damage dealt to real (non-illusion)
  heroes, self-damage excluded, illusion attacker damage counted toward
  the owning hero
- hero_healing_t follows the hero_healing scoreboard definition:
  healing done to heroes other than yourself

Damage/heal events are bucketed by event time in a pre-scan rather than
accumulated in stream order: combat log entries can appear in the stream
after the interval entry of the same game time, which would drop the
final teamfight of a match from the last sample.

Implemented in the Java processors (the live path) and mirrored in the
legacy JS reference processors.
@howardchung

Copy link
Copy Markdown
Member

This will increase the storage cost for future matches--do we have a specific need for it?

},
times: [],
gold_t: [],
lh_t: [],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've since switched to the Java implementations so these are just here for reference.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's why the primary implementation here is in CreateParsedDataBlob.java — I updated the .mjs alongside it just to keep the reference in sync with what the Java actually does. Happy to drop the JS changes if you'd rather keep the diff minimal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - dropped the .mjs changes in 936e5de, the diff now only touches the Java path.

@geracosta

geracosta commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Measured master vs this branch on the same replays. The blob archive stores gzip, so that's the relevant column:

match raw pre raw post gzip pre gzip post gzip delta
8919875707 (18 min) 153,498 155,814 29,390 29,976 +586 B (+2.0%)
1781962623 (37 min, old replay so camps_stacked_t is empty) 145,416 148,709 27,945 28,796 +851 B (+3.0%)

So roughly +0.6–0.9 KB gzipped per parsed match (~2–3% of the blob) — the arrays compress well since they're monotonic integers with long flat stretches. Example output (the 18-min match, Dazzle):

"times":           [0, 60, 120, 180, ...],
"hero_damage_t":   [0,158,713,1126,1201,1536,1846,1846,2731,2731,2886,2886,2988,3170,3170,3170,3170,3207,3207],
"hero_healing_t":  [0,0,85,170,400,525,1019,1353,1788,1933,2078,2078,2223,2458,2501,2501,2501,3076,3076],
"camps_stacked_t": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]

On the specific need: I'm building a coaching tool on top of the OpenDota API that gives per-phase feedback (laning / mid game / late game). Damage output, healing and stacking are core coaching signals for supports and mids, and today the API only supports time-phased analysis for gold/xp/cs — for everything else I'd have to download and reparse replays myself, or use Stratz, which exposes exactly these fields (heroDamagePerMinute / healPerMinute / campStack). It's also what odota/web#1834 asked for ("DPM at the specific time interval").

If storage is still a concern, I'm happy to drop camps_stacked_t (mostly zeros, the least essential of the three) to cut the delta roughly a third, or discuss alternatives.

The JS processors are reference-only since the switch to the Java
implementation, so the new series no longer need to be mirrored there.
// pre-scan (rather than accumulated in stream order) because combat log
// entries can appear in the stream after the interval entry of the same
// game time, which would drop the final minute of a match.
private Map<Integer, Map<Integer, Integer>> heroDamageMinuteBySlot = new HashMap<>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to bucket into minutes here? Isn't hero damage/healing additive, so we can just add to it whenever we encounter a combat log event and then add on minute boundaries?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants