Introduction

In this chapter, I explore how to use a Replay's PlayerStatsEvent data to infer a player's macroeconomic performance indicators in a match. Based on this indicators I export the module macro_econ_parser, which exports functions that facilitate the rapid extraction of thsi indicators from a Replay.

Exportable Members

Helper Fucntions

Primary function

PlayerStatsEvents

Of the different types of TrackerEvents (see <<Chapter 2 - Handling Tracker Events>>), PlayerStatsEvents contain several pieces of information I can use as the players' macroeconomic performance indicators in a match.

In each Replay, StarCraft II registers a PlayerStatsEvent for each player every ten seconds, regardless of the player's presence in the match. In other words, if a player leaves a game without terminating it, the Replay still recordsPlayerStatsEvents for that player up until the end of the match.

Additionally, the game generates a PlayerStatEvent when the game starts, each time a player leaves (i.e., is defeated or surrenders) and when the match ends.

In this section, I examine how to handle this events. The example follows the analysis of the replay I loaded in the following code.

rps_path = Path("./test_replays")
game_path = str(rps_path/"Jagannatha LE.SC2Replay")
single_replay = sc2reader.load_replay(game_path)
single_replay

match_events = [event for event in single_replay.events]
tracker_e = [event for event in single_replay.events 
            if isinstance(event, sc2reader.events.tracker.TrackerEvent)]

print(f'{"Game length in seconds:":<27}',
    f'{single_replay.game_length.seconds:>6}')
print(f'{"Game frames per second:":<27}',
    f'{single_replay.game_fps:>6}')
print(f'{"Total frames in the match:":<27}',
    f'{single_replay.game_length.seconds * single_replay.game_fps:>6}')

p_one_state = [event for event in tracker_e 
               if isinstance(event, sc2reader.events.tracker.PlayerStatsEvent) 
               and event.pid == 1]
print(f'Number of PlayerStatsEvents for player 1: {len(p_one_state)}')
Game length in seconds:        590
Game frames per second:       16.0
Total frames in the match:  9440.0
Number of PlayerStatsEvents for player 1: 85

Similarly, looking at the seconds and frame count registered in the last event, it is clear that they do not match the duration of the game (590 seconds, 9440 frames).

print(f'Second recorded in the last event: ', 
    f'{p_one_state[len(p_one_state)-1].second}')

print(f'Frame recorded in the last event: ', 
    f'{p_one_state[len(p_one_state)-1].frame}')
Second recorded in the last event:  826
Frame recorded in the last event:  13224

Plotting the graph for the army value in two different ways, I can demonstrate how calc_realtime_index can correct the time indexing discrepancy. I use the army value given that it is usually used as one of the most common measures of a player's performance. Meanwhile, comparing these graphs shows how this solution changes the information stored in these events.

First, I plot this value with the time registered in the events as the graph's horizontal axis.

pd.set_option('display.precision', 2)

# Calculate the army value (a_value), adding the amounts of minerals and
# vespene spent in the player's active army at each point during the game.
a_mineral_value = np.array([e.minerals_used_active_forces 
                        for e in  p_one_state])
a_vespene_value = np.array([e.vespene_used_active_forces 
                        for e in  p_one_state])
a_value = a_mineral_value + a_vespene_value

# Generate a DataFrame of the army value at each point in the game using 
# the seconds as an index.
pstats_noprocess = pd.DataFrame({'second': [e.second for e in p_one_state],
                             'army_value': a_value})
pstats_noprocess.set_index('second', inplace=True)
#print(pstats_noprocess.tail(10).to_markdown())

The following table shows the last ten army_value records indexed according to the seconds registered in the PlayerStatsEvent.

second army_value
750 0
760 0
770 350
780 0
790 0
800 0
810 350
820 0
826 0
826 0
x_values = [x for x in pstats_noprocess.index if x % 50 == 0]
pstats_noprocess['army_value'].plot(figsize= (10,5), xticks= x_values,
                                    xlabel='Time Index Recorded in Events',
                                    ylabel='Army Value')
<AxesSubplot:xlabel='Time Index Recorded in Events', ylabel='Army Value'>

Next, I recalculate the index of the DataFrame based on the actual duration of the match.

# Establish the new second count as index.
pstats_change_seconds = pstats_noprocess.reset_index()
pstats_change_seconds['real_time'] = [
                                calc_realtime_index(event.second, 
                                                    single_replay)
                                for event in p_one_state]

pstats_change_seconds.set_index('real_time', inplace=True)

# print(pstats_change_seconds.tail(10).to_markdown())

The following table shows the difference in the last ten army_value records between the original time index recorded in the PlayerStatsEvent and the time indexes calculated according to replay's length.

real_time second army_value
535.714 750 0
542.857 760 0
550 770 350
557.143 780 0
564.286 790 0
571.429 800 0
578.571 810 350
585.714 820 0
590 826 0
590 826 0

While the difference in the index is notable, note that the form of the graph remains the same.

real_x_ticks = np.arange(0,590,50)

# Create the plot with the new time index
pstats_change_seconds['army_value'].plot(figsize= (10,5), 
                                        xticks= real_x_ticks,
                                        xlabel='Real-time Index',
                                        ylabel='Army Value')
<AxesSubplot:xlabel='Real-time Index', ylabel='Army Value'>

Since the time index is evenly spaced in both cases, there is no change to the curve. However, the timing of the events is different, which one can see using in the graph bellow, where I use the same x_axis from the second graph with the army_values indexed by the original time index.

pstats_noprocess['army_value'].plot(figsize= (10,5), 
                                    xticks= real_x_ticks,
                                    xlabel='Real-time Index',
                                    ylabel='Army Value')
<AxesSubplot:xlabel='Real-time Index', ylabel='Army Value'>

Data in PlayStatsEvent

Having examined how to manipulate PlayerStatsEvent objects, I can examine the type of data they store. In general, this type of objects store macro-economic data.

Here is a list of their attributes and how they can be used to compose player stats.

  • minerals_used_active_forces and vespene_used_active_forces: As I show in the example above, these indicators can be used to calculate the players' army value.
  • minerals_collection_rate, vespene_collection_rate, workers_active_count: are important indicators about the rate of growth of the economy.
  • minerals_current and vespene_current: these attributes can be added and averaged over time to extract the average unspent resources. This information is critical because the game's macro strategy is built around growing an economy and spending resources. Thus, an unspent economy can signal missed opportunities.
  • food_used and food_made: these two attributes can be used to signal moments where the players' supply has been capped. Therefore the player is blocked, preventing players from training units.
  • Economy use and lost indicators divide the players' resources into:
    • used_in_progress: resources that have been spent in elements (structures, units or research) under development.
      • minerals_used_in_progress
      • vespene_used_in_progress
      • resources_used_in_progress
    • used_current: resources invested in elements that are currently in play.
      • minerals_used_current
      • vespene_used_current
      • resources_used_current
    • lost: resources invested in elements that a player has lost (by killing or destruction) during the game.
      • minerals_lost
      • vespene_lost
      • resources_lost
    • killed: resources that an opponent had invested in elements that the player has killed.
      • minerals_killed
      • vespene_killed
      • resources_killed
    • Additionally, each mineral and vespene resource attribute has three more associated attributes, providing more detail into the economy. These extended attributes break each indicator into army, economy and technology indicators (buildings, units and research). For example, the minerals_used_in_progress attribute has the following attributes that disaggregate it:
      • minerals_used_in_progress_army
      • minerals_used_in_progress_economy
      • minerals_used_in_progress_technology

Helper Functions

In this module, I define various helper functions that simplify the definition of the module's main exportable functions.

  • get_pstatse extracts a list of all the PlayerStatsEvent instances related to a player from a Replay.
  • complete_pstatse_df expands the initial DataFrame that can be extracted directly from the PlayerStatsEvents with several relevant features. I use this function in the get_player_macro_econ_df definition.

  • Some functions that allow for the functional computation of various indicators:

  • get_player_macro_econ_df extracts all significant indicators from the PlayerStatsEvents, and compiles them into a pandas.DataFrame. This DataFrame expands the information in these events with several columns. Chiefly, the data frame includes a real_timecolumn that allows for the correct indexing of the events based on the match's duration.

The get_player_macro_econ_df function organises a player's PlayerStatsEvents into a DataFrame where the columns represent each indicators' values. In said DataFrame the rows represent the player's events.

The code below shows the recorded data for the last event (i.e. row) of the DataFrame extracted from a sample replay using this function.

test_df = get_player_macro_econ_df(single_replay, 1)
print(test_df.iloc[-1])
real_time                      590.0
second                           826
minerals_current                2750
vespene_current                  996
unspent_rsrc                    3746
minerals_used_active_forces        0
vespene_used_active_forces         0
army_value                         0
minerals_collection_rate        1455
vespene_collection_rate          447
rsrc_collection_rate            1902
workers_active_count              41
minerals_used_in_progress          0
vespene_used_in_progress           0
resources_used_in_progress         0
minerals_used_current           3875
vespene_used_current               0
resources_used_current          3875
minerals_lost                   5000
vespene_lost                    1250
resources_lost                  6250
minerals_killed                  900
vespene_killed                   125
resources_killed                1025
food_used                       41.0
food_made                       39.0
supply_capped                   True
Name: 83, dtype: object

Exportable Fucntions

The following two functions facilitate the division of information so that stats can be calculated based on pandas.DataFrames for the whole match and the early, mid and late intervals of the game.

Meanwhile, is the main function exported by this module.

gen_interval_sub_dfs[source]

gen_interval_sub_dfs(rpl_length:float, df:DataFrame, column:str)

Extract a set of DataFrames containing the records for a particular field of the PlayerStatsEvent.

The function extracts the information of a particular column of the df DataFrame. It returns a list of four DataFrames for the whole, early, mid and late game intervals.

Args

- df (pd.DataFrame)
    DataFrame containing all PlayerStatsEvent instances of a
    replay. The DataFrame must have a 'real_time' column that
    indexes the events in the data frame in time in a manner that
    consistent with the replays length.
- rpl_length (float)
    Length of a match in seconds.
- column (str)
    Name of the column that should be extracted in the DataFrames

Returns

- list[DataFrame]
    List of the DataFrames, containing the column information for
    the whole, early, mid and late games in that order.

list_attr_interval_values[source]

list_attr_interval_values(df:DataFrame, func:Callable[DataFrame, Any], df_attribute:str, rpl_length:float)

Lists the result of a function it receives applied to the values of various listed DataFrames.

This function receives a DataFrame, splits it into sub-DataFrames based on various game intervals, and then applies a function to those intervals listing the results.

Args

- df (pandas.DataFrame)
    A DataFrame of various attributes of a replay, including those
    that must be processed.
- func (callable)
    A function that will be applied to each interval
- df_attribute (str)
    Name of the DataFrame's attribute (i.e. column) to which the
    function must be applied.
- rpl_length (float)
    The duration of the Replay from which the DataFrame is
    constructed in seconds.

Returns

-  List of the return values of the function func applied to the
various game intervals.

get_player_macro_econ_stats[source]

get_player_macro_econ_stats(rpl:Replay, pid:int)

This function organises a player's major macroeconomic performance indicatorsinto a dictionary.

The function uses a player's PlayerStatsEvents in a Replay object to calculate the player's major economic performance indicators. These indicators are calculated for the whole game, as well as for the early (first 4 minutes), mid (between minutes 4 and 8) and late games (paste minute 8).

Arguments

- rpl (sc2reader.resources.Replay)
        Replay object generated with sc2reader containing a match's
        data.
- pid (int)
        A player's id number distinguishes them from the other
        players in a match. It can be extracted from a Participant
        object through the pid attribute.

Returns

- dict
    This dictionary contains the values for a player's major
    macroeconomic performance indicators. Each indicator is
    calculated for the entire game and the early, mid and late
    game intervals.

The indicators extracted with this function include:

  • Average unspent minerals, vespene and minerals
  • Average Active Workers
  • Average mineral, vespene and resource collection rate
  • Average army value
  • Total minerals and vespene lost
  • Spending Quotient

Note that the spending quotient is a performance measurement that the community has adopted to compare and evaluate a player's macroeconomic performance (whatthefat, 2011; Spending Quotient, 2020).

Moreover, I calculate each of these indicators for the entire match and the early (first 4 minutes), mid (minutes 4-8) and late game (past minute 8).

The following are three sample results of this function used on a short, medium and long replays of the same player.

pprint(get_player_macro_econ_stats(single_replay, 1), sort_dicts=False)
{'replay_name': 'test_replays\\Jagannatha LE.SC2Replay',
 'player_username': 'HDEspino',
 'player_id': 1,
 'unspent_minerals_avg_whole': 607.2619047619048,
 'unspent_minerals_avg_early': 348.0882352941176,
 'unspent_minerals_avg_mid': 441.7647058823529,
 'unspent_minerals_avg_late': 1509.6875,
 'unspent_vespene_avg_whole': 197.04761904761904,
 'unspent_vespene_avg_early': 35.470588235294116,
 'unspent_vespene_avg_mid': 170.52941176470588,
 'unspent_vespene_avg_late': 596.75,
 'unspent_resources_avg_whole': 804.3095238095239,
 'unspent_resources_avg_early': 383.55882352941177,
 'unspent_resources_avg_mid': 612.2941176470588,
 'unspent_resources_avg_late': 2106.4375,
 'active_workers_avg_whole': 26.678571428571427,
 'active_workers_avg_early': 16.5,
 'active_workers_avg_mid': 30.941176470588236,
 'active_workers_avg_late': 39.25,
 'mineral_collection_rate_avg_whole': 1106.6785714285713,
 'mineral_collection_rate_avg_early': 770.6764705882352,
 'mineral_collection_rate_avg_mid': 1243.3529411764705,
 'mineral_collection_rate_avg_late': 1530.25,
 'vespene_collection_rate_avg_whole': 269.0952380952381,
 'vespene_collection_rate_avg_early': 52.470588235294116,
 'vespene_collection_rate_avg_mid': 408.44117647058823,
 'vespene_collection_rate_avg_late': 433.3125,
 'resource_collection_rate_avg_whole': 1375.7738095238096,
 'resource_collection_rate_avg_early': 823.1470588235294,
 'resource_collection_rate_avg_mid': 1651.7941176470588,
 'resource_collection_rate_avg_late': 1963.5625,
 'army_value_avg_whole': 436.01190476190476,
 'army_value_avg_early': 0.0,
 'army_value_avg_mid': 874.2647058823529,
 'army_value_avg_late': 431.25,
 'lost_minerals_totals_whole': 5000,
 'lost_minerals_totals_early': 0,
 'lost_minerals_totals_mid': 400,
 'lost_minerals_totals_late': 5000,
 'lost_vespene_totals_whole': 1250,
 'lost_vespene_totals_early': 0,
 'lost_vespene_totals_mid': 0,
 'lost_vespene_totals_late': 1250,
 'time_supply_capped_whole': 54.28571428571422,
 'time_supply_capped_early': 0,
 'time_supply_capped_mid': 42.85714285714295,
 'time_supply_capped_late': 11.428571428571331,
 'spending_qs_whole': 71.81890804321279,
 'spending_qs_early': 71.23764664416228,
 'spending_qs_mid': 94.60108164010262,
 'spending_qs_late': 66.30645249314745}
short_rpl = sc2reader.load_replay('./test_replays/Oxide LE (12).SC2replay')
long_rpl = sc2reader.load_replay('./test_replays/Blackburn LE (2).SC2replay')
pprint(get_player_macro_econ_stats(short_rpl, 1), sort_dicts=False)
{'replay_name': './test_replays/Oxide LE (12).SC2replay',
 'player_username': 'HDEspino',
 'player_id': 1,
 'unspent_minerals_avg_whole': 163.9189189189189,
 'unspent_minerals_avg_early': 155.0,
 'unspent_minerals_avg_mid': 265.0,
 'unspent_minerals_avg_late': None,
 'unspent_vespene_avg_whole': 154.59459459459458,
 'unspent_vespene_avg_early': 126.47058823529412,
 'unspent_vespene_avg_mid': 473.3333333333333,
 'unspent_vespene_avg_late': None,
 'unspent_resources_avg_whole': 318.5135135135135,
 'unspent_resources_avg_early': 281.47058823529414,
 'unspent_resources_avg_mid': 738.3333333333334,
 'unspent_resources_avg_late': None,
 'active_workers_avg_whole': 19.054054054054053,
 'active_workers_avg_early': 18.705882352941178,
 'active_workers_avg_mid': 23.0,
 'active_workers_avg_late': None,
 'mineral_collection_rate_avg_whole': 697.5405405405405,
 'mineral_collection_rate_avg_early': 693.2941176470588,
 'mineral_collection_rate_avg_mid': 745.6666666666666,
 'mineral_collection_rate_avg_late': None,
 'vespene_collection_rate_avg_whole': 115.86486486486487,
 'vespene_collection_rate_avg_early': 98.47058823529412,
 'vespene_collection_rate_avg_mid': 313.0,
 'vespene_collection_rate_avg_late': None,
 'resource_collection_rate_avg_whole': 813.4054054054054,
 'resource_collection_rate_avg_early': 791.7647058823529,
 'resource_collection_rate_avg_mid': 1058.6666666666667,
 'resource_collection_rate_avg_late': None,
 'army_value_avg_whole': 54.054054054054056,
 'army_value_avg_early': 44.11764705882353,
 'army_value_avg_mid': 166.66666666666666,
 'army_value_avg_late': None,
 'lost_minerals_totals_whole': 650,
 'lost_minerals_totals_early': 550,
 'lost_minerals_totals_mid': 650,
 'lost_minerals_totals_late': None,
 'lost_vespene_totals_whole': 0,
 'lost_vespene_totals_early': 0,
 'lost_vespene_totals_mid': 0,
 'lost_vespene_totals_late': None,
 'time_supply_capped_whole': 57.04545454545456,
 'time_supply_capped_early': 57.04545454545456,
 'time_supply_capped_mid': 0,
 'time_supply_capped_late': None,
 'spending_qs_whole': 77.27451759377894,
 'spending_qs_early': 80.56413905297947,
 'spending_qs_mid': 59.60922788327724,
 'spending_qs_late': None}
pprint(get_player_macro_econ_stats(long_rpl, 2), sort_dicts=False)
{'replay_name': './test_replays/Blackburn LE (2).SC2replay',
 'player_username': 'HDEspino',
 'player_id': 2,
 'unspent_minerals_avg_whole': 1435.680473372781,
 'unspent_minerals_avg_early': 205.88235294117646,
 'unspent_minerals_avg_mid': 688.9705882352941,
 'unspent_minerals_avg_late': 2101.039603960396,
 'unspent_vespene_avg_whole': 326.9585798816568,
 'unspent_vespene_avg_early': 41.411764705882355,
 'unspent_vespene_avg_mid': 275.2352941176471,
 'unspent_vespene_avg_late': 440.4950495049505,
 'unspent_resources_avg_whole': 1762.6390532544378,
 'unspent_resources_avg_early': 247.2941176470588,
 'unspent_resources_avg_mid': 964.2058823529412,
 'unspent_resources_avg_late': 2541.5346534653463,
 'active_workers_avg_whole': 33.142011834319526,
 'active_workers_avg_early': 17.88235294117647,
 'active_workers_avg_mid': 33.794117647058826,
 'active_workers_avg_late': 38.05940594059406,
 'mineral_collection_rate_avg_whole': 1121.3254437869823,
 'mineral_collection_rate_avg_early': 801.1470588235294,
 'mineral_collection_rate_avg_mid': 1241.7058823529412,
 'mineral_collection_rate_avg_late': 1188.5841584158416,
 'vespene_collection_rate_avg_whole': 456.6804733727811,
 'vespene_collection_rate_avg_early': 107.70588235294117,
 'vespene_collection_rate_avg_mid': 558.2352941176471,
 'vespene_collection_rate_avg_late': 539.9702970297029,
 'resource_collection_rate_avg_whole': 1578.0059171597634,
 'resource_collection_rate_avg_early': 908.8529411764706,
 'resource_collection_rate_avg_mid': 1799.9411764705883,
 'resource_collection_rate_avg_late': 1728.5544554455446,
 'army_value_avg_whole': 2233.579881656805,
 'army_value_avg_early': 111.76470588235294,
 'army_value_avg_mid': 1258.8235294117646,
 'army_value_avg_late': 3275.990099009901,
 'lost_minerals_totals_whole': 14450,
 'lost_minerals_totals_early': 0,
 'lost_minerals_totals_mid': 150,
 'lost_minerals_totals_late': 14450,
 'lost_vespene_totals_whole': 6700,
 'lost_vespene_totals_early': 0,
 'lost_vespene_totals_mid': 150,
 'lost_vespene_totals_late': 6700,
 'time_supply_capped_whole': 264.31723315444253,
 'time_supply_capped_early': 21.431127012522364,
 'time_supply_capped_mid': 42.86225402504476,
 'time_supply_capped_late': 200.0238521168755,
 'spending_qs_whole': 54.055523781485874,
 'spending_qs_early': 90.70925495148984,
 'spending_qs_mid': 85.81150991680738,
 'spending_qs_late': 48.465868124269775}

References