How Often Did a Smoke Lead to a Kill?
Question. After a team uses Smoke of Deceit, how often does it get a kill, how quickly, and how often does it lose a hero instead?
The facts
gem.build_smoke_analysis(match): each smoke's activation tick, team and members, and itsfirst_fight, the first fight whose first death came within 60 in-game seconds.match.combat_log: every hero death, with the killing and dying teams and the in-game time.match.game_clock: in-game seconds from the smoke, pauses excluded.
A kill is a hero death that is not an illusion and that the hero did not come
back from (will_reincarnate covers the Aegis and Reincarnation). The window is
the 60 seconds after the smoke; change WINDOW_S to widen it.
The recipe
"""How often did a smoke lead to a kill, and how fast?
For each Smoke of Deceit (``gem.build_smoke_analysis``), finds the first enemy
hero the smoking team killed after the smoke, the first hero it lost, and the
smoke's first fight. Kills come straight from the combat log; a death the hero
came back from (Aegis, Reincarnation) is not a kill.
python examples/cookbook/smoke_to_kill.py replay.dem [more.dem ...]
"""
from __future__ import annotations
import sys
import pandas as pd
import gem
WINDOW_S = 60 # how long after the smoke a kill still counts
COLUMNS = [
"match_id", "smoke", "time", "team", "heroes",
"first_kill_after_s", "first_loss_after_s", "first_fight", "fight_winner",
] # fmt: skip
def hero_deaths(match: gem.ParsedMatch) -> pd.DataFrame:
"""Return every real hero death: its game time and the killing and dying teams."""
# Older (Source 1) combat logs have no team fields; fall back to the heroes' teams.
# A summon's kill names its owner as the damage source.
hero_team = {player.hero_name: player.team for player in match.players}
return pd.DataFrame(
[
{
"game_time_s": entry.game_time_s,
"killer_team": entry.attacker_team
or hero_team.get(entry.damage_source_name)
or hero_team.get(entry.attacker_name),
"victim_team": entry.target_team or hero_team.get(entry.target_name),
}
for entry in match.combat_log
if entry.log_type == "DEATH"
and entry.target_is_hero
and not entry.target_is_illusion
and not entry.will_reincarnate
and entry.game_time_s is not None
],
columns=["game_time_s", "killer_team", "victim_team"],
)
def smoke_to_kill(match: gem.ParsedMatch) -> pd.DataFrame:
"""Return one row per smoke: its first kill, first loss and first fight within the window.
Args:
match: A parsed match.
Returns:
A DataFrame; the ``*_after_s`` columns are ``None`` when nothing happened
within ``WINDOW_S`` in-game seconds of the smoke.
"""
clock = match.game_clock
if clock is None:
return pd.DataFrame(columns=COLUMNS)
deaths = hero_deaths(match)
fight_numbers = {id(fight): n for n, fight in enumerate(match.fights, start=1)}
def first_after(start_s: float, rows: pd.DataFrame) -> float | None:
later = rows[(rows["game_time_s"] >= start_s) & (rows["game_time_s"] <= start_s + WINDOW_S)]
return round(float(later["game_time_s"].min() - start_s)) if len(later) else None
rows = []
for number, smoke in enumerate(gem.build_smoke_analysis(match), start=1):
start_s = clock.game_time_at(smoke.activation_tick)
if start_s is None or smoke.team not in (2, 3):
continue
fight = smoke.first_fight
rows.append(
{
"match_id": match.match_id,
"smoke": number,
"time": clock.format_tick(smoke.activation_tick),
"team": "radiant" if smoke.team == 2 else "dire",
"heroes": len(smoke.members),
"first_kill_after_s": first_after(
start_s, deaths[deaths["killer_team"] == smoke.team]
),
"first_loss_after_s": first_after(
start_s, deaths[deaths["victim_team"] == smoke.team]
),
"first_fight": fight_numbers.get(id(fight)) if fight else None,
"fight_winner": fight.winner if fight else None,
}
)
table = pd.DataFrame(rows, columns=COLUMNS)
for column in ("first_kill_after_s", "first_loss_after_s", "first_fight"):
table[column] = table[column].astype("Int64")
return table
def main(paths: list[str]) -> None:
"""Print the table for each replay, then how often a smoke led to a kill."""
if len(paths) == 1:
matches = [gem.parse(paths[0])]
else:
matches = [result.match for result in gem.parse_many(paths) if result.match is not None]
tables = [table for table in map(smoke_to_kill, matches) if not table.empty]
if not tables:
print("Nothing to report for these replays.")
return
table = pd.concat(tables, ignore_index=True)
print(table.to_string(index=False))
kills = table["first_kill_after_s"].dropna()
print(
f"\n{len(kills)} of {len(table)} smokes got a kill within {WINDOW_S} s "
f"(median {kills.median():.0f} s); "
f"{table['first_loss_after_s'].notna().sum()} lost a hero within {WINDOW_S} s."
)
if __name__ == "__main__": # parse_many's worker processes re-import this file
main(sys.argv[1:])
On one match
python examples/cookbook/smoke_to_kill.py 8974053011.dem:
smoke time team heroes first_kill_after_s first_loss_after_s first_fight fight_winner
1 -00:58 dire 5 <NA> <NA> <NA> None
2 -00:44 radiant 3 <NA> <NA> <NA> None
3 13:11 radiant 2 <NA> 35 13 dire
4 17:55 radiant 2 <NA> 23 17 dire
5 19:56 dire 2 9 <NA> 20 dire
6 23:49 dire 1 <NA> <NA> <NA> None
7 32:09 radiant 2 <NA> <NA> <NA> None
Only one of the seven smokes got a kill: Dire's at 19:56, nine seconds in. Both of Radiant's mid-game two-hero smokes lost a hero within 35 seconds.
Across the fixtures
Over the 137 smokes in the 9 fixture replays:
- 74 got a kill within 60 s, a median of 26 s after the smoke;
- 47 lost a hero within 60 s;
- in 35 smokes the team both got a kill and lost a hero.
What this does not say
- Whether the smoke caused the kill. A kill 50 seconds later may have nothing to do with it.
- Who was seen. The smoke breaking early, and whether the enemy saw the
members, are on each smoke's
members(Smoke Analysis). - Pre-horn smokes. Smokes at negative times are often used to walk to a rune or a lane and seldom lead to kills.
Variations
- Count only smokes with at least two heroes:
table[table["heroes"] >= 2]. - Compare teams:
table.groupby("team")["first_kill_after_s"].describe(). - To know which smoked heroes were in the fight, see each
FightPlayerinsmoke.first_fight.players.