<jupyter_start><jupyter_text>8 - Tracking Control Groups
  IntroductionBeyond `CommandEvents` (see >), another potential indicator that I can use to profile the players' tactical play (i.e. their micro-game) is their **selection behaviour**. For instance, in StarCraft II, players can select units in two ways; they can use their mouse in conjunction with some hotkeys like `ctrl` or use control groups to assemble sets of units for custom and fast selection. This chapter explores how I can extract information about this behaviour with sc2reader's `SelectionEvents` and `ControlGroupEvents`. Using these events, I define a set of functions to quantify this characteristic of the players' play. Exported Functions:- `calc_ctrlg_ratio`- `count_max_active_groups`- `calc_get_ctrl_grp_ratio`- `calc_select_ratio`   Extracting the Control Group Compositions

Before examining the structure of the `ControlGroupEvents` and the `SelectionEvents`, I must explain how to load `Replays` to include enough information to quantify the abovementioned indicators.  The issue is that loading `Replays` with `sc2reader` as I have in previous modules does not record the composition of the players' control groups over time. This can be remediated to an extent using the `sc2reader` plug-in `SelectionTracker`, which is similar to the `APMTracker` I use in >. However, in contrast with `APMTracker`, `SelectionTracker` is meant as an input for user-defined plug-ins that specify its behaviour. 

> Note: this chapter's development notebook defines the module `selection_parser`, which exports the class `CtrlGroupTracker` that can be used as an `sc2reader` plug-in. This plug-in adds the `ctrl_grp_trk` attribute to the `Replay` objects upon load. This attribute stores the composition of the players' control groups each time the player triggers a `ControlGroupEvent` during the match. See the notebook or the module's source code for implementation details.<jupyter_code>#export
class CtrlGroupTracker(object):
    """Tracks the composition of the Replay's Players Control Groups.

    Using this plug-in, the Replay object will include the `ctrl_grp_trk` 
    attribute. This attribute will store a dictionary of the control group 
    compositions using the replay's human players' ids (`pid`) as keys. 
    i.e.:
	`dict[pid (int):  control_group_compositions (dict)]`

    Each of these dictionaries uses the `second` attribute of a 
    `ControlGroupEvent` as an index and organises the control group 
    composition as one more dictionary, indexed with the integers 1 to 9, 
    which stores a list of units that compose each one of the nine control 
    groups of the player. i.e.

    `dict[pid(int): dict[second(int): dict[group(int): list_of_units]]]`
    """
    from sc2reader.engine.plugins import SelectionTracker  
    sc2reader.engine.register_plugin(SelectionTracker())
    name = "CtrlGroupTracker"

    def handleInitGame(self, event, replay):
        replay.ctrl_grp_trk = dict()
        for human in replay.humans:
            selection = human.selection
            replay.ctrl_grp_trk[human.pid] = {0:{k: v for k, v 
                                                in selection.items()
                                                if 0<k<10}}

    def handleEvent(self, event, replay):

        if isinstance(event, sc2reader.events.game.ControlGroupEvent):
            selection = event.player.selection
            replay.ctrl_grp_trk[event.player.pid][event.second] = {
                                                        k: v for k, v 
                                                        in selection.items()
                                                        if 0<k<10} 
show_doc(CtrlGroupTracker, title_level=5)<jupyter_output><empty_output><jupyter_text>Selection Behaviour EventsAs stated above, in this chapter, I take a look into two kinds of `GameEvents` that I can use to get a notion of the players' selection behaviours, i.e. `ControlGroupEvents` and `SelectonEvents`. Handling `ControlGroupEvents`I will start by loading the sample `Replays` I use in this notebook to analyse this type of event.> Note:  In the following code, the first command registers that `CtrlGroupTracker` plug-in I define above.<jupyter_code>#
# Register CtrlGroupTracker plug-in
sc2reader.engine.register_plugin(CtrlGroupTracker())

# Load sample replays
RPS_PATH = Path("./test_replays")

game_path = str(RPS_PATH/"Jagannatha LE.SC2Replay")
single_replay = sc2reader.load_replay(game_path)
ctrl_grp_test = sc2reader.load_replay(str(RPS_PATH/'ctrl_grp_t.SC2Replay'))
ctrl_grp_test_2 = sc2reader.load_replay(str(RPS_PATH/'ctrl_grp_t_2.SC2Replay'))

ta_test = sc2reader.load_replay(str(RPS_PATH/'Terran_abilities.SC2Replay'))
pa_test = sc2reader.load_replay(str(RPS_PATH/'ProtossAbilities.SC2Replay'))

tms_test = sc2reader.load_replay(str(RPS_PATH/'TMovesSelect.SC2Replay'))
pms_test = sc2reader.load_replay(str(RPS_PATH/'p_move_test.SC2Replay'))<jupyter_output><empty_output><jupyter_text>With `Replays` loaded, one can see that they now have the `ctrl_grp_trk` attribute. Calling this attribute, I can choose to look at each of the players' **control group compositions** using their `pid` as an index. Afterwards, I can look at each composition independently using the value of the `second` attribute of the `ControlGroupEvent` that generated it as an index.> Tip: in the sample code, TEST_PID stores the sample player's player ID (`pid`). Meanwhile, the number 221, which I use to extract a sample composition, refers to the time index of the event that triggered its recording.<jupyter_code>TEST_MATCH = single_replay
TEST_PID = 2
sample_ctrlg_comp = TEST_MATCH.ctrl_grp_trk[TEST_PID][221]
print('LOAD RESULTS:')
print(f'Ctrl group compositions: {sample_ctrlg_comp}')
print('---------------------------')
print('Sample Ctrl group composition:')
pprint(TEST_MATCH.ctrl_grp_trk[TEST_PID][221])
print('----------------------------')

sample_event = [e for e in TEST_MATCH.events 
              if isinstance(e, sc2reader.events.game.ControlGroupEvent)
              and e.pid == (TEST_PID - 1)
              and e.second == 221]

print(f'Sample {sample_event[0].name}')
print(f'Generated by: {sample_event[0].player}')
print(f'Recorded time: {sample_event[0].second}second')<jupyter_output>LOAD RESULTS:
Ctrl group compositions: {1: [OrbitalCommand [3100001]], 2: [SCV [3440001]], 3: [OrbitalCommand [3100001]], 4: [Factory [3B80001]], 5: [OrbitalCommand [3100001]], 6: [], 7: [], 8: [], 9: []}
---------------------------
Sample Ctrl group composition:
{1: [OrbitalCommand [3100001]],
 2: [SCV [3440001]],
 3: [OrbitalCommand [3100001]],
 4: [Factory [3B80001]],
 5: [OrbitalCommand [3100001]],
 6: [],
 7: [],
 8: [],
 9: []}
----------------------------
Sample GetControlGroupEvent
Generated by: Player 2 - MxChrisxM (Terran)
Recorded time: 221second<jupyter_text>> Note: in the composition, nine lists track the units that belong to each of the nine control groups that players can assign in the game. Beyond the control group compositions, I can use simple _list comprehensions_ to extract the `ControlGroupEvents` from the `Replay's` event list.With this technique, I can also segregate these events into three sub-types:- `SetControlGroupEvent`: created each time a player assigns a control group.- `GetControlGroupEvent`: registered each time a player summons, i.e. uses a hot-key to select a control group.- `AddToControlGroupEvent`: registered when a player uses a hot-key to assign selected units to an existing control group.This classification is helpful to distinguish between events that select units and those that do not.<jupyter_code>#
#List all ControlGroupEvents
ctrl_grp_e = [e for e in TEST_MATCH.events 
              if isinstance(e, sc2reader.events.game.ControlGroupEvent)
              and e.pid == (TEST_PID - 1)]

#List ControlGroupEvents sub-types
set_ctrl_grp = [e for e in ctrl_grp_e
                if isinstance(e, sc2reader.events.game.SetControlGroupEvent)]

get_ctrl_grp = [e for e in ctrl_grp_e
                if isinstance(e, sc2reader.events.game.GetControlGroupEvent)]

add_ctrl_grp = [e for e in ctrl_grp_e
                if isinstance(e, 
                              sc2reader.events.game.AddToControlGroupEvent)]

print(f'ControlGroupEvents: {len(ctrl_grp_e)}')
print(f'SetControlGroupEvents: {len(set_ctrl_grp)}')
print(f'GetControlGroupEvents: {len(get_ctrl_grp)}')
print(f'AddControlGroupEvents: {len(add_ctrl_grp)}')<jupyter_output>ControlGroupEvents: 100
SetControlGroupEvents: 14
GetControlGroupEvents: 86
AddControlGroupEvents: 0<jupyter_text>Meanwhile, with a simple loop I can go through a control group composition group-lists to count how many of them actually have units assign to them as shown in the code bellow.<jupyter_code>count = 0 
for l in sample_ctrlg_comp.values():
    if l:
        count += 1 

count<jupyter_output><empty_output><jupyter_text>In this module, I define an internal function called `count_active_groups` that carries out this task.<jupyter_code>#exporti
def count_active_groups(ctrl_grps: dict[int, list]) -> int:
    '''Count how many of the control groups have units assign to them.

    *Args*
        - ctrl_grps (dict[int, list])
            Control group compossitions indexed from 1 to 9
    
    *Returns*
        - (int)
            Number of active control groups
    '''
    count = 0 
    for l in ctrl_grps.values():
        if l:
            count += 1 

    return count

#
# count_active_groups sample run
count_active_groups(sample_ctrlg_comp)<jupyter_output><empty_output><jupyter_text>Handling `SelectionEvents`As explained in `sc2reader's` documentation,     >: "`SelectionEvents` are generated when ever the active selection of the player is updated. Unlike other game events, these events can also be generated by non-player actions like unit deaths or transformations. [...] selection events targetting control group buffers are also generated when control group selections are modified by non-player actions. When a player action updates a control group a `ControlGroupEvent` is generated." [(Kim, 2015, p. 48)](https://sc2reader.readthedocs.io/en/latest/events/game.html)Hence, `SelectionEvents` events can refer to game situations that also trigger `ControlGroupEvents`, but they are essentially different. Below, I extract the sample match's `SelectionEvents`. I also use their `control_group` attribute to see what control groups they are linked to. Note that there is a control group number ten according to the list of control groups linked to the selections. This group refers to the player's current active selection, whatever it was at the moment. In other words, if the player selects units just by clicking, it would trigger a `SelectionEvent` but not a `ControlGroupEvent`. Said `SelectionEvent` would be linked to the current selection instead of a control group.<jupyter_code>select_e = [e for e in TEST_MATCH.events 
              if isinstance(e, sc2reader.events.game.SelectionEvent)
              and e.pid == (TEST_PID - 1)]

print(f'SelectionEvents in sample replay: {len(select_e)}')
print(f'Groups referenced by the Selection Events:')
print(set([s.control_group for s in select_e]))<jupyter_output>SelectionEvents in sample replay: 215
Groups referenced by the Selection Events:
{1, 3, 4, 5, 10}<jupyter_text>Similarly, the following code exposes the intersection between the two sets. It shows how many `SelectionEvents` and `ControlGroupEvents` `sc2reader` registered independently and how many were triggered by the same game event. Recognizing this link is crucial if I want to count the two types of events together. In this case, I need to make sure that I only count each game event once. The following code shows how the sets of events intersect, how simply adding the sets miss-counts the elements, and how I can use set-union to count them correctly.<jupyter_code>sel_e_times = set([s.second for s in select_e])
ctrl_grp_times = set([c.second for c in ctrl_grp_e])
print(f'select_e_times_indexes: {len(sel_e_times)}')
print(f'ctrl_grp_times_indexes: {len(ctrl_grp_times)}')

intersect = sel_e_times.intersection(ctrl_grp_times)
not_intersect_set = sel_e_times.symmetric_difference(ctrl_grp_times)
print(f'Number of elements that intersect: {len(intersect)}')
print(f'Number of elements that do not intersect: {len(not_intersect_set)}')
print(f'Sum of set counts: {len(sel_e_times) + len(ctrl_grp_times)}')
print(f'Union of select and ctrl_group: {len(sel_e_times | ctrl_grp_times)}')<jupyter_output>select_e_times_indexes: 179
ctrl_grp_times_indexes: 85
Number of elements that intersect: 7
Number of elements that do not intersect: 250
Sum of set counts: 264
Union of select and ctrl_group: 257<jupyter_text>Functions

 Helper functions

Internally the module defines the helper functions:

- `build_ctrlg_df` that builds a DataFrame with the `Replay's` list of `ControlGroupEvents`.<jupyter_code>#exporti
def build_ctrlg_df(events: list, 
                   rpl:sc2reader.resources.Replay) -> pd.DataFrame:

    raw_df = pd.DataFrame([e.__dict__ for e in events])[['second', 
                                                         'player', 
                                                         'name', 
                                                         'control_group']]
                                                         
    raw_df.insert(1, 'pid', [player.pid for player in raw_df.player])
    raw_df.insert(0, 'real_time', 
                  [calc_realtime_index(sec, rpl) for sec in raw_df.second])

    return raw_df.drop(['player'], axis=1)<jupyter_output><empty_output><jupyter_text>The table results from a sample run of `build_ctrlg_df` called on the notebooks `TEST_MATCH`.<jupyter_code>#hide
#print(build_ctrlg_df(ctrl_grp_e, TEST_MATCH).head().to_markdown())<jupyter_output><empty_output><jupyter_text>|    |   real_time |   second |   pid | name                 |   control_group ||---:|------------:|---------:|------:|:---------------------|----------------:||  0 |     4.28571 |        6 |     2 | SetControlGroupEvent |               1 ||  1 |     4.28571 |        6 |     2 | SetControlGroupEvent |               2 ||  2 |     4.28571 |        6 |     2 | SetControlGroupEvent |               3 ||  3 |     5       |        7 |     2 | SetControlGroupEvent |               4 ||  4 |     5       |        7 |     2 | SetControlGroupEvent |               5 |  Exportable FunctionsConsidering all of the above, I define multiple functions to extract the players' _seclection behaviour_ indicators. These functions include:<jupyter_code>#export
def count_max_active_groups(rpl: sc2reader.resources.Replay, 
                      pid: int) -> dict[str, float]:
    """Counts the maximum number of active control groups during the 
    different stages of the game.

    *Args*
        - rpl (sc2reader.resources.Replay)
            The replay being analysed.
        - pid (int)
            In-game player ID of the player being considered in the 
            analysis.
    
    *Returns*
        - dict[str, int]
            Maximum number of active control groups at each game stage 
            indexed with the keys [stage]_max_act_grps
    """
    column_names = ['whole_max_act_grps', 'early_max_act_grps',
                    'mid_max_act_grps', 'late_max_act_grps']
    ctrl_grp_e = [e for e in rpl.events 
                if isinstance(e, sc2reader.events.game.ControlGroupEvent)
                and e.pid == (pid - 1)]

    if not ctrl_grp_e:
        return {name: 0 for name in column_names}

    ctrl_grp_e_df = build_ctrlg_df(ctrl_grp_e, rpl)

    grp_trk = rpl.ctrl_grp_trk
    grp_trk_indexes = [(row[1].pid, row[1].second) 
                        for row in ctrl_grp_e_df.iterrows()]
    ctrl_grp_e_df['active_groups'] = [count_active_groups(grp_trk[player][sec]) 
                                      for player, sec in grp_trk_indexes]

    
    
    interv_dfs= gen_interval_sub_dfs(rpl.length.seconds,
                                     ctrl_grp_e_df,
                                     ['active_groups'])
    
    return {name: (df.agg('max')['active_groups'] if
                   not df.empty else 0)
            for name, df in zip(column_names, interv_dfs)}<jupyter_output><empty_output><jupyter_text>`count_max_active_groups` sample run:<jupyter_code>count_max_active_groups(TEST_MATCH, TEST_PID)
#export
def calc_ctrlg_ratio(rpl: sc2reader.resources.Replay, 
                      pid: int) -> dict[str, float]:
    
    """Calculates the ratio between `ControlGroupEvents` and the union of
    the `CommandEvents`, `SelectionEvents` and `ControlGroupCommand` sets 
    to quantify the players' level of awareness and use of this tactical 
    feature.

    *Args*
        - rpl (sc2reader.resources.Replay)
            The replay being analysed.
        - pid (int)
            In-game player ID of the player being considered in the 
            analysis.

    *Returns*
        - (dict[str, float])

    """

    command_secs = {e.second for e in rpl.events 
                    if isinstance(e, sc2reader.events.game.CommandEvent)
                    and e.pid == (pid - 1)}

    select_secs = {e.second for e in rpl.events 
                    if isinstance(e, sc2reader.events.game.SelectionEvent)
                    and e.pid == (pid - 1)}

    ctrlg_secs = {e.second for e in rpl.events 
                    if isinstance(e, sc2reader.events.game.ControlGroupEvent)
                    and e.pid == (pid - 1)}

    
    total_counted_events = len(command_secs | select_secs | ctrlg_secs)

    if not total_counted_events:
        return {"ctrlg_ratio": 0}
        
    return {"ctrlg_ratio": len(ctrlg_secs)/total_counted_events}
calc_ctrlg_ratio(TEST_MATCH, TEST_PID)<jupyter_output><empty_output><jupyter_text>The next two functions use the union between `GetControlGroupEvent` and `SelectEvent` sets to quantify if the player preferes to select units based on mouse clicks or using control groups.<jupyter_code>#export
def calc_get_ctrl_grp_ratio(rpl: sc2reader.resources.Replay, 
                            pid: int) -> dict[str, float]:
    """Calculates the ratio between `GetControlGroupEvent` to the all 
    section events (i.e. the union of `GetControlGroupEvent` and 
    `SelectEvent`).
    """
    
    select_secs = {e.second for e in rpl.events 
                   if isinstance(e, sc2reader.events.game.SelectionEvent)
                   and e.pid == (pid - 1)}
    
    ctrlg_secs = {e.second for e in rpl.events 
                  if isinstance(e, sc2reader.events.game.GetControlGroupEvent)
                  and e.pid == (pid - 1)}

    selection_union = len(ctrlg_secs | select_secs)

    if not selection_union:
        return {"get_ctrl_grp_ratio": 0}

    return {'get_ctrl_grp_ratio':len(ctrlg_secs)/selection_union}<jupyter_output><empty_output><jupyter_text>`calc_get_ctrl_grp_ratio` sample run:<jupyter_code>calc_get_ctrl_grp_ratio(TEST_MATCH, TEST_PID)
#export
def calc_select_ratio(rpl: sc2reader.resources.Replay, 
                            pid: int) -> dict[str, float]:
    """Calculates the ratio between `SelectEvent` to the all 
    section events (i.e. the union of `GetControlGroupEvent` and 
    `SelectEvent`).
    """
    select_secs = {e.second for e in rpl.events 
                   if isinstance(e, sc2reader.events.game.SelectionEvent)
                   and e.pid == (pid - 1)}
    
    ctrlg_secs = {e.second for e in rpl.events 
                  if isinstance(e, sc2reader.events.game.GetControlGroupEvent)
                  and e.pid == (pid - 1)}

    selection_union = len(ctrlg_secs | select_secs)

    if not selection_union:
        return {"get_ctrl_grp_ratio": 0}

    return {'select_ratio':len(select_secs)/selection_union}<jupyter_output><empty_output><jupyter_text>`calc_select_ratio` sample run:<jupyter_code>calc_select_ratio(TEST_MATCH, TEST_PID)<jupyter_output><empty_output><jupyter_text>References- <NAME>. (2015) 'sc2reader Documentation'. Available at: https://sc2reader.readthedocs.io/_/downloads/en/latest/pdf/.<jupyter_code>#hide
from nbdev.export import notebook2script
notebook2script()<jupyter_output>Converted 00_Comp_model.ipynb.
Converted 01_01_ingest_and_clustering.ipynb.
Converted 01_summarise_rpl.ipynb.
Converted 02_handle_tracker_events.ipynb.
Converted 03_macro_econ_parser.ipynb.
Converted 04_build_parser.ipynb.
Converted 05_handle_command_events.ipynb.
Converted 06_selection_parser.ipynb.
Converted 07_ingest.ipynb.
Converted 08_profiler.ipynb.
Converted index.ipynb.
{'fraction_non_alphanumeric': 0.09276917916526906, 'fraction_numerical': 0.014375389333461115, 'mean_word_length': 3.054001554001554, 'pattern_counts': {'":': 4, '<': 65, '<?xml version=': 0, '>': 74, 'https://': 2, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 25, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '16', 'dir': 'jupyter-structured-clean-dedup', 'id': '120978', 'license': 'Apache-2.0', 'n_tokens_mistral': 6482, 'n_tokens_neox': 6042, 'n_words': 1923, 'path': '06_selection_parser.ipynb', 'repo_name': 'HDavidEspinosa/sc_training', 'size': '20869'}