How Fast Did Each Core Farm from 10 to 20 Minutes?
Question. Between 10:00 and 20:00, how much did each team's carry, mid and offlaner earn, how much of it came from the jungle, and where on the map were they?
The facts
player.total_earned_gold_t_minandplayer.lh_t_min: earned gold and last hits at each minute (index 10 is 10:00).match.combat_log: every neutral creep death, with its killer and in-game time.player.position_log: sampled(tick, x, y)positions, about one a second.gem.region_of(x, y):"radiant_half","dire_half","river","top_lotus"or"bottom_lotus"; see Map Regions and Camps.player.lane_role(1 safe lane, 2 mid, 3 off lane) andplayer.lane_last_hits(last hits at 10:00).
The cores are each team's safe-lane, mid and off-lane player with the most last hits at 10:00, the rule the HTML report's Farming tab uses. Earned gold counts every source (creeps, kills, passive income), not only farm; the neutral kills column shows how much of the farm came from the jungle.
The recipe
"""How fast did each core farm from minute 10 to 20, and where?
Uses the per-minute curves (``total_earned_gold_t_min``, ``lh_t_min``), the
combat log's neutral creep deaths, and the sampled positions labelled with
``gem.region_of``. The cores are each team's safe-lane, mid and off-lane player
with the most last hits at 10:00, the rule the HTML report's Farming tab uses.
python examples/cookbook/core_farm_10_to_20.py replay.dem [more.dem ...]
"""
from __future__ import annotations
import sys
from collections import Counter
import pandas as pd
import gem
ROLES = {1: "carry", 2: "mid", 3: "offlane"}
OWN_HALF = {2: "radiant_half", 3: "dire_half"}
START_MIN, END_MIN = 10, 20
COLUMNS = [
"match_id", "team", "role", "hero", "gold_per_min", "last_hits",
"neutral_kills", "own_half", "river", "enemy_half",
] # fmt: skip
def cores(match: gem.ParsedMatch) -> list[tuple[gem.ParsedPlayer, str]]:
"""Return each team's carry, mid and offlaner: the laner with the most last hits at 10:00."""
picked = []
for team in (2, 3):
for lane_role, role in ROLES.items():
laners = [p for p in match.players if p.team == team and p.lane_role == lane_role]
if laners:
picked.append((max(laners, key=lambda p: p.lane_last_hits), role))
return picked
def core_farm(match: gem.ParsedMatch) -> pd.DataFrame:
"""Return one row per core: gold, last hits, neutral kills and map time from 10:00 to 20:00.
Args:
match: A parsed match.
Returns:
A DataFrame, empty when the match is shorter than 20 minutes.
"""
clock = match.game_clock
start_s, end_s = START_MIN * 60, END_MIN * 60
# Pauses stop the game clock, not replay ticks, so convert the window once.
start_tick = clock.tick_at(start_s) if clock else None
end_tick = clock.tick_at(end_s) if clock else None
if start_tick is None or end_tick is None:
return pd.DataFrame(columns=COLUMNS)
rows = []
for player, role in cores(match):
gold, last_hits = player.total_earned_gold_t_min, player.lh_t_min
if len(gold) <= END_MIN or len(last_hits) <= END_MIN:
continue
hero = player.hero_name
neutral_kills = sum(
1
for entry in match.combat_log
if entry.log_type == "DEATH"
and entry.target_name.startswith("npc_dota_neutral")
and hero in (entry.attacker_name, entry.damage_source_name)
and entry.game_time_s is not None
and start_s <= entry.game_time_s < end_s
)
regions = Counter(
gem.region_of(x, y)
for tick, x, y in player.position_log
if start_tick <= tick < end_tick
)
samples = sum(regions.values()) or 1
own, enemy = OWN_HALF[player.team], OWN_HALF[5 - player.team]
rows.append(
{
"match_id": match.match_id,
"team": "radiant" if player.team == 2 else "dire",
"role": role,
"hero": hero.removeprefix("npc_dota_hero_"),
"gold_per_min": (gold[END_MIN] - gold[START_MIN]) / (END_MIN - START_MIN),
"last_hits": last_hits[END_MIN] - last_hits[START_MIN],
"neutral_kills": neutral_kills,
"own_half": regions[own] / samples,
"river": regions["river"] / samples,
"enemy_half": regions[enemy] / samples,
}
)
return pd.DataFrame(rows, columns=COLUMNS)
def main(paths: list[str]) -> None:
"""Print the table for each replay, then the averages by role."""
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(core_farm, matches) if not table.empty]
if not tables:
print("Nothing to report for these replays.")
return
table = pd.concat(tables, ignore_index=True)
with pd.option_context("display.float_format", "{:.2f}".format):
print(table.to_string(index=False))
print("\nAverage by role, 10:00 to 20:00:")
print(
table.groupby("role")[["gold_per_min", "last_hits", "neutral_kills", "enemy_half"]]
.mean()
.round(2)
.to_string()
)
if __name__ == "__main__": # parse_many's worker processes re-import this file
main(sys.argv[1:])
On one match
python examples/cookbook/core_farm_10_to_20.py 8974053011.dem:
team role hero gold_per_min last_hits neutral_kills own_half river enemy_half
radiant carry life_stealer 511.00 91 37 1.00 0.00 0.00
radiant mid ember_spirit 366.60 58 34 0.94 0.03 0.03
radiant offlane abyssal_underlord 540.50 105 38 0.92 0.00 0.06
dire carry razor 860.30 168 73 0.46 0.00 0.53
dire mid huskar 665.40 101 70 0.20 0.15 0.65
dire offlane dark_seer 599.80 118 76 0.30 0.05 0.64
Razor earned 860 gold a minute, with 168 last hits and 73 neutral kills, and spent half the window in Radiant's half. Every Dire core spent more time on Radiant's side than its own, while Radiant's three cores barely left theirs.
Across the fixtures
Averages over the 48 cores in the 8 fixtures that reach 20:00:
role gold_per_min last_hits neutral_kills own_half river enemy_half
carry 675.25 132.81 78.88 0.80 0.04 0.15
mid 546.36 87.69 38.19 0.63 0.10 0.27
offlane 580.08 101.50 52.31 0.50 0.04 0.45
What this does not say
- Why a core farmed where it did: map pressure, a plan or a lost lane all look the same here.
- Positions are samples, about one a second, not a continuous path.
- Lotus pools count towards neither half, so
own_half + river + enemy_halfcan be slightly under 1.
Variations
- Change
START_MINandEND_MIN, or compare windows side by side. - For camp-by-camp visits, use
gem.build_farming_routes(match); each segment has its camp, ticks, neutral kills and the gold and XP earned during the visit. - Swap
total_earned_gold_t_minfortotal_earned_xp_t_minto compare XP.