gem
v0.13.0

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_min and player.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) and player.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_half can be slightly under 1.

Variations

  • Change START_MIN and END_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_min for total_earned_xp_t_min to compare XP.