From 1356f25d640c8422d386b01378b1c9a810e8453b Mon Sep 17 00:00:00 2001 From: Graylin Kim Date: Mon, 22 Jul 2013 00:31:24 -0400 Subject: [PATCH] Create players based on Blizzard/s2protocol#8. Closes #133 and refs #131. --- CHANGELOG.rst | 5 + sc2reader/objects.py | 271 ++++++++++++++++++++++++--------------- sc2reader/resources.py | 147 ++++++++++----------- test_replays/test_all.py | 1 - 4 files changed, 241 insertions(+), 183 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 77f057a0..fe89c702 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,11 @@ CHANGELOG 0.5.2 - -------------------- +* Deprecated player.gateway for player.region +* Reorganized the person/player/observer hierarchy. Top level classes are now Computer, Participant, and Observer. Participant and Computer are both children of player so any isinstance code should still work fine. +* Player.uid now means something completely different! Use player.toon_id instead +* Player.uid is now the user id of the player +* PersonDict can no longer be constructed from a player list and new players cannot be added by string (name). Only integer keys accepted for setting. * Added a sc2json script contributed by @ChrisLundquist * Hooked up travis-ci for continuous testing. https://travis-ci.org/GraylinKim/sc2reader * Switched to built in python unittest module for testing. diff --git a/sc2reader/objects.py b/sc2reader/objects.py index 73482984..d0ac10a1 100644 --- a/sc2reader/objects.py +++ b/sc2reader/objects.py @@ -5,6 +5,7 @@ from collections import namedtuple +from sc2reader import utils from sc2reader.constants import * Location = namedtuple('Location',('x','y')) @@ -38,7 +39,7 @@ class Team(object): #: pick races are not reflected in this string lineup = str() - def __init__(self,number): + def __init__(self, number): self.number = number self.players = list() self.result = "Unknown" @@ -72,145 +73,215 @@ def __repr__(self): def __str__(self): return "[%s] %s: %s" % (self.player, self.name, self.value) -class Person(object): + +class Entity(object): + """ + :param integer sid: The entity's unique slot id. + :param dict slot_data: The slot data associated with this entity """ - :param integer pid: The person's unique id in this game. - :param string name: The person's battle.net name + def __init__(self, sid, slot_data): + #: The entity's unique in-game slot id + self.sid = int(sid) - Base class for :class:`Player` and :class:`Observer` classes. + #: The entity's replay.initData slot data + self.slot_data = slot_data - Contains attributes shared by all starcraft II clients in a game. - """ + #: The player's handicap as set prior to game start, ranges from 50-100 + self.handicap = slot_data['handicap'] - #: The person's unique in this game - pid = int() + #: The entity's team number. None for observers + self.team_id = slot_data['team_id']+1 - #: The person's battle.net name - name = str() + #: A flag indicating if the person is a human or computer + #: Really just a shortcut for isinstance(entity, User) + self.is_human = slot_data['control'] == 2 - #: A flag indicating the player's observer status. - #: Really just a shortcut for isinstance(obj, Observer). - is_observer = bool() + #: A flag indicating the entity's observer status. + #: Really just a shortcut for isinstance(entity, Observer). + self.is_observer = slot_data['observe'] != 0 - #: A flag indicating if the person is a human or computer - is_human = bool() + #: A flag marking this entity as a referee (can talk to players) + self.is_referee = slot_data['observe'] == 2 - #: A list of :class:`~sc2reader.events.message.ChatEvent` objects representing all of the chat - #: messages the person sent during the game - messages = list() + #: The unique Battle.net account identifier in the form of + #: -S2-- + self.toon_handle = slot_data['toon_handle'] - #: A list of :class:`Event` objects representing all the game events - #: generated by the person over the course of the game - events = list() + toon_handle = self.toon_handle or "0-S2-0-0" + parts = toon_handle.split("-") - #: A flag indicating if this person was the one who recorded the game. - recorder = bool() + #: The Battle.net region the entity is registered to + self.region = GATEWAY_LOOKUP[int(parts[0])] - #: A flag indicating if the person is a computer or human - is_human = bool() + #: Deprecated, see Entity.region + self.gateway = self.region - #: The player's region. - region = str() + #: The Battle.net subregion the entity is registered to + self.subregion = int(parts[2]) - def __init__(self, pid, name): - self.pid = pid - self.name = name - self.is_observer = bool() - self.messages = list() + #: The Battle.net acount identifier. Used to construct the + #: bnet profile url. This value can be zero for games + #: played offline when a user was not logged in to battle.net. + self.toon_id = int(parts[3]) + + #: A list of :class:`Event` objects representing all the game events + #: generated by the person over the course of the game self.events = list() - self.camera_events = list() - self.ability_events = list() - self.selection_events = list() - self.is_human = bool() - self.region = str() - self.recorder = False # Actual recorder will be determined using the replay.message.events file - -class Observer(Person): - """ - Extends :class:`Person`. - Represents observers in the game. - """ + #: A list of :class:`~sc2reader.events.message.ChatEvent` objects representing all of the chat + #: messages the person sent during the game + self.messages = list() - def __init__(self, pid, name): - super(Observer,self).__init__(pid, name) - self.is_observer = True - self.is_human = True + def format(self, format_string): + return format_string.format(**self.__dict__) - def __repr__(self): - return str(self) - def __str__(self): - return "Player {0} - {1}".format(self.pid, self.name) -class Player(Person): +class Player(object): """ - Extends :class:`Person`. - - Represents an active player in the game. Observers are represented via the - :class:`Observer` class. + :param integer pid: The player's unique player id. + :param dict detail_data: The detail data associated with this player + :param dict attribute_data: The attribute data associated with this player """ + def __init__(self, pid, detail_data, attribute_data): + #: The player's unique in-game player id + self.pid = int(pid) - URL_TEMPLATE = "http://%s.battle.net/sc2/en/profile/%s/%s/%s/" + #: The replay.details data on this player + self.detail_data = detail_data - #: A reference to the player's :class:`Team` object - team = None + #: The replay.attributes.events data on this player + self.attribute_data = attribute_data - #: A reference to a :class:`~sc2reader.utils.Color` object representing the player's color - color = None + #: The player result, one of "Win", "Loss", or None + self.result = None + if detail_data.result == 1: + self.result = "Win" + elif detail_data.result == 2: + self.result = "Loss" - #: The race the player picked prior to the game starting. - #: Protoss, Terran, Zerg, Random - pick_race = str() + #: A reference to the player's :class:`Team` object + self.team = None - #: The race the player ultimately wound up playing. - #: Protoss, Terran, Zerg - play_race = str() - - #: The difficulty setting for the player. Always Medium for human players. - #: Very Easy, Easy, Medium, Hard, Harder, Very hard, Elite, Insane, - #: Cheater 2 (Resources), Cheater 1 (Vision) - difficulty = str() + #: The race the player picked prior to the game starting. + #: One of Protoss, Terran, Zerg, Random + self.pick_race = attribute_data.get('Race', 'Unknown') - #: The player's handicap as set prior to game start, ranges from 50-100 - handicap = int() + #: The difficulty setting for the player. Always Medium for human players. + #: Very Easy, Easy, Medium, Hard, Harder, Very hard, Elite, Insane, + #: Cheater 2 (Resources), Cheater 1 (Vision) + self.difficulty = attribute_data.get('Difficulty', 'Unknown') - #: The subregion with in the player's region - subregion = int() + #: The race the player played the game with. + #: One of Protoss, Terran, Zerg + self.play_race = LOCALIZED_RACES.get(detail_data.race, detail_data.race) - #: The player's bnet uid for his region/subregion. - #: Used to construct the bnet profile url. This value can be zero for games - #: played offline when a user was not logged in to battle.net. - uid = int() + #: A reference to a :class:`~sc2reader.utils.Color` object representing the player's color + self.color = utils.Color(**detail_data.color._asdict()) - def __init__(self, pid, name): - super(Player,self).__init__(pid, name) - self.is_observer = False - - #: A list of references to the units the player had this game + #: A list of references to the :class:`~sc2reader.data.Unit` objects the player had this game self.units = list() - #: A list of references to the units that the player killed this game + #: A list of references to the :class:`~sc2reader.data.Unit` objects that the player killed this game self.killed_units = list() + #: The Battle.net region the entity is registered to + self.region = GATEWAY_LOOKUP[detail_data.bnet.gateway] + + #: Deprecated, see `Player.region` + self.gateway = self.region + + #: The Battle.net subregion the entity is registered to + self.subregion = detail_data.bnet.subregion + + #: The Battle.net acount identifier. Used to construct the + #: bnet profile url. This value can be zero for games + #: played offline when a user was not logged in to battle.net. + self.toon_id = detail_data.bnet.uid + + +class User(object): + """ + :param integer uid: The user's unique user id + :param dict init_data: The init data associated with this user + """ + #: The Battle.net profile url template + URL_TEMPLATE = "http://{region}.battle.net/sc2/en/profile/{toon_id}/{subregion}/{name}/" + + def __init__(self, uid, init_data): + #: The user's unique in-game user id + self.uid = int(uid) + + #: The replay.initData data on this user + self.init_data = init_data + + #: The user's Battle.net clan tag at the time of the game + self.clan_tag = init_data['clan_tag'] + + #: The user's Battle.net name at the time of the game + self.name = init_data['name'] + + #: The user's combined Battle.net race levels + self.combined_race_levels = init_data['combined_race_levels'] + + #: The user's highest leauge in the current season + self.highest_league = init_data['highest_league'] + + #: A flag indicating if this person was the one who recorded the game. + #: This is deprecated because it doesn't actually work. + self.recorder = None @property def url(self): - """The player's formatted battle.net profile url""" - return self.URL_TEMPLATE % (self.gateway, self.uid, self.subregion, self.name) + """The player's formatted Battle.net profile url""" + return self.URL_TEMPLATE.format(**self.__dict__) - def __str__(self): - return "Player %s - %s (%s)" % (self.pid, self.name, self.play_race) - @property - def result(self): - """The game result for this player: Win, Loss, Unknown""" - return self.team.result if self.team else "Unknown" +class Observer(Entity, User): + """ + :param integer sid: The entity's unique slot id. + :param dict slot_data: The slot data associated with this entity + :param integer uid: The user's unique user id + :param dict init_data: The init data associated with this user + :param integer pid: The player's unique player id. + """ + def __init__(self, sid, slot_data, uid, init_data, pid): + Entity.__init__(self, sid, slot_data) + User.__init__(self, uid, init_data) - def format(self, format_string): - return format_string.format(**self.__dict__) + #: The player id of the observer. Only meaningful in pre 2.0.4 replays + self.pid = pid - def __repr__(self): - return str(self) + +class Computer(Entity, Player): + """ + :param integer sid: The entity's unique slot id. + :param dict slot_data: The slot data associated with this entity + :param integer pid: The player's unique player id. + :param dict detail_data: The detail data associated with this player + :param dict attribute_data: The attribute data associated with this player + """ + def __init__(self, sid, slot_data, pid, detail_data, attribute_data): + Entity.__init__(self, sid, slot_data) + Player.__init__(self, pid, detail_data, attribute_data) + + #: The auto-generated in-game name for this computer player + self.name = detail_data.name + + +class Participant(Entity, User, Player): + """ + :param integer sid: The entity's unique slot id. + :param dict slot_data: The slot data associated with this entity + :param integer uid: The user's unique user id + :param dict init_data: The init data associated with this user + :param integer pid: The player's unique player id. + :param dict detail_data: The detail data associated with this player + :param dict attribute_data: The attribute data associated with this player + """ + def __init__(self, sid, slot_data, uid, init_data, pid, detail_data, attribute_data): + Entity.__init__(self, sid, slot_data) + User.__init__(self, uid, init_data) + Player.__init__(self, pid, detail_data, attribute_data) class PlayerSummary(): diff --git a/sc2reader/resources.py b/sc2reader/resources.py index ae613818..172efc38 100644 --- a/sc2reader/resources.py +++ b/sc2reader/resources.py @@ -24,7 +24,7 @@ from sc2reader import exceptions from sc2reader.data import builds as datapacks from sc2reader.exceptions import SC2ReaderLocalizationError -from sc2reader.objects import Player, Observer, Team, PlayerSummary, Graph, BuildEntry +from sc2reader.objects import Participant, Observer, Computer, Team, PlayerSummary, Graph, BuildEntry from sc2reader.constants import REGIONS, LOCALIZED_RACES, GAME_SPEED_FACTOR, LOBBY_PROPERTIES, GATEWAY_LOOKUP @@ -288,7 +288,6 @@ def __init__(self, replay_file, filename=None, load_level=4, **options): for event in self.events: event.load_context(self) - def load_details(self): if 'replay.attributes.events' in self.raw_data: # Organize the attribute data to be useful @@ -358,84 +357,70 @@ def load_players(self): self.clients = list() self.client = dict() - def createObserver(pid, name, attributes): - # TODO: Make use of that attributes, new in HotS - observer = Observer(pid, name) - return observer - - def createPlayer(pid, pdata, attributes): - # make sure to strip the clan tag out of the name - # in newer replays, the clan tag can be separated from the - # player name with a symbol. It should also be stripped. - name = pdata.name.split("]", 1)[-1].split(">", 1)[-1] - player = Player(pid, name) - - # In some beta patches attribute information is missing - # Just assign them to team 2 to keep the issue from being fatal - team_number = int(attributes.get('Teams'+self.type, "Team 2")[5:]) - # team_number = pdata.team+1 - - if not team_number in self.team: - self.team[team_number] = Team(team_number) - self.teams.append(self.team[team_number]) - - # Maintain order in case people depended on it - self.teams.sort(key=lambda t: t.number) - - self.team[team_number].players.append(player) - player.team = self.team[team_number] - - # Do basic win/loss processing from details data - if pdata.result == 1: - player.team.result = "Win" - self.winner = player.team - elif pdata.result == 2: - player.team.result = "Loss" - else: - player.team.result = None - - player.pick_race = attributes.get('Race','Unknown') - player.play_race = LOCALIZED_RACES.get(pdata.race, pdata.race) - player.difficulty = attributes.get('Difficulty','Unknown') - player.is_human = (attributes.get('Controller','Computer') == 'User') - player.uid = pdata.bnet.uid - player.subregion = pdata.bnet.subregion - player.gateway = GATEWAY_LOOKUP[pdata.bnet.gateway] - player.handicap = pdata.handicap - player.color = utils.Color(**pdata.color._asdict()) - return player - - - pid = 0 - init_data = self.raw_data['replay.initData'] - clients = [d['name'] for d in init_data['player_init_data'] if d['name']] - for index, pdata in enumerate(self.raw_data['replay.details'].players): - pid += 1 - attributes = self.attributes.get(pid, dict()) - player = createPlayer(pid, pdata, attributes) - self.player[pid] = player - self.players.append(player) - self.player[pid] = player - self.people.append(player) - self.person[pid] = player - - for cid, name in enumerate(clients): - if name not in self.player._key_map: - pid += 1 - attributes = self.attributes.get(pid, dict()) - client = createObserver(pid, name, attributes) - self.observers.append(client) - self.people.append(client) - self.person[pid] = client + # For players, we can use the details file to look up additional + # information. detail_id marks the current index into this data. + detail_id = 0 + player_id = 1 + details = self.raw_data['replay.details'] + initData = self.raw_data['replay.initData'] + + # Assume that the first X map slots starting at 1 are player slots + # so that we can assign player ids without the map + self.entities = list() + for slot_id, slot_data in enumerate(initData['lobby_state']['slots']): + user_id = slot_data['user_id'] + + if slot_data['control'] == 2: + if slot_data['observe'] == 0: + self.entities.append(Participant(slot_id, slot_data, user_id, initData['player_init_data'][user_id], player_id, details.players[detail_id], self.attributes.get(player_id, dict()))) + detail_id += 1 + player_id += 1 + + else: + self.entities.append(Observer(slot_id, slot_data, user_id, initData['player_init_data'][user_id], player_id)) + player_id += 1 + + elif slot_data['control'] == 3: + self.entities.append(Computer(slot_id, slot_data, player_id, details.players[detail_id], self.attributes.get(player_id, dict()))) + detail_id += 1 + player_id += 1 + + def get_team(team_id): + if team_id is not None and team_id not in self.team: + team = Team(team_id) + self.team[team_id] = team + self.teams.append(team) + return self.team[team_id] + + # Set up all our cross reference data structures + for entity in self.entities: + if entity.is_observer is False: + entity.team = get_team(entity.team_id) + entity.team.players.append(entity) + self.players.append(entity) + self.player[entity.pid] = entity + else: - client = self.player.name(name) + self.observers.append(entity) + + if entity.is_human: + self.person[entity.pid] = entity + self.client[entity.uid] = entity + self.people.append(entity) - client.cid = cid - self.clients.append(client) - self.client[cid] = client + # Pull results up for teams + for team in self.teams: + results = set([p.result for p in team.players]) + if len(results) == 1: + team.result = list(results)[0] + if team.result == 'Win': + self.winner = team + else: + self.logger.warn("Conflicting results: {0}".format(results)) + team.result = 'Unknown' - # replay.clients replaces replay.humans - self.humans = self.clients + self.teams.sort(key=lambda t: t.number) + self.humans = self.clients = self.people #Create an store an ordered lineup string for team in self.teams: @@ -447,11 +432,9 @@ def createPlayer(pid, pdata, attributes): # We know there will be a default region because there must be # at least 1 human player or we wouldn't have a replay. default_region = self.humans[0].region - for player in self.players: - if not player.is_human: - player.region = default_region - for obs in self.observers: - obs.region = default_region + for entity in self.entities: + if not entity.region: + entity.region = default_region # Pretty sure this just never worked, forget about it for now self.recorder = None diff --git a/test_replays/test_all.py b/test_replays/test_all.py index 80aa4950..886515f2 100644 --- a/test_replays/test_all.py +++ b/test_replays/test_all.py @@ -16,7 +16,6 @@ class TestReplays(unittest.TestCase): - @unittest.expectedFailure def test_teams(self): replay = sc2reader.load_replay("test_replays/1.2.2.17811/13.SC2Replay") self.assertNotEqual(replay.player[1].team.number, replay.player[2].team.number)