gem
v0.13.0

Where Were the Fights, and Who Got the Kills?

Question. Where did a match's fights happen, which side got more kills in each, and did that side also take the next building or Roshan?

10655443332222222

Figure 1. All 36 fights in match 8856501050, placed at the centre of their deaths, sized by deaths and coloured by the side with more kills. Filter by size, drag on the timeline to pick a stretch, and pick a fight for its card. Below the map, the picked fight opens in the home page's fight player: the activity log, a recap of each death and the damage timeline for every fight, and the map playback for fights with 3 or more deaths. It opens on the biggest fight, 42:34.

  • Radiant had more kills
  • Dire had more kills
  • Even
  • Size: deaths in the fight

The facts

  • match.fights: gem's fights (every size), with their deaths, the kills each side scored, the centre of their deaths, and each player's gold and XP near the fight.
  • match.towers, match.barracks and match.roshans: every building that fell and every Roshan kill. A building counts for the side that didn't own it, so a deny still loses it; Roshan counts for the team that killed it.
  • match.game_clock: in-game seconds, pauses excluded.
  • gem.region_of(x, y): the region of a fight's deaths.

"More kills" is just that: the side that scored more hero kills in the fight, or even when the counts match (Fight.winner). The window after a fight is WINDOW_S, 120 in-game seconds from the fight's end.

The recipe

"""Where were the fights, who got the kills, and what fell next?

Every fight in ``match.fights``: when it started and how long it lasted, its
deaths, the kills each side scored, where its deaths were (the centre of them,
as a ``gem.region_of`` region) and each side's net gold in it (gold earned near
the fight, less gold lost on death).
Then what fell next: the first building or Roshan within ``WINDOW_S`` in-game
seconds of the fight's end, and for which side. A building counts for the side
that didn't own it (a deny still loses it); Roshan for the team that killed it.

The summary asks how often the side with more kills in a fight also got the
next building or Roshan.

    python examples/cookbook/fights.py replay.dem [more.dem ...]
"""

from __future__ import annotations

import sys

import pandas as pd

import gem

WINDOW_S = 120  # how long after a fight the next building or Roshan still counts
TEAMS = {2: "radiant", 3: "dire"}
OTHER = {2: 3, 3: 2}
COLUMNS = [
    "match_id", "fight", "start", "start_s", "duration_s", "deaths", "radiant_kills",
    "dire_kills", "more_kills", "region", "radiant_gold", "dire_gold",
    "next", "next_for", "next_after_s",
]  # fmt: skip


def objectives(match: gem.ParsedMatch) -> pd.DataFrame:
    """Return every building that fell and every Roshan kill: when, what, and for which side."""
    clock = match.game_clock
    rows = []
    for tower in match.towers:
        rows.append((tower.tick, tower.tower_name, OTHER.get(tower.team)))
    for barracks in match.barracks:
        rows.append((barracks.tick, barracks.barracks_name, OTHER.get(barracks.team)))
    for roshan in match.roshans:
        rows.append((roshan.tick, "roshan", roshan.killer_team))
    table = pd.DataFrame(
        [
            {
                "time_s": clock.game_time_at(tick) if clock else None,
                "what": what.removeprefix("npc_dota_"),
                "for": TEAMS.get(team) if team is not None else None,
            }
            for tick, what, team in rows
        ],
        columns=["time_s", "what", "for"],
    )
    return table.sort_values("time_s", ignore_index=True)


def fight_table(match: gem.ParsedMatch) -> pd.DataFrame:
    """Return one row per fight: kills each side scored, where, the gold, and what fell next.

    ``more_kills`` is ``radiant``, ``dire`` or ``even``. ``radiant_gold`` and
    ``dire_gold`` are each side's net gold change near the fight
    (``FightPlayer.gold_delta``). ``region`` is ``None``
    when none of the fight's deaths had a position. ``next`` is the first
    building or Roshan within ``WINDOW_S`` in-game seconds after the fight
    ended, ``next_for`` the side it counted for, ``next_after_s`` how long after.

    Args:
        match: A parsed match.

    Returns:
        A DataFrame with one row per fight, in time order.
    """
    clock = match.game_clock
    if clock is None:
        return pd.DataFrame(columns=COLUMNS)
    team_of = {player.player_id: player.team for player in match.players}
    later = objectives(match)
    rows = []
    for number, fight in enumerate(sorted(match.fights, key=lambda f: f.start_tick), start=1):
        start_s = clock.game_time_at(fight.start_tick)
        end_s = clock.game_time_at(fight.end_tick)
        if start_s is None or end_s is None:
            continue
        gold = {2: 0, 3: 0}
        for player in fight.players:
            team = team_of.get(player.player_id)
            if team in gold:
                gold[team] += player.gold_delta
        r, d = fight.radiant_kills, fight.dire_kills
        after = later[(later["time_s"] >= end_s) & (later["time_s"] <= end_s + WINDOW_S)]
        first = after.iloc[0] if len(after) else None
        rows.append(
            {
                "match_id": match.match_id,
                "fight": number,
                "start": clock.format_tick(fight.start_tick),
                "start_s": start_s,
                "duration_s": end_s - start_s,
                "deaths": fight.deaths,
                "radiant_kills": r,
                "dire_kills": d,
                "more_kills": "radiant" if r > d else "dire" if d > r else "even",
                "region": (
                    gem.region_of(fight.centroid_x, fight.centroid_y)
                    if fight.centroid_x is not None and fight.centroid_y is not None
                    else None
                ),
                "radiant_gold": gold[2],
                "dire_gold": gold[3],
                "next": first["what"] if first is not None else None,
                "next_for": first["for"] if first is not None else None,
                "next_after_s": round(first["time_s"] - end_s) if first is not None else None,
            }
        )
    table = pd.DataFrame(rows, columns=COLUMNS)
    table["next_after_s"] = table["next_after_s"].astype("Int64")
    return table


def next_objective_summary(table: pd.DataFrame) -> dict[str, int]:
    """Count the fights one side won on kills, those followed by a building or Roshan, and those it got."""
    decided = table[table["more_kills"] != "even"]
    followed = decided[decided["next"].notna()]
    return {
        "fights": len(table),
        "more_kills": len(decided),
        "followed": len(followed),
        "same_side": int((followed["next_for"] == followed["more_kills"]).sum()),
    }


def main(paths: list[str]) -> None:
    """Print each replay's fights, then how often the side with more kills got what fell next."""
    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(fight_table, matches) if not table.empty]
    if not tables:
        print("No fights in these replays.")
        return
    table = pd.concat(tables, ignore_index=True)
    shown = ["match_id", "fight", "start", "deaths", "radiant_kills", "dire_kills", "more_kills"]
    print(table[[*shown, "region", "next", "next_for", "next_after_s"]].to_string(index=False))
    n = next_objective_summary(table)
    print(
        f"\n{n['more_kills']} of {n['fights']} fights had a side with more kills; "
        f"{n['followed']} of those had a building fall or Roshan killed within {WINDOW_S} s, "
        f"and the side with more kills got {n['same_side']} of them."
    )


if __name__ == "__main__":  # parse_many's worker processes re-import this file
    main(sys.argv[1:])

On one match

python examples/cookbook/fights.py 8856501050.dem, the first and last rows:

  match_id  fight start  deaths  radiant_kills  dire_kills more_kills       region                   next next_for  next_after_s
8856501050      1 01:27       1              0           1       dire    dire_half                   None     None          <NA>
8856501050      2 05:22       2              2           0    radiant    dire_half                   None     None          <NA>
8856501050      3 06:58       2              1           1       even radiant_half                   None     None          <NA>
...
8856501050     17 32:03       5              2           3       dire radiant_half                 roshan     dire            48
8856501050     19 42:34      10              4           6       dire radiant_half                   None     None          <NA>
8856501050     22 46:41       6              4           2    radiant radiant_half                 roshan     dire            14
...
8856501050     35 90:28       5              5           0    radiant radiant_half                 roshan  radiant            26
8856501050     36 92:23       4              3           1    radiant    dire_half                   None     None          <NA>

31 of 36 fights had a side with more kills; 19 of those had a building fall or Roshan killed within 120 s, and the side with more kills got 11 of them.

Dire had more kills in 22 of the 36 fights and Radiant in 9. Fight 22 shows why "more kills" is only half the story: Radiant scored 4 of its 6 deaths, and Dire killed Roshan 14 seconds after it ended.

Across the fixtures

Over the 331 fights in the 9 fixture replays:

  • 303 had a side with more kills;
  • 161 of those were followed within 2 minutes by a building falling or a Roshan kill;
  • the side with more kills got 117 of those 161.

The bigger the fight, the more often it went that way: the side with more kills got what fell next after 85 of 123 one-death fights, and after 32 of 38 fights with two or more deaths.

What this does not say

  • Whether the fight won the building. A tower can fall to a push that had nothing to do with the fight before it; the 2-minute window is a choice.
  • Who won the fight in any other sense. Gold, experience, buybacks and Aegis lives are on each fight (FightPlayer), not in "more kills".
  • OpenDota's teamfights. gem's fights include one-death skirmishes; OpenDota's teamfight definition is opendota_teamfights.

Variations

  • Change WINDOW_S to widen or narrow what counts as "next".
  • Filter fight_table to deaths >= 3 to ask the question of teamfights only.
  • Use gem.build_fight_timeline(match, start, end) for any fight's casts, damage and deaths, the facts behind the player above.