From 8ef09f99f4808dbacc73100f1d0fef89f69a962f Mon Sep 17 00:00:00 2001 From: geracosta Date: Wed, 29 Jul 2026 17:08:43 -0300 Subject: [PATCH 1/2] Add per-minute hero damage, healing and camp stack series 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. --- processors/parseSchema.mjs | 3 + processors/processExpand.mjs | 75 +++++++++++++++++++ .../java/opendota/CreateParsedDataBlob.java | 72 +++++++++++++++++- 3 files changed, 149 insertions(+), 1 deletion(-) diff --git a/processors/parseSchema.mjs b/processors/parseSchema.mjs index f5f6bafe..30ee8235 100644 --- a/processors/parseSchema.mjs +++ b/processors/parseSchema.mjs @@ -30,6 +30,9 @@ export default { lh_t: [], dn_t: [], xp_t: [], + camps_stacked_t: [], + hero_damage_t: [], + hero_healing_t: [], obs_log: [], sen_log: [], obs_left_log: [], diff --git a/processors/processExpand.mjs b/processors/processExpand.mjs index 4368b722..77e9402f 100644 --- a/processors/processExpand.mjs +++ b/processors/processExpand.mjs @@ -45,6 +45,50 @@ function processExpand(entries, meta) { let aegisHolder = null; // Used to ignore meepo clones killing themselves let aegisDeathTime = null; + // Damage/healing dealt to real (non-illusion) heroes, bucketed by slot and + // minute of the event time, then emitted as cumulative hero_damage_t and + // hero_healing_t series on minute boundaries. Bucketed by event time in a + // 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. + // Events in (60*(k-1), 60*k] belong to bucket k so they are included in the + // sample taken at the minute boundary 60*k + const minuteBucket = (time) => Math.max(0, Math.ceil(time / 60)); + const heroDamageMinuteBySlot = {}; + const heroHealingMinuteBySlot = {}; + const heroDamageCumBySlot = {}; + const heroHealingCumBySlot = {}; + entries.forEach((e) => { + if (e.time == null || e.value == null) { + return; + } + // matches the Valve scoreboard definitions: damage/healing dealt to + // real (non-illusion) heroes other than yourself; illusion attacker + // damage counts toward the owning hero + if (e.type === 'DOTA_COMBATLOG_DAMAGE' || e.type === 'DOTA_COMBATLOG_HEAL') { + if ( + !e.targethero || + e.targetillusion || + !e.targetname || + e.targetname === e.sourcename + ) { + return; + } + const sourceSlot = meta.hero_to_slot[e.sourcename]; + if (sourceSlot === undefined) { + return; + } + const target = + e.type === 'DOTA_COMBATLOG_DAMAGE' + ? heroDamageMinuteBySlot + : heroHealingMinuteBySlot; + if (!target[sourceSlot]) { + target[sourceSlot] = {}; + } + const bucket = minuteBucket(e.time); + target[sourceSlot][bucket] = (target[sourceSlot][bucket] || 0) + e.value; + } + }); const types = { DOTA_COMBATLOG_DAMAGE(e) { // damage @@ -599,6 +643,37 @@ function processExpand(entries, meta) { type: 'dn_t', value: e.denies, }); + if (e.camps_stacked != null) { + // not present in old replays; leave the array empty rather than filling with nulls + expand({ + time: e.time, + slot: e.slot, + interval: true, + type: 'camps_stacked_t', + value: e.camps_stacked, + }); + } + const minuteIdx = e.time / 60; + heroDamageCumBySlot[e.slot] = + (heroDamageCumBySlot[e.slot] || 0) + + (heroDamageMinuteBySlot[e.slot]?.[minuteIdx] || 0); + expand({ + time: e.time, + slot: e.slot, + interval: true, + type: 'hero_damage_t', + value: heroDamageCumBySlot[e.slot], + }); + heroHealingCumBySlot[e.slot] = + (heroHealingCumBySlot[e.slot] || 0) + + (heroHealingMinuteBySlot[e.slot]?.[minuteIdx] || 0); + expand({ + time: e.time, + slot: e.slot, + interval: true, + type: 'hero_healing_t', + value: heroHealingCumBySlot[e.slot], + }); } } // store player position for the first 10 minutes diff --git a/src/main/java/opendota/CreateParsedDataBlob.java b/src/main/java/opendota/CreateParsedDataBlob.java index ff58a2eb..ed6cbfd5 100644 --- a/src/main/java/opendota/CreateParsedDataBlob.java +++ b/src/main/java/opendota/CreateParsedDataBlob.java @@ -51,6 +51,9 @@ class PlayerData { public List lh_t = new ArrayList<>(); public List dn_t = new ArrayList<>(); public List xp_t = new ArrayList<>(); + public List camps_stacked_t = new ArrayList<>(); + public List hero_damage_t = new ArrayList<>(); + public List hero_healing_t = new ArrayList<>(); public List obs_log = new ArrayList<>(); public List sen_log = new ArrayList<>(); public List obs_left_log = new ArrayList<>(); @@ -142,6 +145,50 @@ public class CreateParsedDataBlob { private Gson g = new Gson(); + // Damage/healing dealt to real (non-illusion) heroes, bucketed by slot and + // minute of the event time, then emitted as cumulative hero_damage_t and + // hero_healing_t series on minute boundaries. Bucketed by event time in a + // 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> heroDamageMinuteBySlot = new HashMap<>(); + private Map> heroHealingMinuteBySlot = new HashMap<>(); + private Map heroDamageCumBySlot = new HashMap<>(); + private Map heroHealingCumBySlot = new HashMap<>(); + + // Events in (60*(k-1), 60*k] belong to bucket k so they are included in the + // sample taken at the minute boundary 60*k + private int minuteBucket(Integer time) { + return Math.max(0, (int) Math.ceil(time / 60.0)); + } + + private void precomputeMinuteSeries(List entries, Metadata meta) { + for (Entry e : entries) { + if (e.time == null || e.value == null) { + continue; + } + // matches the Valve scoreboard definitions: damage/healing dealt to + // real (non-illusion) heroes other than yourself; illusion attacker + // damage counts toward the owning hero + if ("DOTA_COMBATLOG_DAMAGE".equals(e.type) || "DOTA_COMBATLOG_HEAL".equals(e.type)) { + if (e.targethero == null || !e.targethero || + (e.targetillusion != null && e.targetillusion) || + e.targetname == null || e.targetname.equals(e.sourcename)) { + continue; + } + Integer sourceSlot = meta.hero_to_slot.get(e.sourcename); + if (sourceSlot == null) { + continue; + } + Map> target = "DOTA_COMBATLOG_DAMAGE".equals(e.type) + ? heroDamageMinuteBySlot + : heroHealingMinuteBySlot; + target.computeIfAbsent(sourceSlot, k -> new HashMap<>()) + .merge(minuteBucket(e.time), e.value, Integer::sum); + } + } + } + public ParsedData createParsedDataBlob(List entries) { long tStart = System.currentTimeMillis(); Metadata meta = processMetadata(entries); @@ -461,6 +508,7 @@ private List processExpand(List entries, Metadata meta) { List output = new ArrayList<>(); Integer aegisHolder = null; Integer aegisDeathTime = null; + precomputeMinuteSeries(entries, meta); for (Entry e : entries) { String type = e.type; @@ -1124,6 +1172,21 @@ private void handleInterval(Entry e, List output, Metadata meta) { addIntervalData(e, output, meta, "xp_t", e.xp); addIntervalData(e, output, meta, "lh_t", e.lh); addIntervalData(e, output, meta, "dn_t", e.denies); + if (e.camps_stacked != null) { + // not present in old replays; leave the array empty rather than filling with nulls + addIntervalData(e, output, meta, "camps_stacked_t", e.camps_stacked); + } + int minuteIdx = e.time / 60; + int dmgCum = heroDamageCumBySlot.getOrDefault(e.slot, 0) + + heroDamageMinuteBySlot.getOrDefault(e.slot, Collections.emptyMap()) + .getOrDefault(minuteIdx, 0); + heroDamageCumBySlot.put(e.slot, dmgCum); + addIntervalData(e, output, meta, "hero_damage_t", dmgCum); + int healCum = heroHealingCumBySlot.getOrDefault(e.slot, 0) + + heroHealingMinuteBySlot.getOrDefault(e.slot, Collections.emptyMap()) + .getOrDefault(minuteIdx, 0); + heroHealingCumBySlot.put(e.slot, healCum); + addIntervalData(e, output, meta, "hero_healing_t", healCum); } } @@ -1385,7 +1448,8 @@ private void handlePosDataTeamfight(Entry e, TeamfightPlayer player) { // Helper methods private boolean isArrayField(String type) { - return Arrays.asList("times", "gold_t", "lh_t", "dn_t", "xp_t", "obs_log", + return Arrays.asList("times", "gold_t", "lh_t", "dn_t", "xp_t", + "camps_stacked_t", "hero_damage_t", "hero_healing_t", "obs_log", "sen_log", "obs_left_log", "sen_left_log", "purchase_log", "kills_log", "buyback_log", "runes_log", "connection_log", "neutral_tokens_log", "neutral_item_history").contains(type); @@ -1424,6 +1488,12 @@ private List getPlayerIntegerList(PlayerData player, String type) { return player.dn_t; case "xp_t": return player.xp_t; + case "camps_stacked_t": + return player.camps_stacked_t; + case "hero_damage_t": + return player.hero_damage_t; + case "hero_healing_t": + return player.hero_healing_t; default: throw new RuntimeException("missing list type " + type); } From 936e5dea96397b6007ecbe2ffc6193c960d47c8e Mon Sep 17 00:00:00 2001 From: geracosta Date: Thu, 30 Jul 2026 14:28:17 -0300 Subject: [PATCH 2/2] Drop the .mjs changes, keep the diff to the live Java path The JS processors are reference-only since the switch to the Java implementation, so the new series no longer need to be mirrored there. --- processors/parseSchema.mjs | 3 -- processors/processExpand.mjs | 75 ------------------------------------ 2 files changed, 78 deletions(-) diff --git a/processors/parseSchema.mjs b/processors/parseSchema.mjs index 30ee8235..f5f6bafe 100644 --- a/processors/parseSchema.mjs +++ b/processors/parseSchema.mjs @@ -30,9 +30,6 @@ export default { lh_t: [], dn_t: [], xp_t: [], - camps_stacked_t: [], - hero_damage_t: [], - hero_healing_t: [], obs_log: [], sen_log: [], obs_left_log: [], diff --git a/processors/processExpand.mjs b/processors/processExpand.mjs index 77e9402f..4368b722 100644 --- a/processors/processExpand.mjs +++ b/processors/processExpand.mjs @@ -45,50 +45,6 @@ function processExpand(entries, meta) { let aegisHolder = null; // Used to ignore meepo clones killing themselves let aegisDeathTime = null; - // Damage/healing dealt to real (non-illusion) heroes, bucketed by slot and - // minute of the event time, then emitted as cumulative hero_damage_t and - // hero_healing_t series on minute boundaries. Bucketed by event time in a - // 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. - // Events in (60*(k-1), 60*k] belong to bucket k so they are included in the - // sample taken at the minute boundary 60*k - const minuteBucket = (time) => Math.max(0, Math.ceil(time / 60)); - const heroDamageMinuteBySlot = {}; - const heroHealingMinuteBySlot = {}; - const heroDamageCumBySlot = {}; - const heroHealingCumBySlot = {}; - entries.forEach((e) => { - if (e.time == null || e.value == null) { - return; - } - // matches the Valve scoreboard definitions: damage/healing dealt to - // real (non-illusion) heroes other than yourself; illusion attacker - // damage counts toward the owning hero - if (e.type === 'DOTA_COMBATLOG_DAMAGE' || e.type === 'DOTA_COMBATLOG_HEAL') { - if ( - !e.targethero || - e.targetillusion || - !e.targetname || - e.targetname === e.sourcename - ) { - return; - } - const sourceSlot = meta.hero_to_slot[e.sourcename]; - if (sourceSlot === undefined) { - return; - } - const target = - e.type === 'DOTA_COMBATLOG_DAMAGE' - ? heroDamageMinuteBySlot - : heroHealingMinuteBySlot; - if (!target[sourceSlot]) { - target[sourceSlot] = {}; - } - const bucket = minuteBucket(e.time); - target[sourceSlot][bucket] = (target[sourceSlot][bucket] || 0) + e.value; - } - }); const types = { DOTA_COMBATLOG_DAMAGE(e) { // damage @@ -643,37 +599,6 @@ function processExpand(entries, meta) { type: 'dn_t', value: e.denies, }); - if (e.camps_stacked != null) { - // not present in old replays; leave the array empty rather than filling with nulls - expand({ - time: e.time, - slot: e.slot, - interval: true, - type: 'camps_stacked_t', - value: e.camps_stacked, - }); - } - const minuteIdx = e.time / 60; - heroDamageCumBySlot[e.slot] = - (heroDamageCumBySlot[e.slot] || 0) + - (heroDamageMinuteBySlot[e.slot]?.[minuteIdx] || 0); - expand({ - time: e.time, - slot: e.slot, - interval: true, - type: 'hero_damage_t', - value: heroDamageCumBySlot[e.slot], - }); - heroHealingCumBySlot[e.slot] = - (heroHealingCumBySlot[e.slot] || 0) + - (heroHealingMinuteBySlot[e.slot]?.[minuteIdx] || 0); - expand({ - time: e.time, - slot: e.slot, - interval: true, - type: 'hero_healing_t', - value: heroHealingCumBySlot[e.slot], - }); } } // store player position for the first 10 minutes