Source code for awpy.analytics.stats

"""Functions to calculate statistics for a player or team from a demofile.

Example::

    from awpy.parser import DemoParser
    from awpy.analytics.stats import player_stats

    # Create the parser object.
    parser = DemoParser(
        demofile = "astralis-vs-liquid-m2-nuke.dem",
        demo_id = "AST-TL-BLAST2019-nuke",
        parse_frames=False,
    )

    # Parse the demofile, output results to a dictionary of dataframes.
    data = parser.parse()
    player_stats_json = player_stats(data["gameRounds"])
    player_stats_json[76561197999004010]

https://github.com/pnxenopoulos/awpy/blob/main/examples/01_Basic_CSGO_Analysis.ipynb
"""
from typing import Any, Literal, cast, overload

import pandas as pd

from awpy.types import (
    KAST,
    BombAction,
    DamageAction,
    FlashAction,
    GameRound,
    GrenadeAction,
    KillAction,
    Players,
    PlayerStatistics,
    RoundStatistics,
    WeaponFireAction,
    int_to_string_n_players,
    is_valid_side,
    lower_side,
    other_side,
    proper_player_number,
)

# accuracy
# kast
# adr
# kill stats
# flash stats
# econ stats


[docs]def initialize_round( cur_round: GameRound, player_statistics: dict[str, PlayerStatistics], active_sides: set[Literal["CT", "T"]], ) -> RoundStatistics: """Initialize players and statistics for the given round. Args: cur_round (GameRound): Current CSGO round to initialize for. player_statistics (dict[str, PlayerStatistics]): Dict storing player statistics for a given match. active_sides (set[Literal["CT", "T"]]): Set of the currently active sides. Returns: dict[str, KAST]: Initialized KAST dict for each player. dict[str, int]: Number of kills in the round for each player. set[str]]: Players to track for the given round. """ kast: dict[str, KAST] = {} round_kills: dict[str, int] = {} active_players: set[str] = set() for side in [lower_side(team) + "Side" for team in active_sides]: side = cast(Literal["ctSide", "tSide"], side) for player in cur_round[side]["players"] or []: player_key = ( player["playerName"] if player["steamID"] == 0 else str(player["steamID"]) ) active_players.add(player_key) if player_key not in player_statistics: player_statistics[player_key] = { "steamID": player["steamID"], "playerName": player["playerName"], "teamName": cur_round[side]["teamName"], "isBot": player["steamID"] == 0, "totalRounds": 0, "kills": 0, "deaths": 0, "kdr": 0, "assists": 0, "tradeKills": 0, "tradedDeaths": 0, "teamKills": 0, "suicides": 0, "flashAssists": 0, "totalDamageGiven": 0, "totalDamageTaken": 0, "totalTeamDamageGiven": 0, "adr": 0, "totalShots": 0, "shotsHit": 0, "accuracy": 0, "rating": 0, "kast": 0, "hs": 0, "hsPercent": 0, "firstKills": 0, "firstDeaths": 0, "utilityDamage": 0, "smokesThrown": 0, "flashesThrown": 0, "heThrown": 0, "fireThrown": 0, "enemiesFlashed": 0, "teammatesFlashed": 0, "blindTime": 0, "plants": 0, "defuses": 0, "kills0": 0, "kills1": 0, "kills2": 0, "kills3": 0, "kills4": 0, "kills5": 0, "attempts1v1": 0, "success1v1": 0, "attempts1v2": 0, "success1v2": 0, "attempts1v3": 0, "success1v3": 0, "attempts1v4": 0, "success1v4": 0, "attempts1v5": 0, "success1v5": 0, } player_statistics[player_key]["totalRounds"] += 1 for player_key in active_players: kast[player_key] = {"k": False, "a": False, "s": True, "t": False} round_kills[player_key] = 0 # Calculate kills players_killed: dict[Literal["CT", "T"], set[str]] = { "T": set(), "CT": set(), } is_clutching: set[str | None] = set() round_statistics: RoundStatistics = { "kast": kast, "round_kills": round_kills, "is_clutching": is_clutching, "active_players": active_players, "players_killed": players_killed, } return round_statistics
def _finalize_statistics(player_statistics: dict[str, PlayerStatistics]) -> None: """Finalize player statistics. Round some statistics and calculate relative and per round based statistics. Args: player_statistics (dict[str, PlayerStatistics]): Dictionary of player statistics """ for player in player_statistics.values(): player["kast"] = round( 100 * player["kast"] / player["totalRounds"], 1, ) player["blindTime"] = round(player["blindTime"], 2) player["kdr"] = round( player["kills"] / player["deaths"] if player["deaths"] != 0 else player["kills"], 2, ) player["adr"] = round( player["totalDamageGiven"] / player["totalRounds"], 1, ) player["accuracy"] = round( player["shotsHit"] / player["totalShots"] if player["totalShots"] != 0 else 0, 2, ) player["hsPercent"] = round( player["hs"] / player["kills"] if player["kills"] != 0 else 0, 2, ) impact = ( 2.13 * (player["kills"] / player["totalRounds"]) + 0.42 * (player["assists"] / player["totalRounds"]) - 0.41 ) player["rating"] = ( 0.0073 * player["kast"] + 0.3591 * (player["kills"] / player["totalRounds"]) - 0.5329 * (player["deaths"] / player["totalRounds"]) + 0.2372 * (impact) + 0.0032 * (player["adr"]) + 0.1587 ) player["rating"] = round(player["rating"], 2) @overload def _get_actor_key(actor: Literal["thrower"], game_action: GrenadeAction) -> str: ... @overload def _get_actor_key( actor: Literal["player"], game_action: BombAction | WeaponFireAction ) -> str: ... @overload def _get_actor_key( actor: Literal["attacker", "victim"], game_action: DamageAction ) -> str: ... @overload def _get_actor_key( actor: Literal["attacker", "victim", "assister", "flashThrower", "playerTraded"], game_action: KillAction, ) -> str: ... @overload def _get_actor_key( actor: Literal["attacker", "player"], game_action: FlashAction ) -> str: ... def _get_actor_key( actor: Any, game_action: Any, ) -> str: actor_name = actor + "Name" actor_steamid = actor + "SteamID" if (actor_name) not in game_action or (actor_steamid) not in game_action: msg = ( f"{actor} is not a valid actor for game_action of type {type(game_action)}." ) raise KeyError(msg) return ( str(game_action[actor_name]) if game_action[actor_steamid] == 0 else str(game_action[actor_steamid]) ) def _handle_pure_killer_stats( killer_key: str, player_statistics: dict[str, PlayerStatistics], round_statistics: RoundStatistics, kill_action: KillAction, ) -> None: # Purely attacker related stats if ( killer_key in round_statistics["active_players"] and kill_action["attackerSteamID"] ): if not kill_action["isSuicide"] and not kill_action["isTeamkill"]: player_statistics[killer_key]["kills"] += 1 round_statistics["round_kills"][killer_key] += 1 round_statistics["kast"][killer_key]["k"] = True if kill_action["isTeamkill"]: player_statistics[killer_key]["teamKills"] += 1 if kill_action["isHeadshot"]: player_statistics[killer_key]["hs"] += 1 def _is_clutch( victim_side: Literal["CT", "T"], game_round: GameRound, round_statistics: RoundStatistics, ) -> bool: total_players_victim_side = game_round[lower_side(victim_side) + "Side"]["players"] if total_players_victim_side is None: return False # This gets messed up when a player disconnects (dies) in freeze time but # reconnects in time to play the round. # Then this triggers at only 3 "real" deaths. player_killed_victim_side = len(round_statistics["players_killed"][victim_side]) return len(total_players_victim_side) - player_killed_victim_side == 1 def _find_clutcher( victim_side_players: list[Players], victim_side: Literal["CT", "T"], round_statistics: RoundStatistics, ) -> str | None: for player in victim_side_players: clutcher_key = ( str(player["playerName"]) if player["steamID"] == 0 else str(player["steamID"]) ) if ( clutcher_key not in round_statistics["players_killed"][victim_side] and clutcher_key not in round_statistics["is_clutching"] and clutcher_key in round_statistics["active_players"] ): return clutcher_key return None def _handle_clutching( kill_action: KillAction, game_round: GameRound, round_statistics: RoundStatistics, player_statistics: dict[str, PlayerStatistics], ) -> None: # Clutch logic victim_side = kill_action["victimSide"] if victim_side is None or not is_valid_side(victim_side): return if not _is_clutch(victim_side, game_round, round_statistics): return lower_victim_side = lower_side(victim_side) + "Side" victim_side_players = game_round[lower_victim_side]["players"] if victim_side_players is None: return clutcher_key = _find_clutcher(victim_side_players, victim_side, round_statistics) if clutcher_key is None: return round_statistics["is_clutching"].add(clutcher_key) swapped_side = other_side(victim_side) lower_swapped_side = lower_side(swapped_side) enemy_players = game_round[lower_swapped_side + "Side"]["players"] if enemy_players is None: return enemies_alive = len(enemy_players) - len( round_statistics["players_killed"][swapped_side] ) # Typeguard and not 1 v 0 if not proper_player_number(enemies_alive) or enemies_alive == 0: return player_statistics[clutcher_key][ "attempts1v" + int_to_string_n_players(enemies_alive) ] += 1 if game_round["winningSide"] == kill_action["victimSide"]: player_statistics[clutcher_key][ "success1v" + int_to_string_n_players(enemies_alive) ] += 1 def _handle_pure_victim_stats( victim_key: str, player_statistics: dict[str, PlayerStatistics], round_statistics: RoundStatistics, kill_action: KillAction, game_round: GameRound, ) -> None: # Purely victim related stats: if victim_key in round_statistics["active_players"]: player_statistics[victim_key]["deaths"] += 1 round_statistics["kast"][victim_key]["s"] = False if kill_action["isSuicide"]: player_statistics[victim_key]["suicides"] += 1 _handle_clutching(kill_action, game_round, round_statistics, player_statistics) def _handle_trade_stats( killer_key: str, player_statistics: dict[str, PlayerStatistics], round_statistics: RoundStatistics, kill_action: KillAction, ) -> None: if kill_action["isTrade"]: # A trade is always onto an enemy # If your teammate kills someone and then you kill them # -> that is not a trade kill for you # If you kill someone and then yourself # -> that is not a trade kill for you if ( kill_action["attackerSide"] != kill_action["victimSide"] and killer_key in round_statistics["active_players"] and kill_action["attackerSteamID"] ): player_statistics[killer_key]["tradeKills"] += 1 # Enemies CAN trade your own death # If you force an enemy to teamkill their mate after your death # -> thats a traded death for you # If you force your killer to kill themselves # (in their own molo/nade/fall) # -> that is a traded death for you traded_key = _get_actor_key("playerTraded", kill_action) # In most cases the traded player is on the same team as the trader # However in the above scenarios the opposite can be the case # So it is not enough to know that the trading player and # their side is initialized if ( traded_key in round_statistics["active_players"] and kill_action["playerTradedSteamID"] ): round_statistics["kast"][traded_key]["t"] = True player_statistics[traded_key]["tradedDeaths"] += 1 def _handle_assists( assister_key: str, flashthrower_key: str, player_statistics: dict[str, PlayerStatistics], round_statistics: RoundStatistics, kill_action: KillAction, ) -> None: if ( kill_action["assisterSteamID"] and kill_action["assisterSide"] != kill_action["victimSide"] and assister_key in round_statistics["active_players"] ): player_statistics[assister_key]["assists"] += 1 round_statistics["kast"][assister_key]["a"] = True if ( kill_action["flashThrowerSteamID"] and kill_action["flashThrowerSide"] != kill_action["victimSide"] and flashthrower_key in round_statistics["active_players"] ): player_statistics[flashthrower_key]["flashAssists"] += 1 round_statistics["kast"][flashthrower_key]["a"] = True def _handle_first_kill( killer_key: str, victim_key: str, player_statistics: dict[str, PlayerStatistics], round_statistics: RoundStatistics, kill_action: KillAction, ) -> None: if kill_action["isFirstKill"] and kill_action["attackerSteamID"]: if killer_key in round_statistics["active_players"]: player_statistics[killer_key]["firstKills"] += 1 if victim_key in round_statistics["active_players"]: player_statistics[victim_key]["firstDeaths"] += 1 def _handle_kills( game_round: GameRound, player_statistics: dict[str, PlayerStatistics], round_statistics: RoundStatistics, ) -> None: for k in game_round["kills"] or []: killer_key = _get_actor_key("attacker", k) victim_key = _get_actor_key("victim", k) assister_key = _get_actor_key("assister", k) flashthrower_key = _get_actor_key("flashThrower", k) victim_side = k["victimSide"] if victim_side is None or not is_valid_side(victim_side): return if victim_side in round_statistics["players_killed"]: round_statistics["players_killed"][victim_side].add(victim_key) _handle_pure_killer_stats( killer_key, player_statistics, round_statistics, kill_action=k ) _handle_pure_victim_stats( victim_key, player_statistics, round_statistics, kill_action=k, game_round=game_round, ) _handle_trade_stats( killer_key, player_statistics, round_statistics, kill_action=k ) _handle_assists( assister_key, flashthrower_key, player_statistics, round_statistics, kill_action=k, ) _handle_first_kill( killer_key, victim_key, player_statistics, round_statistics, kill_action=k ) def _handle_damages( game_round: GameRound, player_statistics: dict[str, PlayerStatistics], round_statistics: RoundStatistics, ) -> None: for damage_action in game_round["damages"] or []: attacker_key = _get_actor_key("attacker", damage_action) victim_key = _get_actor_key("victim", damage_action) # Purely attacker related stats if ( attacker_key in round_statistics["active_players"] and damage_action["attackerSteamID"] ): if not damage_action["isFriendlyFire"]: player_statistics[attacker_key]["totalDamageGiven"] += damage_action[ "hpDamageTaken" ] else: # damage_action["isFriendlyFire"]: player_statistics[attacker_key][ "totalTeamDamageGiven" ] += damage_action["hpDamageTaken"] if damage_action["weaponClass"] not in ["Unknown", "Grenade", "Equipment"]: player_statistics[attacker_key]["shotsHit"] += 1 if damage_action["weaponClass"] == "Grenade": player_statistics[attacker_key]["utilityDamage"] += damage_action[ "hpDamageTaken" ] if ( damage_action["victimSteamID"] and victim_key in round_statistics["active_players"] ): player_statistics[victim_key]["totalDamageTaken"] += damage_action[ "hpDamageTaken" ] def _handle_weapon_fires( game_round: GameRound, player_statistics: dict[str, PlayerStatistics], round_statistics: RoundStatistics, ) -> None: for weapon_fire in game_round["weaponFires"] or []: fire_key = _get_actor_key("player", weapon_fire) if fire_key in round_statistics["active_players"]: player_statistics[fire_key]["totalShots"] += 1 def _handle_flashes( game_round: GameRound, player_statistics: dict[str, PlayerStatistics], round_statistics: RoundStatistics, ) -> None: for flash_action in game_round["flashes"] or []: flasher_key = _get_actor_key("attacker", flash_action) if ( flash_action["attackerSteamID"] and flasher_key in round_statistics["active_players"] ): if flash_action["attackerSide"] == flash_action["playerSide"]: player_statistics[flasher_key]["teammatesFlashed"] += 1 else: player_statistics[flasher_key]["enemiesFlashed"] += 1 player_statistics[flasher_key]["blindTime"] += ( 0 if flash_action["flashDuration"] is None else flash_action["flashDuration"] ) def _handle_grenades( game_round: GameRound, player_statistics: dict[str, PlayerStatistics], round_statistics: RoundStatistics, ) -> None: for grenade_action in game_round["grenades"] or []: thrower_key = _get_actor_key("thrower", grenade_action) if ( grenade_action["throwerSteamID"] and thrower_key in round_statistics["active_players"] ): if grenade_action["grenadeType"] == "Smoke Grenade": player_statistics[thrower_key]["smokesThrown"] += 1 if grenade_action["grenadeType"] == "Flashbang": player_statistics[thrower_key]["flashesThrown"] += 1 if grenade_action["grenadeType"] == "HE Grenade": player_statistics[thrower_key]["heThrown"] += 1 if grenade_action["grenadeType"] in ["Incendiary Grenade", "Molotov"]: player_statistics[thrower_key]["fireThrown"] += 1 def _handle_bomb( game_round: GameRound, player_statistics: dict[str, PlayerStatistics], round_statistics: RoundStatistics, ) -> None: for bomb_event in game_round["bombEvents"] or []: player_key = ( bomb_event["playerName"] if bomb_event["playerSteamID"] == 0 else str(bomb_event["playerSteamID"]) ) if ( bomb_event["playerSteamID"] and player_key in round_statistics["active_players"] ): if bomb_event["bombAction"] == "plant": player_statistics[player_key]["plants"] += 1 if bomb_event["bombAction"] == "defuse": player_statistics[player_key]["defuses"] += 1 def _handle_kast( player_statistics: dict[str, PlayerStatistics], round_statistics: RoundStatistics, ) -> None: for player, components in round_statistics["kast"].items(): if player in round_statistics["active_players"] and any(components.values()): player_statistics[player]["kast"] += 1 def _handle_multi_kills( player_statistics: dict[str, PlayerStatistics], round_statistics: RoundStatistics, ) -> None: for player, n_kills in round_statistics["round_kills"].items(): if player in round_statistics["active_players"]: _increment_statistic(player_statistics, player, n_kills) def _increment_statistic( player_statistics: dict[str, PlayerStatistics], player: str, n_kills: int ) -> None: if not proper_player_number(n_kills): # 0, 1, 2, 3, 4, 5 return kills_string = "kills" + int_to_string_n_players(n_kills) player_statistics[player][kills_string] += 1
[docs]def player_stats( game_rounds: list[GameRound], return_type: str = "json", selected_side: str = "all" ) -> dict[str, PlayerStatistics] | pd.DataFrame: """Generate a stats summary for a list of game rounds as produced by the DemoParser. Args: game_rounds (list[GameRound]): List of game rounds as produced by the DemoParser return_type (str, optional): Return format ("json" or "df"). Defaults to "json". selected_side (str, optional): Which side(s) to consider. Defaults to "all". Other options are "CT" and "T" Returns: Union[dict[str, PlayerStatistics],pd.Dataframe]: Dictionary or Dataframe containing player information """ player_statistics: dict[str, PlayerStatistics] = {} selected_side = selected_side.upper() if selected_side in {"CT", "T"}: selected_side = cast(Literal["CT", "T"], selected_side) active_sides: set[Literal["CT", "T"]] = {selected_side} else: active_sides = {"CT", "T"} for game_round in game_rounds: # Add players round_statistics = initialize_round(game_round, player_statistics, active_sides) _handle_kills(game_round, player_statistics, round_statistics) _handle_damages(game_round, player_statistics, round_statistics) _handle_weapon_fires(game_round, player_statistics, round_statistics) _handle_flashes(game_round, player_statistics, round_statistics) _handle_grenades(game_round, player_statistics, round_statistics) _handle_bomb(game_round, player_statistics, round_statistics) _handle_kast(player_statistics, round_statistics) _handle_multi_kills(player_statistics, round_statistics) _finalize_statistics(player_statistics) if return_type == "df": return ( pd.DataFrame() .from_dict(player_statistics, orient="index") .reset_index(drop=True) ) return player_statistics