Who Took Each Rune, and What Happened While a Power Rune Lasted?
Question. Where did each rune spawn, and who took it, put it in a Bottle, denied it, or left it? Who took the four bounties at 0:00, and who was near? And while each power rune lasted, did its taker get a kill, hit Roshan or a building, or did its team take an objective?
RadiantDire
- 7Power runes27
- 0Water runes1
- 11Bounty, own jungle21
- 3Bounty, other jungle12
- 1Bounty, river1
- 4Denied1
84 runes taken, 6 of them a second or third bounty one hero took at once.
The 0:00 bounties
- Shadow Fiend in Dire's own jungleno enemy within 1,200
- Tiny in Radiant's own jungleno enemy within 1,200
- Treant Protector in the top riverno enemy within 1,200
- Lion in the bottom riverno enemy within 1,200
No hero died before 1:30.
Power runes, and what happened while each lasted
- Illusion 19 s
Sniperno kill, no damage to Roshan or buildings, no objective in the 30 s after - Shield 75 s
Treant Protectorno kill, no damage to Roshan or buildings, no objective in the 30 s after - Invisibility 10 s, from a Bottle
Ember Spiritno kill, no damage to Roshan or buildings, no objective in the 30 s after - Arcane 50 s
Timbersawno kill, no damage to Roshan or buildings, no objective in the 30 s after - Haste 22 s
Treant Protectorno kill, no damage to Roshan or buildings, no objective in the 30 s after - Regeneration 19 s, from a Bottle
Ember Spiritthe team took Roshan (+6 s) - Illusion 28 s
Lionno kill, no damage to Roshan or buildings, no objective in the 30 s after - Arcane 50 s, from a Bottle
Ember Spiritthe team took Radiant tier 2 top tower (+63 s) - Regeneration 11 s, from a Bottle
Ember Spiritno kill, no damage to Roshan or buildings, no objective in the 30 s after - Haste 22 s, from a Bottle
Ember Spiritno kill, no damage to Roshan or buildings, no objective in the 30 s after - Shield 75 s, from a Bottle
Ember Spiritno kill, no damage to Roshan or buildings, no objective in the 30 s after - Invisibility 3 s, from a Bottle
Ember Spiritno kill, no damage to Roshan or buildings, no objective in the 30 s after - Double Damage 45 s, from a Bottle
Ember Spiritkilled Ancient Apparition, Timbersaw, Lion - Illusion 75 s, from a Bottle
Ember Spiritthe team took Radiant tier 2 middle tower (+22 s) - Haste 22 s, from a Bottle
Ember Spiritno kill, no damage to Roshan or buildings, no objective in the 30 s after - Shield 75 s, from a Bottle
Ember Spiritno kill, no damage to Roshan or buildings, no objective in the 30 s after - Double Damage 14 s, from a Bottle
Ember Spiritthe taker died, which ended it - Arcane 50 s, from a Bottle
Ember Spiritno kill, no damage to Roshan or buildings, no objective in the 30 s after - Invisibility 22 s
Ember Spiritno kill, no damage to Roshan or buildings, no objective in the 30 s after - Illusion 75 s
Shadow Fiend79 damage to buildings - Double Damage 45 s, from a Bottle
Ember Spiritno kill, no damage to Roshan or buildings, no objective in the 30 s after - Arcane 50 s
Ember Spiritkilled Tiny; the team took Radiant tier 3 top tower (+30 s), Radiant top melee barracks (+79 s) - Shield 75 s, from a Bottle
Ember Spiritthe team took Radiant top ranged barracks (+13 s), Radiant tier 3 middle tower (+19 s), Radiant middle melee barracks (+26 s), Radiant middle ranged barracks (+28 s) - Illusion 75 s
Ember Spiritkilled Ancient Apparition - Double Damage 39 s
Shadow Fiendkilled Sniper; the taker died, which ended it - Arcane 50 s
Timbersawno kill, no damage to Roshan or buildings, no objective in the 30 s after - Haste 22 s
Ember Spiritthe team took Radiant tier 3 bottom tower (+48 s) - Regeneration 7 s
Shadow Fiendno kill, no damage to Roshan or buildings, no objective in the 30 s after - Shield 75 s
Pangolierno kill, no damage to Roshan or buildings, no objective in the 30 s after - Double Damage 45 s, from a Bottle
Shadow Fiendkilled Sniper - Illusion 38 s
Tinyno kill, no damage to Roshan or buildings, no objective in the 30 s after - Haste 22 s
Sniperno kill, no damage to Roshan or buildings, no objective in the 30 s after - Arcane 50 s
Ember Spiritno kill, no damage to Roshan or buildings, no objective in the 30 s after - Haste 22 s
Timbersawno kill, no damage to Roshan or buildings, no objective in the 30 s after
Who took the power runes
- Ember Spirit20
- Shadow Fiend4
- Timbersaw3
- Sniper2
- Treant Protector2
- Lion1
- Pangolier1
- Tiny1
Figure 1. The 98 runes of match 8856501050: each side's by kind, the four spots with the runes each side took there, the 0:00 bounties, and every power rune with what happened while it lasted. Pick a stage, or drag on the timeline for a stretch of your own, to see the runes taken in it (the 0:00 bounties stay). A highlighted power rune was followed by an objective for its team.
- Radiant
- Dire
The facts
match.runes: every power, bounty and water rune that spawned, from its entity in the replay: when and where it appeared, and how it ended (picked_up,bottled,denied,not_taken,still_there), with the player and, for a bottled rune, when the Bottle was used.match.combat_log:PICKUP_RUNEentries: each rune taken and each Bottle used, timed by OpenDota's clock for its chat event (at most half a second from the game clock). A rune bottled, denied or not taken has no such entry and is timed by the game clock when it left the map;- each power rune's buff,
modifier_rune_*added and removed, and an illusion rune's illusions (modifier_illusion); - hero deaths, and damage to Roshan and to buildings.
match.towers,match.barracks,match.roshans,match.tormentors: the objectives.- Hero positions (about one a second) and
gem.region_offor the spots.
Spots and stages
Each rune is at one of four spots: the top or bottom river (power and water runes, and two of the four bounties at 0:00), or a side's jungle (bounties). A bounty taken from the other side's jungle counts as such. A bounty nobody took stays on the map and stacks with the next one, so one hero can take two or three at once: each is its own rune.
The recipe splits the match into three stages: 0:00 to 6:00 (the lanes recipe's laning stage, with the water runes), 6:00 to 20:00, and 20:00 to the end. A rune counts in the stage it left the map in.
A rune put in a Bottle counts as taken when it was bottled. Using the Bottle
later is a second PICKUP_RUNE entry, which OpenDota also counts as a pickup;
the recipe ties it back to the rune.
The 0:00 bounties
Four bounties spawn at the horn. For each, the recipe lists who took it, how
many seconds after the horn, whose it was (the river, the taker's own jungle,
or the other side's), and the enemy heroes within NEAR (1,200 world units)
of the taker at the pickup, from the heroes' sampled positions (about one a
second). It also lists every hero death up to 1:30,
counting the ones before the horn.
While a power rune lasted
A power rune's buff starts when it is taken, or when it is used from a Bottle. The combat log adds its modifier then and removes it when the buff ends, or earlier when the hero dies. The recipe takes that window as it is. A buff can run longer than its usual length, and the combat log's own duration agrees: in 8821954344 a shield buff that began at 42:08 lasted 99.75 s.
An illusion rune has no buff. Its two illusions appear a few ticks after the
pickup, and the window runs until both are gone, at most ILLUSION_S (75 s;
5 of the 28 on the fixtures ran the full 75).
For each window, the recipe lists:
- the taker's kills;
- the taker's damage to Roshan and to buildings;
- the objectives its team took during the window or within
AFTER_S(30 s) after it.
A summon's or an illusion's kill or damage counts for its hero, the combat log's damage source. An objective after a power rune is what followed it, not what it caused.
The recipe
"""Who took each rune, and what happened while a power rune lasted?
Every power, bounty and water rune that spawned (``match.runes``): its spot,
who took it and when, or how else it ended (put in a Bottle, denied, not taken,
or still there when the game ended). A rune's spot is the top or bottom river,
or a side's jungle (``gem.region_of``); a bounty taken from the other side's
jungle says so. Several bounties taken together are several runes: untaken
bounties stay on the map and stack.
The match is split into stages: 0:00 to ``LANING_S`` (the lanes recipe's
laning stage, with the water runes), to ``MID_S`` and to the end.
- **The 0:00 bounties** (spawned at the horn): who took each and how many
seconds after the horn, whose rune it was, the enemy heroes within ``NEAR``
world units at the pickup, and every hero death up to ``OPENING_S``, from
before the horn.
- **While a power rune lasted:** each power rune's buff, taken or used from a
Bottle, runs from its ``modifier_rune_*`` add to its remove in the combat log
(it ends early when the hero dies). An illusion rune has no buff: its two
illusions run from their ``modifier_illusion`` adds to their removes, at most
``ILLUSION_S``. For each: the taker's kills, the taker's damage to Roshan and
to buildings (its summons' and illusions' count, as the combat log's damage
source), and the objectives its team took during it or within ``AFTER_S``
after. That is what followed, not why.
Times are in-game seconds (pauses excluded). A pickup, or a Bottle's use, keeps
its own ``PICKUP_RUNE`` entry's time (OpenDota's clock for its chat event, at
most half a second from the game clock); a rune bottled, denied or not taken has
no such entry and is timed by the game clock when it left the map.
python examples/cookbook/runes.py replay.dem [more.dem ...]
"""
from __future__ import annotations
import math
import re
import sys
from collections import defaultdict
import pandas as pd
import gem
from gem.combat.log import CombatLogEntry
LANING_S = 360 # the first stage ends here: the lanes recipe's laning stage
MID_S = 1200 # and the second here
NEAR = 1200 # world units: an enemy hero this close to a 0:00 bounty's pickup is listed
OPENING_S = 90 # the 0:00 bounties' deaths: from before the horn up to 1:30
OPENING_SPAWN_S = 60 # a bounty that spawned before this is a 0:00 bounty
AFTER_S = 30 # objectives this long after a power rune ended still count for it
ILLUSION_S = 75 # an illusion rune's illusions last this long at most
ILLUSION_TICKS = 6 # the illusions appear within this many ticks of the pickup
ELAPSED_TOLERANCE_S = 0.5 # a remove's elapsed duration dates its add to within this
BUFF_TICKS = 2 # a buff starts within this many ticks of the pickup
PICKUP_TICKS = 2 # a PICKUP_RUNE entry is within this many ticks of its rune's removal
TEAMS = {2: "radiant", 3: "dire"}
OTHER = {2: 3, 3: 2}
STAGES = (("0:00-6:00", 0, LANING_S), ("6:00-20:00", LANING_S, MID_S), ("20:00-end", MID_S, None))
RUNES = {
0: "double_damage", 1: "haste", 2: "illusion", 3: "invisibility", 4: "regeneration",
5: "bounty", 6: "arcane", 7: "water", 9: "shield",
} # fmt: skip
BUFFS = {
0: "modifier_rune_doubledamage", 1: "modifier_rune_haste", 3: "modifier_rune_invis",
4: "modifier_rune_regen", 6: "modifier_rune_arcane", 9: "modifier_rune_shield",
} # fmt: skip
ILLUSION, BOUNTY, WATER = 2, 5, 7
BUILDING = re.compile(r"npc_dota_(goodguys|badguys)_(tower\d|melee_rax|range_rax|fort)")
RUNE_COLUMNS = [
"match_id", "rune", "kind", "spawn_s", "spot", "outcome", "hero", "side", "whose",
"time_s", "used_s", "stage", "x", "y",
] # fmt: skip
OPENING_COLUMNS = [
"match_id", "spot", "hero", "side", "whose", "after_horn_s", "enemies_near",
] # fmt: skip
DEATH_COLUMNS = ["match_id", "time_s", "hero", "side", "by"]
WINDOW_COLUMNS = [
"match_id", "rune", "hero", "side", "from_bottle", "start_s", "end_s", "died", "kills",
"killed", "roshan_damage", "building_damage", "took", "took_s",
] # fmt: skip
SUMMARY_COLUMNS = [
"match_id", "stage", "side", "power", "water", "bounty_own_jungle", "bounty_other_jungle",
"bounty_river", "denied",
] # fmt: skip
def spot_of(x: float, y: float) -> str:
"""The rune spot a world position is at: ``top_river``, ``bot_river``, ``radiant_jungle`` or ``dire_jungle``."""
region = gem.region_of(x, y)
if region in ("radiant_half", "dire_half"):
return region.replace("half", "jungle")
if region == "top_lotus":
return "top_river"
if region == "bottom_lotus":
return "bot_river"
# The river runs from the top left to the bottom right: above its middle line is the top.
return "top_river" if y > x else "bot_river"
def stage_of(time_s: float) -> str:
"""The stage a game second falls in (``STAGES``)."""
return next(name for name, start, end in STAGES if end is None or time_s < end)
def _pickup_times(match: gem.ParsedMatch) -> dict[tuple[int, int, int], float]:
"""Each pickup's own time, keyed by (player slot, rune type, the rune's removal or Bottle-use tick).
A rune taken or a Bottle used has a ``PICKUP_RUNE`` entry within
``PICKUP_TICKS``. Several bounties taken together have several entries, so
each player's pickups of a type and its entries are paired in tick order.
"""
entries: dict[tuple[int, int], list[CombatLogEntry]] = defaultdict(list)
for entry in match.combat_log:
if entry.log_type == "PICKUP_RUNE" and entry.rune_type in RUNES:
entries[(entry.value, entry.rune_type)].append(entry)
pickups: dict[tuple[int, int], list[int]] = defaultdict(list)
for rune in match.runes:
if rune.player_id is None:
continue
if rune.outcome == "picked_up" and rune.end_tick is not None:
pickups[(rune.player_id, rune.rune_type)].append(rune.end_tick)
if rune.used_tick is not None:
pickups[(rune.player_id, rune.rune_type)].append(rune.used_tick)
times = {}
for key, ticks in pickups.items():
remaining = sorted(entries.get(key, []), key=lambda e: e.tick)
for tick in sorted(ticks):
found = next((e for e in remaining if abs(e.tick - tick) <= PICKUP_TICKS), None)
if found is not None and found.game_time_s is not None:
remaining.remove(found)
times[(*key, tick)] = float(found.game_time_s)
return times
def rune_table(match: gem.ParsedMatch) -> pd.DataFrame:
"""Return every rune that spawned: where, who took it and when, or how else it ended.
``kind`` is ``power``, ``bounty`` or ``water``; ``outcome`` is the rune's
own (``picked_up``, ``bottled``, ``denied``, ``not_taken``, ``still_there``).
``whose`` is a bounty's: ``river``, ``own`` (the taker's side's jungle) or
``other``. ``time_s`` is when it left the map, ``used_s`` when a bottled
rune was used, and ``stage`` the stage of ``time_s`` (of the spawn for a
rune still there).
Args:
match: A parsed match.
Returns:
One row per rune, in spawn order, with ``RUNE_COLUMNS``.
"""
clock = match.game_clock
if clock is None:
return pd.DataFrame(columns=RUNE_COLUMNS)
heroes = {p.player_id: p for p in match.players}
pickup = _pickup_times(match)
rows = []
for rune in match.runes:
if rune.rune_type not in RUNES or rune.x is None or rune.y is None:
continue
spawn_s = clock.game_time_at(rune.spawn_tick)
player = heroes.get(rune.player_id) if rune.player_id is not None else None
side = TEAMS.get(player.team) if player is not None else None
time_s = used_s = None
if rune.end_tick is not None:
key = (rune.player_id or 0, rune.rune_type, rune.end_tick)
time_s = pickup.get(key) if rune.outcome == "picked_up" else None
time_s = time_s if time_s is not None else clock.game_time_at(rune.end_tick)
if rune.used_tick is not None:
key = (rune.player_id or 0, rune.rune_type, rune.used_tick)
used_s = pickup.get(key, clock.game_time_at(rune.used_tick))
spot = spot_of(rune.x, rune.y)
whose = None
if rune.rune_type == BOUNTY and side is not None:
whose = (
"river" if spot.endswith("river") else "own" if spot.startswith(side) else "other"
)
when = time_s if time_s is not None else spawn_s
rows.append(
{
"match_id": match.match_id,
"rune": RUNES[rune.rune_type],
"kind": "bounty"
if rune.rune_type == BOUNTY
else "water"
if rune.rune_type == WATER
else "power",
"spawn_s": spawn_s,
"spot": spot,
"outcome": rune.outcome,
"hero": player.hero_name if player is not None else None,
"side": side,
"whose": whose,
"time_s": time_s,
"used_s": used_s,
"stage": stage_of(max(when, 0.0)) if when is not None else None,
"x": rune.x,
"y": rune.y,
}
)
return pd.DataFrame(rows, columns=RUNE_COLUMNS)
def opening_bounties(match: gem.ParsedMatch, runes: pd.DataFrame | None = None) -> pd.DataFrame:
"""Return the bounties that spawned at the horn: who took each, how soon, and who was near.
``after_horn_s`` is the pickup's game second (the horn is 0); ``whose`` is
``river``, ``own`` or ``other``; ``enemies_near`` the enemy heroes within
``NEAR`` world units of the taker at the pickup (sampled positions, about
one a second).
Args:
match: A parsed match.
runes: ``rune_table(match)``, if already built.
Returns:
One row per 0:00 bounty, in pickup order, with ``OPENING_COLUMNS``.
"""
runes = rune_table(match) if runes is None else runes
clock = match.game_clock
first = runes[
(runes["rune"] == "bounty")
& (runes["spawn_s"] < OPENING_SPAWN_S)
& runes["outcome"].isin(["picked_up", "bottled"])
]
by_hero = {p.hero_name: p for p in match.players}
rows = []
for row in first.sort_values("time_s").itertuples(index=False):
taker = by_hero[row.hero]
tick = clock.tick_at(row.time_s) if clock is not None else None
here = gem.position_at_tick(taker, tick) if tick is not None else None
near = []
for other in match.players:
if other.team == taker.team or here is None or tick is None:
continue
there = gem.position_at_tick(other, tick)
if there is not None and math.dist(here, there) <= NEAR:
near.append(other.hero_name)
rows.append(
{
"match_id": match.match_id,
"spot": row.spot,
"hero": row.hero,
"side": row.side,
"whose": row.whose,
"after_horn_s": row.time_s,
"enemies_near": ", ".join(near),
}
)
return pd.DataFrame(rows, columns=OPENING_COLUMNS)
def opening_deaths(match: gem.ParsedMatch) -> pd.DataFrame:
"""Return every hero death up to ``OPENING_S``, from before the horn.
``by`` is the killer (a summon's kill counts for its owner, the combat log's
damage source).
Args:
match: A parsed match.
Returns:
One row per death, in time order, with ``DEATH_COLUMNS``.
"""
clock = match.game_clock
heroes = {p.hero_name: p for p in match.players}
rows = []
for entry in match.combat_log:
if (
entry.log_type != "DEATH"
or entry.target_name not in heroes
or entry.target_is_illusion
or entry.will_reincarnate # an Aegis or Reincarnation trigger, not a death
):
continue
time_s = clock.game_time_at(entry.tick) if clock is not None else None
if time_s is None or time_s > OPENING_S:
continue
rows.append(
{
"match_id": match.match_id,
"time_s": time_s,
"hero": entry.target_name,
"side": TEAMS.get(heroes[entry.target_name].team),
"by": entry.damage_source_name or entry.attacker_name,
}
)
return pd.DataFrame(rows, columns=DEATH_COLUMNS)
def _window(
match: gem.ParsedMatch, modifiers: list, rune_type: int, hero: str, tick: int
) -> tuple[int, int] | None:
"""A power rune's window in replay ticks, from its buff (or its illusions), or ``None``.
``modifiers`` are the match's rune-buff and illusion modifier entries.
"""
clock = match.game_clock
if rune_type in BUFFS:
name = BUFFS[rune_type]
start = next(
(
e.tick
for e in modifiers
if e.log_type == "MODIFIER_ADD"
and e.inflictor_name == name
and e.target_name == hero
and not e.target_is_illusion
and abs(e.tick - tick) <= BUFF_TICKS
),
None,
)
if start is None:
return None
end = next(
(
e.tick
for e in modifiers
if e.log_type == "MODIFIER_REMOVE"
and e.inflictor_name == name
and e.target_name == hero
and not e.target_is_illusion
and e.tick >= start
),
match.post_game_tick or match.game_end_tick or start,
)
return start, end
adds = [
e.tick
for e in modifiers
if e.log_type == "MODIFIER_ADD"
and e.inflictor_name == "modifier_illusion"
and e.target_name == hero
and 0 <= e.tick - tick <= ILLUSION_TICKS
]
if not adds or clock is None:
return None
start_s = clock.game_time_at(adds[0])
last = clock.tick_at(start_s + ILLUSION_S) if start_s is not None else None
later = [
e
for e in modifiers
if e.log_type == "MODIFIER_REMOVE"
and e.inflictor_name == "modifier_illusion"
and e.target_name == hero
and e.tick >= adds[0]
]
# The hero may have other illusions (Manta Style, an ability): a remove is
# one of the rune's when its elapsed duration dates its add to theirs.
def theirs(e: CombatLogEntry) -> bool:
if e.modifier_elapsed_duration_s is None or start_s is None:
return True # no elapsed duration (Source 1 replays): the first removes
removed_s = clock.game_time_at(e.tick)
return (
removed_s is not None
and abs(removed_s - e.modifier_elapsed_duration_s - start_s) <= ELAPSED_TOLERANCE_S
)
removes = sorted(e.tick for e in later if theirs(e))[: len(adds)]
end = max(removes) if len(removes) == len(adds) else None
if last is not None and (end is None or end > last):
end = last
return (adds[0], end) if end is not None else None
def rune_windows(match: gem.ParsedMatch) -> pd.DataFrame:
"""Return each power rune's window and what happened in it.
One row per power rune taken or used from a Bottle (``from_bottle``):
``start_s`` and ``end_s`` (in-game seconds), whether the taker ``died`` in
it, the taker's ``kills`` (and who: ``killed``), its damage to Roshan and to
buildings, and the objectives its team took (``took``, ``took_s`` the
seconds after ``start_s``) from ``start_s`` to ``end_s + AFTER_S``. A
summon's or illusion's kill and damage count for its hero (the combat log's
damage source).
Args:
match: A parsed match.
Returns:
One row per window, in time order, with ``WINDOW_COLUMNS``.
"""
clock = match.game_clock
if clock is None:
return pd.DataFrame(columns=WINDOW_COLUMNS)
heroes = {p.player_id: p for p in match.players}
team_of = {p.hero_name: p.team for p in match.players}
def seconds(tick: int) -> float:
now = clock.game_time_at(tick)
return now if now is not None else tick / 30
objectives = [(seconds(t.tick), OTHER.get(t.team), t.tower_name) for t in match.towers]
objectives += [(seconds(b.tick), OTHER.get(b.team), b.barracks_name) for b in match.barracks]
slot_team = {p.player_id: p.team for p in match.players}
# The protocol's team; the killer's own when it's missing (Source 1 replays).
objectives += [
(seconds(r.tick), r.killer_team or team_of.get(r.killer), "roshan") for r in match.roshans
]
objectives += [
(seconds(t.tick), t.killer_team or slot_team.get(t.killer_player_id), "tormentor")
for t in match.tormentors
]
deaths = [
e
for e in match.combat_log
if e.log_type == "DEATH"
and e.target_name in team_of
and not e.target_is_illusion
and not e.will_reincarnate # an Aegis or Reincarnation trigger, not a death
]
damage = [
e
for e in match.combat_log
if e.log_type == "DAMAGE"
and e.value
and (e.target_name == "npc_dota_roshan" or BUILDING.match(e.target_name))
]
watched = {*BUFFS.values(), "modifier_illusion"}
modifiers = [
e
for e in match.combat_log
if e.log_type in ("MODIFIER_ADD", "MODIFIER_REMOVE") and e.inflictor_name in watched
]
uses = [(r, r.end_tick, False) for r in match.runes if r.outcome == "picked_up"]
uses += [(r, r.used_tick, True) for r in match.runes if r.used_tick is not None]
rows = []
for rune, tick, from_bottle in uses:
if tick is None or rune.player_id not in heroes:
continue
if rune.rune_type not in BUFFS and rune.rune_type != ILLUSION:
continue
player = heroes[rune.player_id]
hero, team = player.hero_name, player.team
window = _window(match, modifiers, rune.rune_type, hero, tick)
if window is None:
continue
start, end = window
start_s, end_s = seconds(start), seconds(end)
def mine(
entry: CombatLogEntry, hero: str = hero, start: int = start, end: int = end
) -> bool:
return (
start <= entry.tick <= end
and (entry.damage_source_name or entry.attacker_name) == hero
)
killed = [e.target_name for e in deaths if mine(e) and team_of[e.target_name] != team]
took = [
(round(when - start_s), name.removeprefix("npc_dota_"))
for when, side, name in sorted(objectives)
if side == team and start_s <= when <= end_s + AFTER_S
]
rows.append(
{
"match_id": match.match_id,
"rune": RUNES[rune.rune_type],
"hero": hero,
"side": TEAMS.get(team),
"from_bottle": from_bottle,
"start_s": start_s,
"end_s": end_s,
"died": any(start <= e.tick <= end and e.target_name == hero for e in deaths),
"kills": len(killed),
"killed": ", ".join(killed),
"roshan_damage": sum(
e.value for e in damage if mine(e) and e.target_name == "npc_dota_roshan"
),
"building_damage": sum(
e.value for e in damage if mine(e) and e.target_name != "npc_dota_roshan"
),
"took": ", ".join(name for _, name in took),
"took_s": ", ".join(str(after) for after, _ in took),
}
)
frame = pd.DataFrame(rows, columns=WINDOW_COLUMNS)
return frame.sort_values("start_s", ignore_index=True)
def rune_summary(runes: pd.DataFrame) -> pd.DataFrame:
"""Return each side's runes by stage and kind: taken (or bottled), and denied.
Args:
runes: ``rune_table(...)`` for one match or many.
Returns:
One row per match, stage and side, with ``SUMMARY_COLUMNS``.
"""
taken = runes[runes["outcome"].isin(["picked_up", "bottled"])]
rows = []
for match_id in runes["match_id"].unique():
for stage, _, _ in STAGES:
for side in TEAMS.values():
mine = taken[
(taken["match_id"] == match_id)
& (taken["stage"] == stage)
& (taken["side"] == side)
]
bounty = mine[mine["kind"] == "bounty"]
denied = runes[
(runes["match_id"] == match_id)
& (runes["stage"] == stage)
& (runes["side"] == side)
& (runes["outcome"] == "denied")
]
rows.append(
{
"match_id": match_id,
"stage": stage,
"side": side,
"power": int((mine["kind"] == "power").sum()),
"water": int((mine["kind"] == "water").sum()),
"bounty_own_jungle": int((bounty["whose"] == "own").sum()),
"bounty_other_jungle": int((bounty["whose"] == "other").sum()),
"bounty_river": int((bounty["whose"] == "river").sum()),
"denied": len(denied),
}
)
return pd.DataFrame(rows, columns=SUMMARY_COLUMNS)
def _clock(time_s: float) -> str:
sign = "-" if time_s < 0 else ""
whole = abs(int(time_s))
return f"{sign}{whole // 60:d}:{whole % 60:02d}"
def main(paths: list[str]) -> None:
"""Print each replay's runes by side and stage, its 0:00 bounties, and its power runes' windows."""
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, windows = [], []
for match in matches:
runes = rune_table(match)
tables.append(runes)
print(f"\n{match.match_id}: runes taken by each side, by stage")
summary = rune_summary(runes).drop(columns=["match_id"])
print(summary.to_string(index=False))
print("\nThe 0:00 bounties:")
print(opening_bounties(match, runes).drop(columns=["match_id"]).to_string(index=False))
deaths = opening_deaths(match)
print(f"Hero deaths up to {_clock(OPENING_S)}: {len(deaths)}")
found = rune_windows(match)
windows.append(found)
shown = found.assign(
start=found["start_s"].map(_clock),
lasted_s=(found["end_s"] - found["start_s"]).round(),
objectives=found["took"].map(lambda took: len(took.split(", ")) if took else 0),
)
print("\nPower runes, and what happened while each lasted:")
columns = ["start", "rune", "hero", "from_bottle", "lasted_s", "kills", "objectives"]
print(shown[columns].to_string(index=False))
print(f"\nObjectives the taker's team took during one or within {AFTER_S} s after:")
for row in shown[shown["took"] != ""].itertuples(index=False):
print(f" {row.start} {row.rune}, {row.hero}: {row.took}")
every = pd.concat(windows, ignore_index=True)
with_objective = every[every["took"] != ""]
print(
f"\n{len(with_objective)} of {len(every)} power runes were followed by an objective for the"
f" taker's team during them or within {AFTER_S} s after."
)
if __name__ == "__main__": # parse_many's worker processes re-import this file
main(sys.argv[1:])
On one match
python examples/cookbook/runes.py 8856501050.dem, with the power runes'
first and last rows:
8856501050: runes taken by each side, by stage
stage side power water bounty_own_jungle bounty_other_jungle bounty_river denied
0:00-6:00 radiant 0 0 1 0 1 3
0:00-6:00 dire 0 1 1 1 1 0
6:00-20:00 radiant 2 0 3 0 0 1
6:00-20:00 dire 4 0 4 0 0 0
20:00-end radiant 5 0 7 3 0 0
20:00-end dire 23 0 16 11 0 1
The 0:00 bounties:
spot hero side whose after_horn_s enemies_near
dire_jungle npc_dota_hero_nevermore dire own 0.0
radiant_jungle npc_dota_hero_tiny radiant own 0.0
top_river npc_dota_hero_treant dire river 0.0
bot_river npc_dota_hero_lion radiant river 1.0
Hero deaths up to 1:30: 0
Power runes, and what happened while each lasted:
start rune hero from_bottle lasted_s kills objectives
6:10 illusion npc_dota_hero_sniper False 19.0 0 0
8:00 shield npc_dota_hero_treant False 75.0 0 0
10:56 invisibility npc_dota_hero_ember_spirit True 10.0 0 0
...
86:31 arcane npc_dota_hero_ember_spirit False 50.0 0 0
90:22 haste npc_dota_hero_shredder False 22.0 0 0
Objectives the taker's team took during one or within 30 s after:
19:24 regeneration, npc_dota_hero_ember_spirit: roshan
23:14 arcane, npc_dota_hero_ember_spirit: goodguys_tower2_top
35:11 illusion, npc_dota_hero_ember_spirit: goodguys_tower2_mid
56:13 arcane, npc_dota_hero_ember_spirit: goodguys_tower3_top, goodguys_melee_rax_top
60:56 shield, npc_dota_hero_ember_spirit: goodguys_range_rax_top, goodguys_tower3_mid, goodguys_melee_rax_mid, goodguys_range_rax_mid
70:33 haste, npc_dota_hero_ember_spirit: goodguys_tower3_bot
6 of 34 power runes were followed by an objective for the taker's team during them or within 30 s after.
Dire took 27 of the 34 power runes, 23 of them after 20:00. Ember Spirit had 20 of the 34 windows, 15 of them from a Bottle. Dire also took 12 bounties from Radiant's jungle; Radiant took 3 from Dire's. All four 0:00 bounties were taken within a second of the horn, each by its own side or in the river, with no enemy within 1,200 units, and no hero died before 1:30.
Across the fixtures
The 9 fixture replays have 514 runes:
- Taken or bottled: 478 (253 bounties, 193 power runes, 32 water runes). Of the power runes, 131 were taken after 20:00, and none before 6:00.
- Bounties: 145 taken from the taker's own jungle, 90 from the other side's, and 18 in the river at 0:00.
- Denied: 5. Not taken (gone at the next spawn): 15, all power or water runes. Still there when the game ended: 16, 11 of them bounties.
- The 0:00 bounties: all 36 were taken within 13 s of the horn, 6 from the other side's jungle. 7 had an enemy hero within 1,200 units at the pickup. Only one replay had a death before 1:30, two heroes, both before the horn.
Of the 192 power-rune windows (106 of them from a Bottle), 26 had a kill by the taker, and 51 were followed by an objective for the team during the window or within 30 s after:
| Rune | Windows | With a kill | With an objective |
|---|---|---|---|
| Arcane | 31 | 6 | 9 |
| Haste | 29 | 5 | 6 |
| Double Damage | 28 | 7 | 8 |
| Illusion | 28 | 1 | 7 |
| Shield | 27 | 4 | 12 |
| Regeneration | 26 | 3 | 4 |
| Invisibility | 23 | 0 | 5 |
What this does not say
- Whether a rune decided anything. An objective after a power rune came next; the team may have been taking it anyway.
- Who fought over a rune. The pickup's taker is known, and who was near at 0:00; who walked to a rune and left without it is not in this recipe.
- Wisdom runes. They have no entity in the replay, and they are in the objectives recipe.
Variations
- Change
NEARto ask who was close to the 0:00 bounties, orAFTER_Sto widen what follows a power rune. - Filter
rune_windowstodouble_damageand join thekilledheroes on the fights they died in. - Group
rune_tablebyheroandruneto ask who uses a Bottle for which runes.