diff --git a/src/main/java/opendota/CreateParsedDataBlob.java b/src/main/java/opendota/CreateParsedDataBlob.java index 147cf683..195e730a 100644 --- a/src/main/java/opendota/CreateParsedDataBlob.java +++ b/src/main/java/opendota/CreateParsedDataBlob.java @@ -57,6 +57,7 @@ class PlayerData { public List sen_left_log = new ArrayList<>(); public List> purchase_log = new ArrayList<>(); public List> kills_log = new ArrayList<>(); + public List> deaths_log = new ArrayList<>(); public List buyback_log = new ArrayList<>(); public List> runes_log = new ArrayList<>(); public List connection_log = new ArrayList<>(); @@ -310,7 +311,7 @@ private void handleArrayField(Entry e, PlayerData player) { } else if (e.interval != null && e.interval) { List targetList = getPlayerIntegerList(player, e.type); targetList.add(e.value); - } else if (Arrays.asList("purchase_log", "kills_log", "runes_log", "neutral_tokens_log").contains(e.type)) { + } else if (Arrays.asList("purchase_log", "kills_log", "deaths_log", "runes_log", "neutral_tokens_log").contains(e.type)) { Map obj = new HashMap<>(); obj.put("time", e.time); obj.put("key", e.key); @@ -327,10 +328,19 @@ private void handleArrayField(Entry e, PlayerData player) { obj.put("tracked_sourcename", e.tracked_sourcename); } + if ("deaths_log".equals(e.type)) { + obj.put("gold_lost", e.gold_lost); + if (e.time_dead != null) { + obj.put("time_dead", e.time_dead); + } + } + if ("purchase_log".equals(e.type)) { player.purchase_log.add(obj); } else if ("kills_log".equals(e.type)) { player.kills_log.add(obj); + } else if ("deaths_log".equals(e.type)) { + player.deaths_log.add(obj); } else if ("runes_log".equals(e.type)) { player.runes_log.add(obj); } else if ("neutral_tokens_log".equals(e.type)) { @@ -465,19 +475,24 @@ private String computeIllusionString(String input, Boolean isIllusion) { // window. WK's ult does apply a modifier while reincarnating, at the death // itself. Both are collected in a pre-scan keyed by event time because the // chat, modifier and death entries around a reincarnation are not strictly - // ordered in the stream. + // ordered in the stream. The same pass collects the deaths_log lookups: + // gold lost on death (a separate GOLD record, also not ordered against the + // death entry) and the dead stretches, which double as time_dead. private static final String WK_HERO = "npc_dota_hero_skeleton_king"; private static final String WK_REINCARNATION_MODIFIER = "modifier_skeleton_king_reincarnation"; private static final int AEGIS_EXPIRY_SECONDS = 300; // Reincarnation revives in ~3s; the shortest real respawn is far longer, // so a WK back on his feet this fast without a buyback reincarnated private static final int WK_REVIVE_MAX_SECONDS = 4; + // EDOTA_ModifyGold_Reason: 1 = hero death (the negative bucket in gold_reasons) + private static final int GOLD_REASON_DEATH = 1; // aegis pickups: slot -> list of {pickupTime, used} private Map> aegisPickupsBySlot = new HashMap<>(); private Map> reincarnationTimesBySlot = new HashMap<>(); private Map> deadWindowsBySlot = new HashMap<>(); private Map> buybackTimesBySlot = new HashMap<>(); + private Map> deathGoldBySlot = new HashMap<>(); private Integer wkSlot = null; private void precomputeReincarnations(List entries, Metadata meta) { @@ -500,6 +515,13 @@ private void precomputeReincarnations(List entries, Metadata meta) { } } else if ("DOTA_COMBATLOG_BUYBACK".equals(e.type) && e.value != null) { buybackTimesBySlot.computeIfAbsent(e.value, k -> new ArrayList<>()).add(e.time); + } else if ("DOTA_COMBATLOG_GOLD".equals(e.type) && e.gold_reason != null + && e.gold_reason == GOLD_REASON_DEATH && e.targetname != null && e.value != null) { + Integer slot = meta.hero_to_slot.get(e.targetname); + if (slot != null) { + deathGoldBySlot.computeIfAbsent(slot, k -> new HashMap<>()) + .merge(e.time, Math.abs(e.value), Integer::sum); + } } else if ("interval".equals(e.type) && e.slot != null && e.life_state != null) { Integer prev = lastState.get(e.slot); if ((prev == null || prev == 0) && e.life_state != 0) { @@ -514,6 +536,8 @@ private void precomputeReincarnations(List entries, Metadata meta) { lastState.put(e.slot, e.life_state); } } + // deaths still open when the replay ends stay out of deadWindowsBySlot: + // their length is unknown, so those deaths get no time_dead } private boolean consumeReincarnationDeath(Integer slot, Integer time) { @@ -571,6 +595,34 @@ private boolean hasBuybackBetween(Integer slot, int from, int to) { return false; } + private Integer lookupDeathGold(Integer slot, Integer time) { + Map byTime = slot == null ? null : deathGoldBySlot.get(slot); + if (byTime == null || time == null) { + return 0; + } + for (int dt : new int[] { 0, 1, -1, 2, -2 }) { + Integer v = byTime.remove(time + dt); + if (v != null) { + return v; + } + } + return 0; + } + + private Integer lookupTimeDead(Integer slot, Integer time) { + List windows = slot == null ? null : deadWindowsBySlot.get(slot); + if (windows == null || time == null) { + return null; + } + for (int[] w : windows) { + // the life_state flip shows up on the tick after the death event + if (w[0] >= time - 1 && w[0] <= time + 3) { + return w[1] - w[0]; + } + } + return null; + } + private List processExpand(List entries, Metadata meta) { List output = new ArrayList<>(); precomputeReincarnations(entries, meta); @@ -792,6 +844,22 @@ private void handleDeathCombat(Entry e, List output, Metadata meta) { return; } + if (e.targethero != null && e.targethero && + (e.targetillusion == null || !e.targetillusion)) { + // deaths_log includes self-kills (e.g. Techies suicide): the death is + // real (it counts on the scoreboard and loses gold) even though no + // kill is credited below + Entry deathLog = new Entry(); + deathLog.time = e.time; + deathLog.unit = key; + deathLog.key = unit; + deathLog.type = "deaths_log"; + Integer victimSlot = meta.hero_to_slot.get(key); + deathLog.gold_lost = lookupDeathGold(victimSlot, e.time); + deathLog.time_dead = lookupTimeDead(victimSlot, e.time); + expand(deathLog, output, meta); + } + if (e.attackername != null && e.attackername.equals(key)) { return; } @@ -1478,7 +1546,7 @@ private void handlePosDataTeamfight(Entry e, TeamfightPlayer player) { private boolean isArrayField(String type) { return Arrays.asList("times", "gold_t", "lh_t", "dn_t", "xp_t", "obs_log", "sen_log", "obs_left_log", "sen_left_log", "purchase_log", "kills_log", - "buyback_log", "runes_log", "connection_log", "neutral_tokens_log", + "deaths_log", "buyback_log", "runes_log", "connection_log", "neutral_tokens_log", "neutral_item_history").contains(type); } diff --git a/src/main/java/opendota/Entry.java b/src/main/java/opendota/Entry.java index 7fcf3a42..8ad45021 100644 --- a/src/main/java/opendota/Entry.java +++ b/src/main/java/opendota/Entry.java @@ -87,6 +87,9 @@ public class Entry implements Cloneable { public Boolean interval; public String event; public Integer killer; + // deaths_log fields + public Integer gold_lost; + public Integer time_dead; public Entry() { }