From cf735f324ed00d5bfdf162614a44c6649e09193f Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 16 Apr 2017 14:10:46 +0300 Subject: [PATCH] Import from python-hearthstone --- README.md | 53 ++++ hslog/__init__.py | 1 + hslog/exceptions.py | 11 + hslog/export.py | 198 ++++++++++++++ hslog/packets.py | 220 +++++++++++++++ hslog/parser.py | 634 ++++++++++++++++++++++++++++++++++++++++++++ hslog/player.py | 140 ++++++++++ hslog/tokens.py | 61 +++++ hslog/utils.py | 22 ++ tests/__init__.py | 0 tests/data.py | 151 +++++++++++ tests/test_main.py | 304 +++++++++++++++++++++ 12 files changed, 1795 insertions(+) create mode 100644 README.md create mode 100644 hslog/__init__.py create mode 100644 hslog/exceptions.py create mode 100644 hslog/export.py create mode 100644 hslog/packets.py create mode 100644 hslog/parser.py create mode 100644 hslog/player.py create mode 100644 hslog/tokens.py create mode 100644 hslog/utils.py create mode 100644 tests/__init__.py create mode 100644 tests/data.py create mode 100644 tests/test_main.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3de8c8 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +## hearthstone.hslog + +hslog is a powerful Hearthstone Power.log deserializer. + +### Concepts + +The data read from Power.log is deserialized into packets. +The log is read line by line using a regex-based approach, with packets +accumulating data when they span over multiple lines. +The `BLOCK_START` and `BLOCK_END` packets are serialized into a Block packet, +which is nestable. +We call the totality of the packets for a game the "Packet Tree". + + +### Exporting a PacketTree + +The `PacketTree` object makes it easy to recursively iterate over, which in +turn makes it very easy to export into various other formats. The `.export()` +method on `PacketTree` will natively export the entire tree to a `Game` entity, +using the `hearthstone.entities` module by default. + +This is achieved through a very flexible class-based Exporter system, which can +be found in `hslog.export`. +The syntax to call an exporter directly is: `MyExporter(packet_tree).export()`. + +The base logic for the Exporter is in the `BaseExporter` class. +Calling `export()` will iterate over each packet and call `export_packet(packet)` +on them. That method will look at the packet's type, get the matching method in +the `self.dispatch` dict (populated by `get_dispatch_dict()`) and call it on it. + +This is the default dispatch lookup: + +* `CreateGame` -> `handle_create_game()` +* `CreateGame.Player`: `handle_player()` +* `Block`: `handle_block` +* `FullEntity`: `handle_full_entity` +* `HideEntity`: `handle_hide_entity` +* `ShowEntity`: `handle_show_entity` +* `ChangeEntity`: `handle_change_entity` +* `TagChange`: `handle_tag_change` +* `MetaData`: `handle_metadata` +* `Choices`: `handle_choices` +* `SendChoices`: `handle_send_choices` +* `ChosenEntities`: `handle_chosen_entities` +* `Options`: `handle_options` +* `Option`: `handle_option` +* `SendOption`: `handle_send_option` + +All of the methods in the dispatch dict should be implemented. + +The default exporter used by `PacketTree` is the `EntityTreeExporter`. It +creates an "Entity Tree" by simulating each packet in its handler. Choices, +Options and MetaData packets are ignored. diff --git a/hslog/__init__.py b/hslog/__init__.py new file mode 100644 index 0000000..2cd9f0a --- /dev/null +++ b/hslog/__init__.py @@ -0,0 +1 @@ +from .parser import LogParser # noqa diff --git a/hslog/exceptions.py b/hslog/exceptions.py new file mode 100644 index 0000000..f7899f1 --- /dev/null +++ b/hslog/exceptions.py @@ -0,0 +1,11 @@ +""" +Log parsing exceptions +""" + + +class ParsingError(Exception): + pass + + +class RegexParsingError(ParsingError): + pass diff --git a/hslog/export.py b/hslog/export.py new file mode 100644 index 0000000..44fca93 --- /dev/null +++ b/hslog/export.py @@ -0,0 +1,198 @@ +from hearthstone.entities import Card, Game, Player +from hearthstone.enums import GameTag, Zone +from . import packets + + +class BaseExporter(object): + def __init__(self, packet_tree): + self.packet_tree = packet_tree + self.dispatch = self.get_dispatch_dict() + + def get_dispatch_dict(self): + return { + packets.CreateGame: self.handle_create_game, + packets.CreateGame.Player: self.handle_player, + packets.Block: self.handle_block, + packets.FullEntity: self.handle_full_entity, + packets.HideEntity: self.handle_hide_entity, + packets.ShowEntity: self.handle_show_entity, + packets.ChangeEntity: self.handle_change_entity, + packets.TagChange: self.handle_tag_change, + packets.MetaData: self.handle_metadata, + packets.Choices: self.handle_choices, + packets.SendChoices: self.handle_send_choices, + packets.ChosenEntities: self.handle_chosen_entities, + packets.Options: self.handle_options, + packets.Option: self.handle_option, + packets.SendOption: self.handle_send_option, + } + + def export(self): + for packet in self.packet_tree: + self.export_packet(packet) + return self + + def export_packet(self, packet): + packet_type = packet.__class__ + handler = self.dispatch.get(packet_type, None) + if not handler: + raise NotImplementedError("Don't know how to export %r" % (packet_type)) + handler(packet) + + def handle_create_game(self, packet): + pass + + def handle_player(self, packet): + pass + + def handle_block(self, packet): + for p in packet.packets: + self.export_packet(p) + + def handle_full_entity(self, packet): + pass + + def handle_hide_entity(self, packet): + pass + + def handle_show_entity(self, packet): + pass + + def handle_change_entity(self, packet): + pass + + def handle_tag_change(self, packet): + pass + + def handle_metadata(self, packet): + pass + + def handle_choices(self, packet): + pass + + def handle_send_choices(self, packet): + pass + + def handle_chosen_entities(self, packet): + pass + + def handle_options(self, packet): + pass + + def handle_option(self, packet): + pass + + def handle_send_option(self, packet): + pass + + +class EntityTreeExporter(BaseExporter): + game_class = Game + player_class = Player + card_class = Card + + class EntityNotFound(Exception): + pass + + def find_entity(self, id, opcode): + try: + entity = self.game.find_entity_by_id(id) + except RuntimeError as e: + raise self.EntityNotFound("Error getting entity %r for %s" % (id, opcode)) + if not entity: + raise self.EntityNotFound("Attempting %s on entity %r (not found)" % (opcode, id)) + return entity + + def handle_create_game(self, packet): + self.game = self.game_class(packet.entity) + self.game.create(packet.tags) + for player in packet.players: + self.export_packet(player) + return self.game + + def handle_player(self, packet): + id = int(packet.entity) + if hasattr(self.packet_tree, "manager"): + # If we have a PlayerManager, first we mutate the CreateGame.Player packet. + # This will have to change if we're ever able to immediately get the names. + player = self.packet_tree.manager.get_player_by_id(id) + packet.name = player.name + entity = self.player_class(id, packet.player_id, packet.hi, packet.lo, packet.name) + entity.tags = dict(packet.tags) + self.game.register_entity(entity) + return entity + + def handle_full_entity(self, packet): + entity = self.card_class(packet.entity, packet.card_id) + entity.tags = dict(packet.tags) + self.game.register_entity(entity) + return entity + + def handle_hide_entity(self, packet): + entity = self.find_entity(packet.entity, "HIDE_ENTITY") + entity.hide() + return entity + + def handle_show_entity(self, packet): + entity = self.find_entity(packet.entity, "SHOW_ENTITY") + entity.reveal(packet.card_id, dict(packet.tags)) + return entity + + def handle_change_entity(self, packet): + entity = self.find_entity(packet.entity, "CHANGE_ENTITY") + entity.change(packet.card_id, dict(packet.tags)) + return entity + + def handle_tag_change(self, packet): + entity = self.find_entity(packet.entity, "TAG_CHANGE") + entity.tag_change(packet.tag, packet.value) + return entity + + +class FriendlyPlayerExporter(BaseExporter): + """ + An exporter that will attempt to guess the friendly player in the game by + looking for initial unrevealed cards. + May produce incorrect results in spectator mode if both hands are revealed. + """ + def __init__(self, packet_tree): + super(FriendlyPlayerExporter, self).__init__(packet_tree) + self._controller_map = {} + self.friendly_player = None + + def export(self): + for packet in self.packet_tree: + self.export_packet(packet) + if self.friendly_player: + # Stop export once we have it + break + return self.friendly_player + + def handle_tag_change(self, packet): + if packet.tag == GameTag.CONTROLLER: + self._controller_map[packet.entity] = packet.value + + def handle_full_entity(self, packet): + tags = dict(packet.tags) + if GameTag.CONTROLLER in tags: + self._controller_map[packet.entity] = tags[GameTag.CONTROLLER] + + # The following logic only works for pre-13619 logs + # The first FULL_ENTITY packet which is in Zone.HAND and does *not* + # have an ID is owned by the friendly player's *opponent*. + if tags[GameTag.ZONE] == Zone.HAND and not packet.card_id: + controller = self._controller_map[packet.entity] + # That controller is the enemy player - return its opponent. + self.friendly_player = controller % 2 + 1 + + def handle_show_entity(self, packet): + tags = dict(packet.tags) + if GameTag.CONTROLLER in tags: + self._controller_map[packet.entity] = tags[GameTag.CONTROLLER] + + if tags.get(GameTag.ZONE) != Zone.HAND: + # Ignore cards already in play (such as enchantments, common in TB) + return + + # The first SHOW_ENTITY packet will always be the friendly player's. + self.friendly_player = self._controller_map[packet.entity] diff --git a/hslog/packets.py b/hslog/packets.py new file mode 100644 index 0000000..6eacba9 --- /dev/null +++ b/hslog/packets.py @@ -0,0 +1,220 @@ +from hearthstone.enums import PowerType + + +class PacketTree: + def __init__(self, ts): + self.ts = ts + self.packets = [] + self.parent = None + + def __iter__(self): + for packet in self.packets: + yield packet + + @property + def start_time(self): + for packet in self.packets: + if packet.ts: + return packet.ts + + @property + def end_time(self): + for packet in self.packets[::-1]: + if packet.ts: + return packet.ts + + def export(self, cls=None): + if cls is None: + from .export import EntityTreeExporter as cls + exporter = cls(self) + return exporter.export() + + +class Packet: + power_type = 0 + + def __repr__(self): + return "<%s>" % (self.__class__.__name__) + + +class Block(Packet): + power_type = PowerType.BLOCK_START + + def __init__(self, ts, entity, type, index, effectid, effectindex, target): + self.ts = ts + self.entity = entity + self.type = type + self.index = index + self.effectid = effectid + self.effectindex = effectindex + self.target = target + self.ended = False + self.packets = [] + + def __iter__(self): + for packet in self.packets: + yield packet + + def __repr__(self): + return "%s(entity=%r, type=%r, index=%r, target=%r)" % ( + self.__class__.__name__, self.entity, self.type, self.index, self.target + ) + + def end(self): + self.ended = True + + def _export(self, game): + for packet in self.packets: + packet._export(game) + + +class MetaData(Packet): + power_type = PowerType.META_DATA + + def __init__(self, ts, meta, data, count): + self.ts = ts + self.meta = meta + self.data = data + self.count = count + self.info = [] + + def __repr__(self): + return "%s(meta=%r, data=%r)" % (self.__class__.__name__, self.meta, self.data) + + +class CreateGame(Packet): + power_type = PowerType.CREATE_GAME + + class Player: + def __init__(self, ts, id, player_id, hi, lo): + self.ts = ts + self.entity = id + self.player_id = player_id + self.hi = hi + self.lo = lo + self.tags = [] + self.name = None + + def __init__(self, ts, entity): + self.ts = ts + self.entity = entity + self.tags = [] + self.players = [] + + +class HideEntity(Packet): + power_type = PowerType.HIDE_ENTITY + + def __init__(self, ts, entity, zone): + self.ts = ts + self.entity = entity + self.zone = zone + + +class FullEntity(Packet): + power_type = PowerType.FULL_ENTITY + + def __init__(self, ts, entity, card_id): + self.ts = ts + self.entity = entity + self.card_id = card_id + self.tags = [] + + +class ShowEntity(Packet): + power_type = PowerType.SHOW_ENTITY + + def __init__(self, ts, entity, card_id): + self.ts = ts + self.entity = entity + self.card_id = card_id + self.tags = [] + + def __repr__(self): + return "%s(entity=%r, card_id=%r)" % ( + self.__class__.__name__, self.entity, self.card_id + ) + + +class ChangeEntity(Packet): + power_type = PowerType.CHANGE_ENTITY + + def __init__(self, ts, entity, card_id): + self.ts = ts + self.entity = entity + self.card_id = card_id + self.tags = [] + + +class TagChange(Packet): + power_type = PowerType.TAG_CHANGE + + def __init__(self, ts, entity, tag, value): + self.ts = ts + self.entity = entity + self.tag = tag + self.value = value + + +class Choices(Packet): + def __init__(self, ts, entity, id, tasklist, type, min, max): + self.ts = ts + self.entity = entity + self.id = id + self.tasklist = tasklist + self.type = type + self.min = min + self.max = max + self.source = None + self.choices = [] + + @property + def player(self): + return self.entity + + +class SendChoices(Packet): + def __init__(self, ts, id, type): + self.ts = ts + self.entity = None + self.id = id + self.type = type + self.choices = [] + + +class ChosenEntities(Packet): + def __init__(self, ts, entity, id): + self.ts = ts + self.entity = entity + self.id = id + self.choices = [] + + +class Options(Packet): + def __init__(self, ts, id): + self.ts = ts + self.entity = None + self.id = id + self.options = [] + + +class Option(Packet): + def __init__(self, ts, entity, id, type, optype, error, error_param): + self.ts = ts + self.entity = entity + self.id = id + self.type = type + self.optype = optype + self.error = error + self.error_param = error_param + self.options = [] + + +class SendOption(Packet): + def __init__(self, ts, option, suboption, target, position): + self.ts = ts + self.entity = None + self.option = option + self.suboption = suboption + self.target = target + self.position = position diff --git a/hslog/parser.py b/hslog/parser.py new file mode 100644 index 0000000..42b6ffe --- /dev/null +++ b/hslog/parser.py @@ -0,0 +1,634 @@ +import logging +from datetime import datetime, timedelta +from aniso8601 import parse_time +from hearthstone.enums import ( + BlockType, ChoiceType, GameTag, MetaDataType, Mulligan, OptionType, + PlayReq, PowerType +) +from . import packets, tokens +from .exceptions import ParsingError, RegexParsingError +from .player import LazyPlayer, PlayerManager +from .utils import parse_enum, parse_tag + + +def parse_entity_id(entity): + if entity.isdigit(): + return int(entity) + + if entity == tokens.GAME_ENTITY: + # GameEntity is always 1 + return 1 + + sre = tokens.ENTITY_RE.match(entity) + if sre: + id = sre.groups()[0] + return int(id) + + +def parse_initial_tag(data): + """ + Parse \a data, a line formatted as tag=FOO value=BAR + Returns the values as int. + """ + sre = tokens.TAG_VALUE_RE.match(data) + if not sre: + raise RegexParsingError(data) + tag, value = sre.groups() + return parse_tag(tag, value) + + +def clean_option_errors(error, error_param): + """ + As of 8.0.0.18336, all option packets are accompanied by an error and an + errorParam argument. + This function turns both into their respective types. + """ + + if error == "NONE": + error = None + else: + error = parse_enum(PlayReq, error) + + if not error_param: + error_param = None + else: + error_param = int(error_param) + + return error, error_param + + +class PowerHandler(object): + def __init__(self): + super(PowerHandler, self).__init__() + self.current_block = None + self._metadata_node = None + self._packets = None + self._creating_game = False + + def _check_for_mulligan_hack(self, ts, tag, value): + # Old game logs didn't handle asynchronous mulligans properly. + # If we're missing an ACTION_END packet after the mulligan SendChoices, + # we just close it out manually. + if tag == GameTag.MULLIGAN_STATE and value == Mulligan.DEALING: + assert self.current_block + if isinstance(self.current_block, packets.Block): + logging.warning("WARNING: Broken mulligan nesting. Working around...") + self.block_end(ts) + + def find_callback(self, method): + if method == self.parse_method("DebugPrintPower"): + return self.handle_data + + def handle_data(self, ts, data): + opcode = data.split()[0] + + if opcode in PowerType.__members__: + return self.handle_power(ts, opcode, data) + + if opcode == "GameEntity": + self.flush() + self._creating_game = True + sre = tokens.GAME_ENTITY_RE.match(data) + if not sre: + raise RegexParsingError(data) + id, = sre.groups() + if int(id) != 1: + raise ParsingError("GameEntity ID: Expected 1, got %r" % (id)) + elif opcode == "Player": + self.flush() + sre = tokens.PLAYER_ENTITY_RE.match(data) + if not sre: + raise RegexParsingError(data) + self.register_player(ts, *sre.groups()) + elif opcode.startswith("tag="): + tag, value = parse_initial_tag(data) + self._entity_packet.tags.append((tag, value)) + if tag == GameTag.CONTROLLER: + # We need to know entity controllers for player name registration + self._packets.manager.register_controller(self._entity_packet.entity, value) + elif opcode.startswith("Info["): + if not self._metadata_node: + logging.warning("Metadata Info outside of META_DATA: %r", data) + return + sre = tokens.METADATA_INFO_RE.match(data) + if not sre: + raise RegexParsingError(data) + idx, entity = sre.groups() + entity = self.parse_entity_or_player(entity) + self._metadata_node.info.append(entity) + else: + raise NotImplementedError(data) + + def flush(self): + super(PowerHandler, self).flush() + if self._metadata_node: + self._metadata_node = None + + def handle_power(self, ts, opcode, data): + self.flush() + + if opcode == "CREATE_GAME": + regex, callback = tokens.CREATE_GAME_RE, self.create_game + elif opcode in ("ACTION_START", "BLOCK_START"): + sre = tokens.BLOCK_START_RE.match(data) + if sre is None: + sre = tokens.ACTION_START_OLD_RE.match(data) + if not sre: + raise RegexParsingError(data) + entity, type, index, target = sre.groups() + effectid, effectindex = None, None + else: + type, entity, effectid, effectindex, target = sre.groups() + index = None + self.block_start(ts, entity, type, index, effectid, effectindex, target) + return + elif opcode in ("ACTION_END", "BLOCK_END"): + regex, callback = tokens.BLOCK_END_RE, self.block_end + elif opcode == "FULL_ENTITY": + if data.startswith("FULL_ENTITY - Updating"): + regex, callback = tokens.FULL_ENTITY_UPDATE_RE, self.full_entity_update + else: + regex, callback = tokens.FULL_ENTITY_CREATE_RE, self.full_entity + elif opcode == "SHOW_ENTITY": + regex, callback = tokens.SHOW_ENTITY_RE, self.show_entity + elif opcode == "HIDE_ENTITY": + regex, callback = tokens.HIDE_ENTITY_RE, self.hide_entity + elif opcode == "CHANGE_ENTITY": + regex, callback = tokens.CHANGE_ENTITY_RE, self.change_entity + elif opcode == "TAG_CHANGE": + regex, callback = tokens.TAG_CHANGE_RE, self.tag_change + elif opcode == "META_DATA": + regex, callback = tokens.META_DATA_RE, self.meta_data + else: + raise NotImplementedError(data) + + sre = regex.match(data) + if not sre: + logging.warning("Could not correctly parse %r", data) + return + return callback(ts, *sre.groups()) + + # Messages + def create_game(self, ts): + entity_id = 1 + self._packets = packets.PacketTree(ts) + self._packets.spectator_mode = self.spectator_mode + self._packets.manager = PlayerManager() + self._entity_packet = packets.CreateGame(ts, entity_id) + self._game_packet = self._entity_packet + self.current_block = self._packets + self.current_block.packets.append(self._entity_packet) + self.games.append(self._packets) + return self._game_packet + + def block_start(self, ts, entity, type, index, effectid, effectindex, target): + id = self.parse_entity_or_player(entity) + type = parse_enum(BlockType, type) + if index is not None: + index = int(index) + target = self.parse_entity_or_player(target) + block = packets.Block(ts, id, type, index, effectid, effectindex, target) + block.parent = self.current_block + self.current_block.packets.append(block) + self.current_block = block + return block + + def block_end(self, ts): + if not self.current_block.parent: + logging.warning("[%s] Orphaned BLOCK_END detected", ts) + return self.current_block + self.current_block.end() + block = self.current_block + self.current_block = self.current_block.parent + return block + + def full_entity(self, ts, id, card_id): + id = int(id) + self._entity_packet = packets.FullEntity(ts, id, card_id) + self.current_block.packets.append(self._entity_packet) + + if self._creating_game: + # First packet after create game should always be a FULL_ENTITY + self._creating_game = False + # It should always have ID 4 + if id != 4: + raise ParsingError("Expected entity 4 after creating game, got %r" % (id)) + + # While we're at it, we check if we got an abnormal amount of players + player_count = len(self._game_packet.players) + if player_count != 2: + raise ParsingError("Expected exactly 2 players, got %r" % (player_count)) + + return self._entity_packet + + def full_entity_update(self, ts, entity, card_id): + id = parse_entity_id(entity) + return self.full_entity(ts, id, card_id) + + def show_entity(self, ts, entity, card_id): + id = parse_entity_id(entity) + self._entity_packet = packets.ShowEntity(ts, id, card_id) + self.current_block.packets.append(self._entity_packet) + return self._entity_packet + + def hide_entity(self, ts, entity, tag, value): + id = parse_entity_id(entity) + tag, value = parse_tag(tag, value) + if tag != GameTag.ZONE: + raise ParsingError("HIDE_ENTITY got non-zone tag (%r)" % (tag)) + packet = packets.HideEntity(ts, id, value) + self.current_block.packets.append(packet) + return packet + + def change_entity(self, ts, entity, card_id): + id = self.parse_entity_or_player(entity) + self._entity_packet = packets.ChangeEntity(ts, id, card_id) + self.current_block.packets.append(self._entity_packet) + return self._entity_packet + + def meta_data(self, ts, meta, data, info): + meta = parse_enum(MetaDataType, meta) + if meta == MetaDataType.JOUST: + data = parse_entity_id(data) + count = int(info) + self._metadata_node = packets.MetaData(ts, meta, data, count) + self.current_block.packets.append(self._metadata_node) + return self._metadata_node + + def tag_change(self, ts, e, tag, value): + id = self.parse_entity_or_player(e) + tag, value = parse_tag(tag, value) + self._check_for_mulligan_hack(ts, tag, value) + + if isinstance(id, LazyPlayer): + id = self._packets.manager.register_player_name_on_tag_change(id, tag, value) + + packet = packets.TagChange(ts, id, tag, value) + self.current_block.packets.append(packet) + return packet + + +class OptionsHandler(object): + def __init__(self): + super(OptionsHandler, self).__init__() + self._option_packet = None + self._options_packet = None + + def find_callback(self, method): + if method == self.parse_method("SendOption"): + return self.handle_send_option + elif method == self.parse_method("DebugPrintOptions"): + return self.handle_options + + def _parse_option_packet(self, ts, data): + if " errorParam=" in data: + sre = tokens.OPTIONS_OPTION_ERROR_RE.match(data) + optype, id, type, entity, error, error_param = sre.groups() + if not sre: + raise RegexParsingError(data) + error, error_param = clean_option_errors(error, error_param) + else: + sre = tokens.OPTIONS_OPTION_RE.match(data) + if not sre: + raise RegexParsingError(data) + optype, id, type, entity = sre.groups() + error, error_param = None, None + + id = int(id) + type = parse_enum(OptionType, type) + if entity: + entity = self.parse_entity_or_player(entity) + self._option_packet = packets.Option(ts, entity, id, type, optype, error, error_param) + if not self._options_packet: + raise ParsingError("Option without a parent option group: %r" % (data)) + + self._options_packet.options.append(self._option_packet) + self._suboption_packet = None + + return self._option_packet + + def _parse_suboption_packet(self, ts, data): + if " errorParam=" in data: + sre = tokens.OPTIONS_SUBOPTION_ERROR_RE.match(data) + if not sre: + raise RegexParsingError(data) + optype, id, entity, error, error_param = sre.groups() + error, error_param = clean_option_errors(error, error_param) + else: + sre = tokens.OPTIONS_SUBOPTION_RE.match(data) + if not sre: + raise RegexParsingError(data) + optype, id, entity = sre.groups() + error, error_param = None, None + + id = int(id) + if not entity: + raise ParsingError("SubOption / target got an empty entity: %r" % (data)) + + entity = self.parse_entity_or_player(entity) + packet = packets.Option(ts, entity, id, None, optype, error, error_param) + if optype == "subOption": + self._suboption_packet = packet + node = self._option_packet + elif optype == "target": + node = self._suboption_packet or self._option_packet + + node.options.append(packet) + + return packet + + def handle_options(self, ts, data): + if data.startswith("id="): + sre = tokens.OPTIONS_ENTITY_RE.match(data) + if not sre: + raise RegexParsingError(data) + id, = sre.groups() + id = int(id) + self._options_packet = packets.Options(ts, id) + self.current_block.packets.append(self._options_packet) + elif data.startswith("option "): + return self._parse_option_packet(ts, data) + elif data.startswith(("subOption ", "target ")): + return self._parse_suboption_packet(ts, data) + + def handle_send_option(self, ts, data): + if data.startswith("selectedOption="): + sre = tokens.SEND_OPTION_RE.match(data) + if not sre: + raise RegexParsingError(data) + option, suboption, target, position = sre.groups() + packet = packets.SendOption(ts, int(option), int(suboption), int(target), int(position)) + self.current_block.packets.append(packet) + return packet + raise NotImplementedError("Unhandled send option: %r" % (data)) + + +class ChoicesHandler(object): + def __init__(self): + super(ChoicesHandler, self).__init__() + self._choice_packet = None + self._chosen_packet = None + self._send_choice_packet = None + + def find_callback(self, method): + if method == self.parse_method("DebugPrintEntityChoices"): + return self.handle_entity_choices + elif method == self.parse_method("DebugPrintChoices"): + return self.handle_entity_choices_old + elif method == self.parse_method("SendChoices"): + return self.handle_send_choices + elif method == self.parse_method("DebugPrintEntitiesChosen"): + return self.handle_entities_chosen + + def flush(self): + if self._choice_packet: + if self._choice_packet.type == ChoiceType.MULLIGAN: + self._packets.manager.register_player_name_mulligan(self._choice_packet) + self._choice_packet = None + if self._chosen_packet: + self._chosen_packet = None + if self._send_choice_packet: + self._send_choice_packet = None + + def handle_entity_choices_old(self, ts, data): + if data.startswith("id="): + sre = tokens.CHOICES_CHOICE_OLD_1_RE.match(data) + if sre: + self.register_choices_old_1(ts, *sre.groups()) + else: + sre = tokens.CHOICES_CHOICE_OLD_2_RE.match(data) + if not sre: + raise RegexParsingError(data) + self.register_choices_old_2(ts, *sre.groups()) + else: + return self.handle_entity_choices(ts, data) + + def handle_entity_choices(self, ts, data): + if data.startswith("id="): + sre = tokens.CHOICES_CHOICE_RE.match(data) + if not sre: + raise RegexParsingError(data) + return self.register_choices(ts, *sre.groups()) + elif data.startswith("Source="): + sre = tokens.CHOICES_SOURCE_RE.match(data) + if not sre: + raise RegexParsingError(data) + entity, = sre.groups() + id = self.parse_entity_or_player(entity) + if not self._choice_packet: + raise ParsingError("Source Choice Entity outside of choie packet: %r" % (data)) + self._choice_packet.source = id + return id + elif data.startswith("Entities["): + sre = tokens.CHOICES_ENTITIES_RE.match(data) + if not sre: + raise RegexParsingError(data) + idx, entity = sre.groups() + id = self.parse_entity_or_player(entity) + if not id: + raise ParsingError("Missing choice entity %r (%r)" % (id, entity)) + if not self._choice_packet: + raise ParsingError("Choice Entity outside of choice packet: %r" % (data)) + self._choice_packet.choices.append(id) + return id + raise NotImplementedError("Unhandled entity choice: %r" % (data)) + + def _register_choices(self, ts, id, player, tasklist, type, min, max): + id = int(id) + type = parse_enum(ChoiceType, type) + min, max = int(min), int(max) + self._choice_packet = packets.Choices(ts, player, id, tasklist, type, min, max) + self.current_block.packets.append(self._choice_packet) + return self._choice_packet + + def register_choices_old_1(self, ts, id, type): + player = None + # XXX: We don't have a player here for old games. + # Is it safe to assume CURRENT_PLAYER? + tasklist = None + min, max = 0, 0 + return self._register_choices(ts, id, player, tasklist, type, min, max) + + def register_choices_old_2(self, ts, id, player_id, type, min, max): + player_id = int(player_id) + player = self._packets.manager._players_by_player_id[player_id] + tasklist = None + return self._register_choices(ts, id, player, tasklist, type, min, max) + + def register_choices(self, ts, id, player, tasklist, type, min, max): + player = self.parse_entity_or_player(player) + if tasklist is not None: + # Sometimes tasklist is empty + tasklist = int(tasklist) + return self._register_choices(ts, id, player, tasklist, type, min, max) + + def handle_send_choices(self, ts, data): + if data.startswith("id="): + sre = tokens.SEND_CHOICES_CHOICE_RE.match(data) + if not sre: + raise RegexParsingError(data) + id, type = sre.groups() + id = int(id) + type = parse_enum(ChoiceType, type) + self._send_choice_packet = packets.SendChoices(ts, id, type) + self.current_block.packets.append(self._send_choice_packet) + return self._send_choice_packet + elif data.startswith("m_chosenEntities"): + sre = tokens.SEND_CHOICES_ENTITIES_RE.match(data) + if not sre: + raise RegexParsingError(data) + idx, entity = sre.groups() + id = self.parse_entity_or_player(entity) + if not id: + raise ParsingError("Missing chosen entity %r (%r)" % (id, entity)) + if not self._send_choice_packet: + raise ParsingError("Chosen Entity outside of choice packet: %r" % (data)) + self._send_choice_packet.choices.append(id) + return id + raise NotImplementedError("Unhandled send choice: %r" % (data)) + + def handle_entities_chosen(self, ts, data): + if data.startswith("id="): + sre = tokens.ENTITIES_CHOSEN_RE.match(data) + if not sre: + raise RegexParsingError(data) + id, player, count = sre.groups() + id = int(id) + player = self.parse_entity_or_player(player) + self._chosen_packet_count = int(count) + self._chosen_packet = packets.ChosenEntities(ts, player, id) + self.current_block.packets.append(self._chosen_packet) + return self._chosen_packet + elif data.startswith("Entities["): + sre = tokens.ENTITIES_CHOSEN_ENTITIES_RE.match(data) + if not sre: + raise RegexParsingError(data) + idx, entity = sre.groups() + id = self.parse_entity_or_player(entity) + if not id: + raise ParsingError("Missing entity chosen %r (%r)" % (id, entity)) + if not self._chosen_packet: + raise ParsingError("Entity Chosen outside of choice packet: %r" % (data)) + self._chosen_packet.choices.append(id) + if len(self._chosen_packet.choices) > self._chosen_packet_count: + raise ParsingError("Too many choices (expected %r)" % (self._chosen_packet_count)) + return id + raise NotImplementedError("Unhandled entities chosen: %r" % (data)) + + +class SpectatorModeHandler(object): + def __init__(self): + super(SpectatorModeHandler, self).__init__() + self.spectating_first_player = False + self.spectating_second_player = False + + @property + def spectator_mode(self): + return self.spectating_first_player or self.spectating_second_player + + def set_spectating(self, first, second=None): + self.spectating_first_player = first + if second is not None: + self.spectating_second_player = second + + def process_spectator_mode(self, line): + if line == tokens.SPECTATOR_MODE_BEGIN_GAME: + self.set_spectating(True) + elif line == tokens.SPECTATOR_MODE_BEGIN_FIRST: + self.set_spectating(True, False) + elif line == tokens.SPECTATOR_MODE_BEGIN_SECOND: + self.set_spectating(True, True) + elif line == tokens.SPECTATOR_MODE_END_MODE: + self.set_spectating(False, False) + elif line == tokens.SPECTATOR_MODE_END_GAME: + self.set_spectating(False, False) + else: + raise NotImplementedError("Unhandled spectator mode: %r" % (line)) + + +class LogParser(PowerHandler, ChoicesHandler, OptionsHandler, SpectatorModeHandler): + def __init__(self): + super(LogParser, self).__init__() + self.games = [] + self.line_regex = tokens.POWERLOG_LINE_RE + self._game_state_processor = "GameState" + self._current_date = None + self._synced_timestamp = False + + def parse_timestamp(self, ts, method): + ret = parse_time(ts) + + if not self._synced_timestamp: + # The first timestamp we parse requires syncing the time + # (in case _current_date is greater than the start date) + if self._current_date is not None: + self._current_date = self._current_date.replace( + hour=ret.hour, + minute=ret.minute, + second=ret.second, + microsecond=ret.microsecond, + ) + # Only do it once per parse tree + self._synced_timestamp = True + + # Logs don't have dates :( + if self._current_date is None: + # No starting date is available. Return just the time. + return ret + + ret = datetime.combine(self._current_date, ret) + ret = ret.replace(tzinfo=self._current_date.tzinfo) + if ret < self._current_date: + # If the new date falls before the last saved date, that + # means we rolled over and need to increment the day by 1. + ret += timedelta(days=1) + self._current_date = ret + return ret + + def read(self, fp): + for line in fp: + self.read_line(line) + + def read_line(self, line): + sre = tokens.TIMESTAMP_RE.match(line) + if not sre: + raise RegexParsingError(line) + level, ts, line = sre.groups() + if line.startswith(tokens.SPECTATOR_MODE_TOKEN): + line = line.replace(tokens.SPECTATOR_MODE_TOKEN, "").strip() + return self.process_spectator_mode(line) + + sre = self.line_regex.match(line) + if not sre: + return + method, msg = sre.groups() + msg = msg.strip() + if not self.current_block and "CREATE_GAME" not in msg: + # Ignore messages before the first CREATE_GAME packet + return + + for handler in PowerHandler, ChoicesHandler, OptionsHandler: + callback = handler.find_callback(self, method) + if callback: + ts = self.parse_timestamp(ts, method) + return callback(ts, msg) + + def parse_entity_or_player(self, entity): + id = parse_entity_id(entity) + if id is None: + # Only case where an id is None is if it's a Player name + id = self._packets.manager.get_player_by_name(entity) + return id + + def parse_method(self, m): + return "%s.%s" % (self._game_state_processor, m) + + def register_player(self, ts, id, player_id, hi, lo): + id = int(id) + player_id = int(player_id) + hi = int(hi) + lo = int(lo) + lazy_player = self._packets.manager.new_player(id, player_id, is_ai=lo == 0) + self._entity_packet = packets.CreateGame.Player(ts, lazy_player, player_id, hi, lo) + self._game_packet.players.append(self._entity_packet) + return lazy_player diff --git a/hslog/player.py b/hslog/player.py new file mode 100644 index 0000000..609fe54 --- /dev/null +++ b/hslog/player.py @@ -0,0 +1,140 @@ +""" +Classes to provide lazy players that are treatable as an entity ID but +do not have to receive one immediately. +""" +from hearthstone.enums import GameTag +from .exceptions import ParsingError + + +UNKNOWN_HUMAN_PLAYER = "UNKNOWN HUMAN PLAYER" + + +class LazyPlayer: + def __init__(self, *args, **kwargs): + self.id = None + self.name = None + + def __repr__(self): + return "%s(id=%r, name=%r)" % (self.__class__.__name__, self.id, self.name) + + def __int__(self): + if not self.id: + raise RuntimeError("Entity ID not available for player %r" % (self.name)) + return self.id + + +class PlayerManager: + def __init__(self): + self._players_by_id = {} + self._players_by_name = {} + self._players_by_player_id = {} + self._entity_controller_map = {} + self._registered_names = [] + self._unregistered_names = set() + self.ai_player = None + + def get_player_by_id(self, id): + assert id, "Expected an id for get_player_by_id (got %r)" % (id) + if id not in self._players_by_id: + lazy_player = LazyPlayer() + lazy_player.id = id + self._players_by_id[id] = lazy_player + return self._players_by_id[id] + + def get_player_by_name(self, name): + assert name, "Expected a name for get_player_by_name (got %r)" % (name) + if name not in self._players_by_name: + if len(self._registered_names) == 1 and name != UNKNOWN_HUMAN_PLAYER: + # Maybe we can figure the name out right there and then + other_player = self.get_player_by_name(self._registered_names[0]) + id = 3 if other_player == 2 else 2 + self.register_player_name(name, id) + elif len(self._registered_names) > 1 and self.ai_player: + # If we are registering our 3rd (or more) name, and we are in an AI game... + # then it's probably the innkeeper with a new name. + self.register_player_name(name, int(self.ai_player)) + else: + lazy_player = LazyPlayer() + lazy_player.name = name + self._players_by_name[name] = lazy_player + self._unregistered_names.add(name) + return self._players_by_name[name] + + def new_player(self, id, player_id, is_ai): + lazy_player = self.get_player_by_id(id) + self._players_by_player_id[player_id] = lazy_player + if is_ai: + self.ai_player = lazy_player + return lazy_player + + def register_controller(self, entity, controller): + self._entity_controller_map[entity] = controller + + def register_player_name(self, name, id): + """ + Registers a link between \a name and \a id. + Note that this does not support two different players with the same name. + """ + if name in self._players_by_name: + self._players_by_name[name].id = id + self._unregistered_names.remove(name) + self._players_by_name[name] = id + lazy_player_by_id = self._players_by_id[id] + lazy_player_by_id.name = name + self._registered_names.append(name) + + if len(self._unregistered_names) == 1: + assert len(self._players_by_id) == 2 + id1, id2 = self._players_by_id.keys() + other_id = id2 if id == id1 else id1 + other_name = list(self._unregistered_names)[0] + self.register_player_name(other_name, other_id) + + return lazy_player_by_id + + def register_player_name_mulligan(self, packet): + """ + Attempt to register player names by looking at Mulligan choice packets. + In Hearthstone 6.0+, registering a player name using tag changes is not + available as early as before. That means games conceded at Mulligan no + longer have player names. + This technique uses the cards offered in Mulligan instead, registering + the name of the packet's entity with the card's controller as PlayerID. + """ + lazy_player = packet.entity + if isinstance(lazy_player, int) or lazy_player.id: + # The player is already registered, ignore. + return + if not lazy_player.name: + # If we don't have the player name, we can't use this at all + return + + for choice in packet.choices: + if choice not in self._entity_controller_map: + raise ParsingError("Unknown entity ID in choice: %r" % (choice)) + player_id = self._entity_controller_map[choice] + # We need ENTITY_ID for register_player_name() + entity_id = int(self._players_by_player_id[player_id]) + packet.entity = entity_id + return self.register_player_name(lazy_player.name, entity_id) + + def register_player_name_on_tag_change(self, player, tag, value): + """ + Triggers on every TAG_CHANGE where the corresponding entity is a LazyPlayer. + Will attempt to return a new value instead + """ + if tag == GameTag.ENTITY_ID: + # This is the simplest check. When a player entity is declared, + # its ENTITY_ID is not available immediately (in pre-6.0). + # If we get a matching ENTITY_ID, then we can use that to match it. + return self.register_player_name(player.name, value) + elif tag == GameTag.LAST_CARD_PLAYED: + # This is a fallback to register_player_name_mulligan in case the mulligan + # phase is not available in this game (spectator mode, reconnects). + if value not in self._entity_controller_map: + raise ParsingError("Unknown entity ID on TAG_CHANGE: %r" % (value)) + player_id = self._entity_controller_map[value] + entity_id = int(self._players_by_player_id[player_id]) + return self.register_player_name(player.name, entity_id) + + return player diff --git a/hslog/tokens.py b/hslog/tokens.py new file mode 100644 index 0000000..79eac00 --- /dev/null +++ b/hslog/tokens.py @@ -0,0 +1,61 @@ +import re + + +# Entity format +GAME_ENTITY = "GameEntity" +_E = r"(%s|UNKNOWN HUMAN PLAYER|\[.+\]|\d+|.+)" % (GAME_ENTITY) +ENTITY_RE = re.compile("\[.*\s*id=(\d+)\s*.*\]") + +# Line format +TIMESTAMP_POWERLOG_FORMAT = r"%H:%M:%S.%f" +TIMESTAMP_RE = re.compile(r"^(D|W) ([\d:.]+) (.+)$") +POWERLOG_LINE_RE = re.compile(r"([^(]+)\(\) - (.+)$") +OUTPUTLOG_LINE_RE = re.compile(r"\[Power\] ()([^(]+)\(\) - (.+)$") + +# Game / Player +GAME_ENTITY_RE = re.compile(r"GameEntity EntityID=(\d+)") +PLAYER_ENTITY_RE = re.compile(r"Player EntityID=(\d+) PlayerID=(\d+) GameAccountId=\[hi=(\d+) lo=(\d+)\]$") + +# Messages +CREATE_GAME_RE = re.compile(r"^CREATE_GAME$") +ACTION_START_OLD_RE = re.compile(r"ACTION_START Entity=%s (?:SubType|BlockType)=(\w+) Index=(-1|\d+) Target=%s$" % (_E, _E)) +BLOCK_START_RE = re.compile(r"(?:ACTION|BLOCK)_START (?:SubType|BlockType)=(\w+) Entity=%s EffectCardId=(.*) EffectIndex=(-1|\d+) Target=%s$" % (_E, _E)) # Changed in 12051 +BLOCK_END_RE = re.compile(r"^(?:ACTION|BLOCK)_END$") +FULL_ENTITY_CREATE_RE = re.compile(r"FULL_ENTITY - Creating ID=(\d+) CardID=(\w+)?$") +FULL_ENTITY_UPDATE_RE = re.compile(r"FULL_ENTITY - Updating %s CardID=(\w+)?$" % _E) +SHOW_ENTITY_RE = re.compile(r"SHOW_ENTITY - Updating Entity=%s CardID=(\w+)$" % _E) +HIDE_ENTITY_RE = re.compile(r"HIDE_ENTITY - Entity=%s tag=(\w+) value=(\w+)$" % _E) +CHANGE_ENTITY_RE = re.compile(r"CHANGE_ENTITY - Updating Entity=%s CardID=(\w+)$" % _E) +TAG_CHANGE_RE = re.compile(r"TAG_CHANGE Entity=%s tag=(\w+) value=(\w+)" % _E) +META_DATA_RE = re.compile(r"META_DATA - Meta=(\w+) Data=%s Info=(\d+)" % _E) + +# Message details +TAG_VALUE_RE = re.compile(r"tag=(\w+) value=(\w+)") +METADATA_INFO_RE = re.compile(r"Info\[(\d+)\] = %s" % _E) + +# Choices +CHOICES_CHOICE_OLD_1_RE = re.compile(r"id=(\d+) ChoiceType=(\w+)$") +CHOICES_CHOICE_OLD_2_RE = re.compile(r"id=(\d+) PlayerId=(\d+) ChoiceType=(\w+) CountMin=(\d+) CountMax=(\d+)$") +CHOICES_CHOICE_RE = re.compile(r"id=(\d+) Player=%s TaskList=(\d+)? ChoiceType=(\w+) CountMin=(\d+) CountMax=(\d+)$" % _E) +CHOICES_SOURCE_RE = re.compile(r"Source=%s$" % _E) +CHOICES_ENTITIES_RE = re.compile(r"Entities\[(\d+)\]=(\[.+\])$") +SEND_CHOICES_CHOICE_RE = re.compile(r"id=(\d+) ChoiceType=(.+)$") +SEND_CHOICES_ENTITIES_RE = re.compile(r"m_chosenEntities\[(\d+)\]=(\[.+\])$") +ENTITIES_CHOSEN_RE = re.compile(r"id=(\d+) Player=%s EntitiesCount=(\d+)$" % _E) +ENTITIES_CHOSEN_ENTITIES_RE = re.compile(r"Entities\[(\d+)\]=%s$" % _E) + +# Options +OPTIONS_ENTITY_RE = re.compile(r"id=(\d+)$") +OPTIONS_OPTION_RE = re.compile(r"(option) (\d+) type=(\w+) mainEntity=%s?$" % _E) +OPTIONS_OPTION_ERROR_RE = re.compile(r"(option) (\d+) type=(\w+) mainEntity=%s? error=(\w+) errorParam=(\d+)?$" % _E) +OPTIONS_SUBOPTION_RE = re.compile(r"(subOption|target) (\d+) entity=%s?$" % _E) +OPTIONS_SUBOPTION_ERROR_RE = re.compile(r"(subOption|target) (\d+) entity=%s? error=(\w+) errorParam=(\d+)?$" % _E) +SEND_OPTION_RE = re.compile(r"selectedOption=(\d+) selectedSubOption=(-1|\d+) selectedTarget=(\d+) selectedPosition=(\d+)") + +# Spectator mode +SPECTATOR_MODE_TOKEN = "==================" +SPECTATOR_MODE_BEGIN_GAME = "Start Spectator Game" +SPECTATOR_MODE_BEGIN_FIRST = "Begin Spectating 1st player" +SPECTATOR_MODE_BEGIN_SECOND = "Begin Spectating 2nd player" +SPECTATOR_MODE_END_MODE = "End Spectator Mode" +SPECTATOR_MODE_END_GAME = "End Spectator Game" diff --git a/hslog/utils.py b/hslog/utils.py new file mode 100644 index 0000000..431c438 --- /dev/null +++ b/hslog/utils.py @@ -0,0 +1,22 @@ +from hearthstone.enums import GameTag, TAG_TYPES + + +def parse_enum(enum, value): + if value.isdigit(): + value = int(value) + elif hasattr(enum, value): + value = getattr(enum, value) + else: + raise Exception("Unhandled %s: %r" % (enum, value)) + return value + + +def parse_tag(tag, value): + tag = parse_enum(GameTag, tag) + if tag in TAG_TYPES: + value = parse_enum(TAG_TYPES[tag], value) + elif value.isdigit(): + value = int(value) + else: + raise NotImplementedError("Invalid string value %r = %r" % (tag, value)) + return tag, value diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/data.py b/tests/data.py new file mode 100644 index 0000000..9e1f569 --- /dev/null +++ b/tests/data.py @@ -0,0 +1,151 @@ +from __future__ import unicode_literals + + +EMPTY_GAME = """ +D 02:59:14.6088620 GameState.DebugPrintPower() - CREATE_GAME +D 02:59:14.6149420 GameState.DebugPrintPower() - GameEntity EntityID=1 +D 02:59:14.6446530 GameState.DebugPrintPower() - Player EntityID=2 PlayerID=1 GameAccountId=[hi=1 lo=0] +D 02:59:14.6481950 GameState.DebugPrintPower() - Player EntityID=3 PlayerID=2 GameAccountId=[hi=3 lo=2] +""".strip() + +INITIAL_GAME = """ +D 02:59:14.6088620 GameState.DebugPrintPower() - CREATE_GAME +D 02:59:14.6149420 GameState.DebugPrintPower() - GameEntity EntityID=1 +D 02:59:14.6420450 GameState.DebugPrintPower() - tag=TURN value=1 +D 02:59:14.6428100 GameState.DebugPrintPower() - tag=ZONE value=PLAY +D 02:59:14.6430430 GameState.DebugPrintPower() - tag=ENTITY_ID value=1 +D 02:59:14.6436240 GameState.DebugPrintPower() - tag=NEXT_STEP value=BEGIN_MULLIGAN +D 02:59:14.6438920 GameState.DebugPrintPower() - tag=CARDTYPE value=GAME +D 02:59:14.6442880 GameState.DebugPrintPower() - tag=STATE value=RUNNING +D 02:59:14.6446530 GameState.DebugPrintPower() - Player EntityID=2 PlayerID=1 GameAccountId=[hi=1 lo=0] +D 02:59:14.6450220 GameState.DebugPrintPower() - tag=PLAYSTATE value=PLAYING +D 02:59:14.6463220 GameState.DebugPrintPower() - tag=PLAYER_ID value=1 +D 02:59:14.6466060 GameState.DebugPrintPower() - tag=TEAM_ID value=1 +D 02:59:14.6469080 GameState.DebugPrintPower() - tag=ZONE value=PLAY +D 02:59:14.6470710 GameState.DebugPrintPower() - tag=CONTROLLER value=1 +D 02:59:14.6472580 GameState.DebugPrintPower() - tag=ENTITY_ID value=2 +D 02:59:14.6476340 GameState.DebugPrintPower() - tag=CARDTYPE value=PLAYER +D 02:59:14.6481950 GameState.DebugPrintPower() - Player EntityID=3 PlayerID=2 GameAccountId=[hi=3 lo=2] +D 02:59:14.6483770 GameState.DebugPrintPower() - tag=PLAYSTATE value=PLAYING +D 02:59:14.6485530 GameState.DebugPrintPower() - tag=CURRENT_PLAYER value=1 +D 02:59:14.6486970 GameState.DebugPrintPower() - tag=FIRST_PLAYER value=1 +D 02:59:14.6492590 GameState.DebugPrintPower() - tag=PLAYER_ID value=2 +D 02:59:14.6493880 GameState.DebugPrintPower() - tag=TEAM_ID value=2 +D 02:59:14.6495200 GameState.DebugPrintPower() - tag=ZONE value=PLAY +D 02:59:14.6496470 GameState.DebugPrintPower() - tag=CONTROLLER value=2 +D 02:59:14.6497780 GameState.DebugPrintPower() - tag=ENTITY_ID value=3 +D 02:59:14.6500380 GameState.DebugPrintPower() - tag=CARDTYPE value=PLAYER +""".strip() + +FULL_ENTITY = """D 22:25:48.0678873 GameState.DebugPrintPower() - FULL_ENTITY - Creating ID=4 CardID= +D 22:25:48.0678873 GameState.DebugPrintPower() - tag=ZONE value=DECK +D 22:25:48.0678873 GameState.DebugPrintPower() - tag=CONTROLLER value=1 +D 22:25:48.0678873 GameState.DebugPrintPower() - tag=ENTITY_ID value=4 +""".strip() + +INVALID_GAME = """ +D 02:59:14.6088620 GameState.DebugPrintPower() - CREATE_GAME +D 02:59:14.6149420 GameState.DebugPrintPower() - GameEntity EntityID=1 +D 02:59:14.6428100 GameState.DebugPrintPower() - tag=ZONE value=PLAY +D 02:59:14.6481950 GameState.DebugPrintPower() - Player EntityID=3 PlayerID=2 GameAccountId=[hi=3 lo=2] +D 02:59:14.6483770 GameState.DebugPrintPower() - tag=PLAYSTATE value=PLAYING +D 02:59:14.6492590 GameState.DebugPrintPower() - tag=PLAYER_ID value=2 +""".strip() + "\n" + FULL_ENTITY + +CONTROLLER_CHANGE = """ +D 22:25:48.0708939 GameState.DebugPrintPower() - TAG_CHANGE Entity=4 tag=CONTROLLER value=2 +""".strip() + +OPTIONS_WITH_ERRORS = """ +D 23:16:30.5267690 GameState.DebugPrintOptions() - id=38 +D 23:16:30.5274350 GameState.DebugPrintOptions() - option 0 type=END_TURN mainEntity= error=INVALID errorParam= +D 23:16:30.5292340 GameState.DebugPrintOptions() - option 1 type=POWER mainEntity=[name=Shadow Word: Pain id=33 zone=HAND zonePos=1 cardId=CS2_234 player=1] error=NONE errorParam= +D 23:16:30.5304620 GameState.DebugPrintOptions() - target 0 entity=[name=Friendly Bartender id=9 zone=PLAY zonePos=3 cardId=CFM_654 player=1] error=NONE errorParam= +D 23:16:30.5315490 GameState.DebugPrintOptions() - target 1 entity=[name=Flame Juggler id=26 zone=PLAY zonePos=4 cardId=AT_094 player=1] error=NONE errorParam= +D 23:16:30.5326920 GameState.DebugPrintOptions() - target 2 entity=[name=Friendly Bartender id=15 zone=PLAY zonePos=5 cardId=CFM_654 player=1] error=NONE errorParam= +D 23:16:30.5335050 GameState.DebugPrintOptions() - target 3 entity=[name=UNKNOWN ENTITY [cardType=INVALID] id=44 zone=HAND zonePos=1 cardId= player=2] error=NONE errorParam= +D 23:16:30.5343760 GameState.DebugPrintOptions() - target 4 entity=GameEntity error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5350590 GameState.DebugPrintOptions() - target 5 entity=BehEh error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5357980 GameState.DebugPrintOptions() - target 6 entity=The Innkeeper error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5365690 GameState.DebugPrintOptions() - target 7 entity=[name=Tyrande Whisperwind id=64 zone=PLAY zonePos=0 cardId=HERO_09a player=1] error=REQ_MINION_TARGET errorParam= +D 23:16:30.5378770 GameState.DebugPrintOptions() - target 8 entity=[name=Lesser Heal id=65 zone=PLAY zonePos=0 cardId=CS1h_001_H1 player=1] error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5387770 GameState.DebugPrintOptions() - target 9 entity=[name=Anduin Wrynn id=66 zone=PLAY zonePos=0 cardId=HERO_09 player=2] error=REQ_MINION_TARGET errorParam= +D 23:16:30.5396470 GameState.DebugPrintOptions() - target 10 entity=[name=Lesser Heal id=67 zone=PLAY zonePos=0 cardId=CS1h_001 player=2] error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5405690 GameState.DebugPrintOptions() - target 11 entity=[name=Chillwind Yeti id=37 zone=PLAY zonePos=1 cardId=CS2_182 player=2] error=REQ_TARGET_MAX_ATTACK errorParam=3 +D 23:16:30.5413980 GameState.DebugPrintOptions() - option 2 type=POWER mainEntity=[name=The Coin id=68 zone=HAND zonePos=3 cardId=GAME_005 player=1] error=NONE errorParam= +D 23:16:30.5422920 GameState.DebugPrintOptions() - option 3 type=POWER mainEntity=[name=Shadow Madness id=13 zone=HAND zonePos=4 cardId=EX1_334 player=1] error=NONE errorParam= +D 23:16:30.5431510 GameState.DebugPrintOptions() - target 0 entity=[name=UNKNOWN ENTITY [cardType=INVALID] id=44 zone=HAND zonePos=1 cardId= player=2] error=NONE errorParam= +D 23:16:30.5454830 GameState.DebugPrintOptions() - target 1 entity=GameEntity error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5477520 GameState.DebugPrintOptions() - target 2 entity=BehEh error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5485390 GameState.DebugPrintOptions() - target 3 entity=The Innkeeper error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5493210 GameState.DebugPrintOptions() - target 4 entity=[name=Tyrande Whisperwind id=64 zone=PLAY zonePos=0 cardId=HERO_09a player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.5507390 GameState.DebugPrintOptions() - target 5 entity=[name=Lesser Heal id=65 zone=PLAY zonePos=0 cardId=CS1h_001_H1 player=1] error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5517260 GameState.DebugPrintOptions() - target 6 entity=[name=Anduin Wrynn id=66 zone=PLAY zonePos=0 cardId=HERO_09 player=2] error=REQ_MINION_TARGET errorParam= +D 23:16:30.5527880 GameState.DebugPrintOptions() - target 7 entity=[name=Lesser Heal id=67 zone=PLAY zonePos=0 cardId=CS1h_001 player=2] error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5537070 GameState.DebugPrintOptions() - target 8 entity=[name=Friendly Bartender id=9 zone=PLAY zonePos=3 cardId=CFM_654 player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.5545530 GameState.DebugPrintOptions() - target 9 entity=[name=Flame Juggler id=26 zone=PLAY zonePos=4 cardId=AT_094 player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.5554740 GameState.DebugPrintOptions() - target 10 entity=[name=Friendly Bartender id=15 zone=PLAY zonePos=5 cardId=CFM_654 player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.5563420 GameState.DebugPrintOptions() - target 11 entity=[name=Chillwind Yeti id=37 zone=PLAY zonePos=1 cardId=CS2_182 player=2] error=REQ_TARGET_MAX_ATTACK errorParam=3 +D 23:16:30.5571950 GameState.DebugPrintOptions() - option 4 type=POWER mainEntity=[name=Prophet Velen id=22 zone=DECK zonePos=0 cardId= player=1] error=NONE errorParam= +D 23:16:30.5581040 GameState.DebugPrintOptions() - option 5 type=POWER mainEntity=[name=Lesser Heal id=65 zone=PLAY zonePos=0 cardId=CS1h_001_H1 player=1] error=NONE errorParam= +D 23:16:30.5590640 GameState.DebugPrintOptions() - target 0 entity=[name=Tyrande Whisperwind id=64 zone=PLAY zonePos=0 cardId=HERO_09a player=1] error=NONE errorParam= +D 23:16:30.5599700 GameState.DebugPrintOptions() - target 1 entity=[name=Anduin Wrynn id=66 zone=PLAY zonePos=0 cardId=HERO_09 player=2] error=NONE errorParam= +D 23:16:30.5609780 GameState.DebugPrintOptions() - target 2 entity=[name=Friendly Bartender id=9 zone=PLAY zonePos=3 cardId=CFM_654 player=1] error=NONE errorParam= +D 23:16:30.5617920 GameState.DebugPrintOptions() - target 3 entity=[name=Flame Juggler id=26 zone=PLAY zonePos=4 cardId=AT_094 player=1] error=NONE errorParam= +D 23:16:30.5626230 GameState.DebugPrintOptions() - target 4 entity=[name=Friendly Bartender id=15 zone=PLAY zonePos=5 cardId=CFM_654 player=1] error=NONE errorParam= +D 23:16:30.5634360 GameState.DebugPrintOptions() - target 5 entity=[name=Chillwind Yeti id=37 zone=PLAY zonePos=1 cardId=CS2_182 player=2] error=NONE errorParam= +D 23:16:30.5642140 GameState.DebugPrintOptions() - target 6 entity=[name=UNKNOWN ENTITY [cardType=INVALID] id=44 zone=HAND zonePos=1 cardId= player=2] error=NONE errorParam= +D 23:16:30.5649970 GameState.DebugPrintOptions() - target 7 entity=GameEntity error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5657040 GameState.DebugPrintOptions() - target 8 entity=BehEh error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5663840 GameState.DebugPrintOptions() - target 9 entity=The Innkeeper error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5853710 GameState.DebugPrintOptions() - target 10 entity=[name=Lesser Heal id=65 zone=PLAY zonePos=0 cardId=CS1h_001_H1 player=1] error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5866110 GameState.DebugPrintOptions() - target 11 entity=[name=Lesser Heal id=67 zone=PLAY zonePos=0 cardId=CS1h_001 player=2] error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5879360 GameState.DebugPrintOptions() - option 6 type=POWER mainEntity=[name=Friendly Bartender id=9 zone=PLAY zonePos=3 cardId=CFM_654 player=1] error=NONE errorParam= +D 23:16:30.5888760 GameState.DebugPrintOptions() - target 0 entity=[name=UNKNOWN ENTITY [cardType=INVALID] id=44 zone=HAND zonePos=1 cardId= player=2] error=NONE errorParam= +D 23:16:30.5899630 GameState.DebugPrintOptions() - target 1 entity=GameEntity error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5906880 GameState.DebugPrintOptions() - target 2 entity=BehEh error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5913550 GameState.DebugPrintOptions() - target 3 entity=The Innkeeper error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5922220 GameState.DebugPrintOptions() - target 4 entity=[name=Tyrande Whisperwind id=64 zone=PLAY zonePos=0 cardId=HERO_09a player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.5930550 GameState.DebugPrintOptions() - target 5 entity=[name=Lesser Heal id=65 zone=PLAY zonePos=0 cardId=CS1h_001_H1 player=1] error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5939620 GameState.DebugPrintOptions() - target 6 entity=[name=Lesser Heal id=67 zone=PLAY zonePos=0 cardId=CS1h_001 player=2] error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.5948320 GameState.DebugPrintOptions() - target 7 entity=[name=Friendly Bartender id=9 zone=PLAY zonePos=3 cardId=CFM_654 player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.5961510 GameState.DebugPrintOptions() - target 8 entity=[name=Flame Juggler id=26 zone=PLAY zonePos=4 cardId=AT_094 player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.5986050 GameState.DebugPrintOptions() - target 9 entity=[name=Friendly Bartender id=15 zone=PLAY zonePos=5 cardId=CFM_654 player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.5994940 GameState.DebugPrintOptions() - target 10 entity=[name=Chillwind Yeti id=37 zone=PLAY zonePos=1 cardId=CS2_182 player=2] error=REQ_TARGET_TAUNTER errorParam= +D 23:16:30.6003150 GameState.DebugPrintOptions() - target 11 entity=[name=Anduin Wrynn id=66 zone=PLAY zonePos=0 cardId=HERO_09 player=2] error=REQ_TARGET_TAUNTER errorParam= +D 23:16:30.6011520 GameState.DebugPrintOptions() - option 7 type=POWER mainEntity=[name=Flame Juggler id=26 zone=PLAY zonePos=4 cardId=AT_094 player=1] error=NONE errorParam= +D 23:16:30.6019770 GameState.DebugPrintOptions() - target 0 entity=[name=UNKNOWN ENTITY [cardType=INVALID] id=44 zone=HAND zonePos=1 cardId= player=2] error=NONE errorParam= +D 23:16:30.6027890 GameState.DebugPrintOptions() - target 1 entity=GameEntity error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.6034650 GameState.DebugPrintOptions() - target 2 entity=BehEh error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.6041340 GameState.DebugPrintOptions() - target 3 entity=The Innkeeper error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.6048310 GameState.DebugPrintOptions() - target 4 entity=[name=Tyrande Whisperwind id=64 zone=PLAY zonePos=0 cardId=HERO_09a player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.6057680 GameState.DebugPrintOptions() - target 5 entity=[name=Lesser Heal id=65 zone=PLAY zonePos=0 cardId=CS1h_001_H1 player=1] error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.6066080 GameState.DebugPrintOptions() - target 6 entity=[name=Lesser Heal id=67 zone=PLAY zonePos=0 cardId=CS1h_001 player=2] error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.6074100 GameState.DebugPrintOptions() - target 7 entity=[name=Friendly Bartender id=9 zone=PLAY zonePos=3 cardId=CFM_654 player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.6082410 GameState.DebugPrintOptions() - target 8 entity=[name=Flame Juggler id=26 zone=PLAY zonePos=4 cardId=AT_094 player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.6090360 GameState.DebugPrintOptions() - target 9 entity=[name=Friendly Bartender id=15 zone=PLAY zonePos=5 cardId=CFM_654 player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.6098350 GameState.DebugPrintOptions() - target 10 entity=[name=Chillwind Yeti id=37 zone=PLAY zonePos=1 cardId=CS2_182 player=2] error=REQ_TARGET_TAUNTER errorParam= +D 23:16:30.6106390 GameState.DebugPrintOptions() - target 11 entity=[name=Anduin Wrynn id=66 zone=PLAY zonePos=0 cardId=HERO_09 player=2] error=REQ_TARGET_TAUNTER errorParam= +D 23:16:30.6114800 GameState.DebugPrintOptions() - option 8 type=POWER mainEntity=[name=Friendly Bartender id=15 zone=PLAY zonePos=5 cardId=CFM_654 player=1] error=NONE errorParam= +D 23:16:30.6123260 GameState.DebugPrintOptions() - target 0 entity=[name=UNKNOWN ENTITY [cardType=INVALID] id=44 zone=HAND zonePos=1 cardId= player=2] error=NONE errorParam= +D 23:16:30.6130940 GameState.DebugPrintOptions() - target 1 entity=GameEntity error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.6137570 GameState.DebugPrintOptions() - target 2 entity=BehEh error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.6143980 GameState.DebugPrintOptions() - target 3 entity=The Innkeeper error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.6151260 GameState.DebugPrintOptions() - target 4 entity=[name=Tyrande Whisperwind id=64 zone=PLAY zonePos=0 cardId=HERO_09a player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.6159780 GameState.DebugPrintOptions() - target 5 entity=[name=Lesser Heal id=65 zone=PLAY zonePos=0 cardId=CS1h_001_H1 player=1] error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.6168520 GameState.DebugPrintOptions() - target 6 entity=[name=Lesser Heal id=67 zone=PLAY zonePos=0 cardId=CS1h_001 player=2] error=REQ_HERO_OR_MINION_TARGET errorParam= +D 23:16:30.6180250 GameState.DebugPrintOptions() - target 7 entity=[name=Friendly Bartender id=9 zone=PLAY zonePos=3 cardId=CFM_654 player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.6188420 GameState.DebugPrintOptions() - target 8 entity=[name=Flame Juggler id=26 zone=PLAY zonePos=4 cardId=AT_094 player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.6196650 GameState.DebugPrintOptions() - target 9 entity=[name=Friendly Bartender id=15 zone=PLAY zonePos=5 cardId=CFM_654 player=1] error=REQ_ENEMY_TARGET errorParam= +D 23:16:30.6204660 GameState.DebugPrintOptions() - target 10 entity=[name=Chillwind Yeti id=37 zone=PLAY zonePos=1 cardId=CS2_182 player=2] error=REQ_TARGET_TAUNTER errorParam= +D 23:16:30.6212910 GameState.DebugPrintOptions() - target 11 entity=[name=Anduin Wrynn id=66 zone=PLAY zonePos=0 cardId=HERO_09 player=2] error=REQ_TARGET_TAUNTER errorParam= +D 23:16:30.6222210 GameState.DebugPrintOptions() - option 9 type=POWER mainEntity=[name=Shadow Word: Death id=23 zone=HAND zonePos=2 cardId=EX1_622 player=1] error=REQ_TARGET_TO_PLAY errorParam= +D 23:16:30.6232190 GameState.DebugPrintOptions() - option 10 type=POWER mainEntity=[name=Ragnaros the Firelord id=16 zone=HAND zonePos=5 cardId=EX1_298 player=1] error=REQ_ENOUGH_MANA errorParam= +D 23:16:30.6242450 GameState.DebugPrintOptions() - option 11 type=POWER mainEntity=GameEntity error=REQ_YOUR_TURN errorParam= +D 23:16:30.6249860 GameState.DebugPrintOptions() - option 12 type=POWER mainEntity=The Innkeeper error=REQ_YOUR_TURN errorParam= +D 23:16:30.6257420 GameState.DebugPrintOptions() - option 13 type=POWER mainEntity=[name=Tyrande Whisperwind id=64 zone=PLAY zonePos=0 cardId=HERO_09a player=1] error=REQ_ATTACK_GREATER_THAN_0 errorParam= +D 23:16:30.6274730 GameState.DebugPrintOptions() - option 14 type=POWER mainEntity=[name=Anduin Wrynn id=66 zone=PLAY zonePos=0 cardId=HERO_09 player=2] error=REQ_YOUR_TURN errorParam= +D 23:16:30.6284990 GameState.DebugPrintOptions() - option 15 type=POWER mainEntity=[name=Lesser Heal id=67 zone=PLAY zonePos=0 cardId=CS1h_001 player=2] error=REQ_YOUR_TURN errorParam= +D 23:16:30.6293790 GameState.DebugPrintOptions() - option 16 type=POWER mainEntity=[name=Chillwind Yeti id=37 zone=PLAY zonePos=1 cardId=CS2_182 player=2] error=REQ_YOUR_TURN errorParam= +D 23:16:30.6303440 GameState.DebugPrintOptions() - option 17 type=POWER mainEntity=[name=UNKNOWN ENTITY [cardType=INVALID] id=44 zone=HAND zonePos=1 cardId= player=2] error=REQ_YOUR_TURN errorParam= +""".strip() diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..e216cd7 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,304 @@ +from datetime import datetime, time, timedelta +from io import StringIO + +import pytest +from aniso8601 import parse_datetime +from hearthstone.enums import ( + CardType, ChoiceType, GameTag, OptionType, PlayReq, PlayState, PowerType, + State, Step, Zone +) +from hslog import LogParser +from hslog.exceptions import ParsingError +from hslog.export import FriendlyPlayerExporter +from hslog.parser import parse_entity_id, parse_initial_tag +from .data import ( + CONTROLLER_CHANGE, EMPTY_GAME, FULL_ENTITY, INITIAL_GAME, INVALID_GAME, + OPTIONS_WITH_ERRORS, +) + + +def test_create_empty_game(): + parser = LogParser() + parser.read(StringIO(EMPTY_GAME)) + parser.flush() + + # Test resulting game/entities + assert len(parser.games) == 1 + + packet_tree = parser.games[0] + game = packet_tree.export().game + assert len(game.entities) == 3 + assert len(game.players) == 2 + assert game.entities[0] is game + assert game.entities[0].id == 1 + assert game.entities[1] is game.players[0] + assert game.entities[2] is game.players[1] + assert game.initial_state == State.INVALID + assert game.initial_step == Step.INVALID + + # Test player objects + assert game.players[0].id == 2 + assert game.players[0].player_id == 1 + assert game.players[0].account_hi == 1 + assert game.players[0].account_lo == 0 + assert game.players[0].is_ai + assert not game.players[0].name + + assert game.players[1].id == 3 + assert game.players[1].player_id == 2 + assert game.players[1].account_hi == 3 + assert game.players[1].account_lo == 2 + assert not game.players[1].is_ai + assert not game.players[1].name + + # Test packet structure + assert len(packet_tree.packets) == 1 + packet = packet_tree.packets[0] + assert packet.power_type == PowerType.CREATE_GAME + assert packet.entity == game.id == 1 + + # Player packet objects are not the same as players + assert int(packet.players[0].entity) == game.players[0].id + assert packet.players[0].player_id == game.players[0].player_id + assert int(packet.players[1].entity) == game.players[1].id + assert packet.players[1].player_id == game.players[1].player_id + + # All tags should be empty (we didn't pass any) + assert not game.tags + assert not game.players[0].tags + assert not game.players[1].tags + + # Check some basic logic + assert game.get_player(1) is game.players[0] + assert game.get_player(2) is game.players[1] + + +def test_tag_value_parsing(): + tag, value = parse_initial_tag("tag=ZONE value=PLAY") + assert tag == GameTag.ZONE + assert value == Zone.PLAY + + tag, value = parse_initial_tag("tag=CARDTYPE value=PLAYER") + assert tag == GameTag.CARDTYPE + assert value == CardType.PLAYER + + tag, value = parse_initial_tag("tag=1 value=2") + assert tag == 1 + assert value == 2 + + tag, value = parse_initial_tag("tag=9999998 value=123") + assert tag == 9999998 + assert value == 123 + + +def test_game_initialization(): + parser = LogParser() + parser.read(StringIO(INITIAL_GAME)) + parser.flush() + + assert len(parser.games) == 1 + packet_tree = parser.games[0] + game = packet_tree.export().game + assert len(game.entities) == 3 + assert len(game.players) == 2 + + assert game.tags == { + GameTag.TURN: 1, + GameTag.ZONE: Zone.PLAY, + GameTag.ENTITY_ID: 1, + GameTag.NEXT_STEP: Step.BEGIN_MULLIGAN, + GameTag.CARDTYPE: CardType.GAME, + GameTag.STATE: State.RUNNING, + } + assert game.initial_state == State.RUNNING + assert game.initial_step == Step.INVALID + + assert game.players[0].tags == { + GameTag.PLAYSTATE: PlayState.PLAYING, + GameTag.PLAYER_ID: 1, + GameTag.TEAM_ID: 1, + GameTag.ZONE: Zone.PLAY, + GameTag.CONTROLLER: 1, + GameTag.ENTITY_ID: 2, + GameTag.CARDTYPE: CardType.PLAYER, + } + + assert game.players[1].tags == { + GameTag.PLAYSTATE: PlayState.PLAYING, + GameTag.CURRENT_PLAYER: 1, + GameTag.FIRST_PLAYER: 1, + GameTag.PLAYER_ID: 2, + GameTag.TEAM_ID: 2, + GameTag.ZONE: Zone.PLAY, + GameTag.CONTROLLER: 2, + GameTag.ENTITY_ID: 3, + GameTag.CARDTYPE: CardType.PLAYER, + } + + # Test that there should be no friendly player + fpe = FriendlyPlayerExporter(packet_tree) + friendly_player = fpe.export() + assert not friendly_player + + +def test_timestamp_parsing(): + parser = LogParser() + parser.read(StringIO(INITIAL_GAME)) + parser.flush() + + assert parser.games[0].packets[0].ts == time(2, 59, 14, 608862) + + # Test with an initial datetime + parser2 = LogParser() + parser2._current_date = datetime(2015, 1, 1) + parser2.read(StringIO(INITIAL_GAME)) + parser2.flush() + + assert parser2.games[0].packets[0].ts == datetime(2015, 1, 1, 2, 59, 14, 608862) + + # Same test, with timezone + parser2 = LogParser() + parser2._current_date = parse_datetime("2015-01-01T02:58:00+0200") + parser2.read(StringIO(INITIAL_GAME)) + parser2.flush() + + ts = parser2.games[0].packets[0].ts + assert ts.year == 2015 + assert ts.hour == 2 + assert ts.second == 14 + assert ts.tzinfo + assert ts.utcoffset() == timedelta(hours=2) + + +def test_info_outside_of_metadata(): + parser = LogParser() + parser.read(StringIO(INITIAL_GAME)) + parser.flush() + + info = u"D 02:59:14.6500380 GameState.DebugPrintPower() - Info[0] = 99" + parser.read(StringIO(info)) + parser.flush() + + +def test_empty_entity_in_options(): + parser = LogParser() + parser.read(StringIO(INITIAL_GAME)) + parser.flush() + + data = "target 0 entity=" + with pytest.raises(ParsingError): + # This can happen, but the game is corrupt + parser.handle_options(None, data) + + +def test_warn_level(): + parser = LogParser() + parser.read(StringIO(INITIAL_GAME)) + parser.flush() + + line = u"W 09:09:23.1428700 GameState.ReportStuck() - Stuck for 10s 89ms. {...}" + parser.read(StringIO(line)) + parser.flush() + + +def test_empty_tasklist(): + parser = LogParser() + parser.read(StringIO(INITIAL_GAME)) + parser.flush() + + ts = datetime.now() + msg = "id=4 Player=The Innkeeper TaskList=1 ChoiceType=GENERAL CountMin=1 CountMax=1" + choices = parser.handle_entity_choices(ts, msg) + assert choices + assert choices.id == 4 + assert choices.player.name == "The Innkeeper" + assert choices.tasklist == 1 + assert choices.type == ChoiceType.GENERAL + assert choices.min == 1 + assert choices.max == 1 + + # Test empty tasklist + msg = "id=4 Player=The Innkeeper TaskList= ChoiceType=GENERAL CountMin=1 CountMax=1" + choices = parser.handle_entity_choices(ts, msg) + assert choices.tasklist is None + + +def test_tag_change_unknown_entity_format(): + # Format changed in 15590 + parser = LogParser() + parser.read(StringIO(INITIAL_GAME)) + parser.flush() + + entity_format = ( + "[name=UNKNOWN ENTITY [cardType=INVALID] id=24 zone=DECK zonePos=0 cardId= player=1]" + ) + id = parse_entity_id(entity_format) + assert id == 24 + + data = "TAG_CHANGE Entity=%s tag=ZONE value=HAND" % (entity_format) + packet = parser.handle_power(None, "TAG_CHANGE", data) + assert packet.power_type == PowerType.TAG_CHANGE + assert packet.entity == id + assert packet.tag == GameTag.ZONE + assert packet.value == Zone.HAND + + +def test_initial_deck_initial_controller(): + parser = LogParser() + parser.read(StringIO(INITIAL_GAME)) + parser.read(StringIO(FULL_ENTITY)) + parser.flush() + packet_tree = parser.games[0] + game = packet_tree.export().game + + assert len(list(game.players[0].initial_deck)) == 1 + assert len(list(game.players[1].initial_deck)) == 0 + + parser = LogParser() + parser.read(StringIO(INITIAL_GAME)) + parser.read(StringIO(FULL_ENTITY)) + parser.read(StringIO(CONTROLLER_CHANGE)) + parser.flush() + packet_tree = parser.games[0] + game = packet_tree.export().game + + assert len(list(game.players[0].initial_deck)) == 1 + assert len(list(game.players[1].initial_deck)) == 0 + + +def test_invalid_game_one_player(): + parser = LogParser() + with pytest.raises(ParsingError): + parser.read(StringIO(INVALID_GAME)) + + +def test_options_packet_with_errors(): + parser = LogParser() + parser.read(StringIO(INITIAL_GAME)) + + parser.read(StringIO(OPTIONS_WITH_ERRORS)) + parser.flush() + packet_tree = parser.games[0] + + options_packet = packet_tree.packets[-1] + + op0 = options_packet.options[0] + assert op0.id == 0 + assert op0.type == OptionType.END_TURN + assert op0.entity is None + assert op0.error == PlayReq.INVALID + assert op0.error_param is None + + op1 = options_packet.options[1] + assert op1.id == 1 + assert op1.type == OptionType.POWER + assert op1.entity == 33 + assert op1.error is None + assert op1.error_param is None + + assert len(op1.options) == 12 + target = op1.options[11] + assert target.id == 11 + assert target.entity == 37 + assert target.error == PlayReq.REQ_TARGET_MAX_ATTACK + assert target.error_param == 3